mirror of
https://github.com/home-assistant/core.git
synced 2026-11-06 13:41:27 -04:00
Improve Anthropic coverage (#163011)
This commit is contained in:
@@ -21,16 +21,6 @@ from anthropic.types.raw_message_delta_event import Delta
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.anthropic.const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
)
|
||||
@@ -84,79 +74,6 @@ def mock_config_entry_with_assist(
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry_with_extended_thinking(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry with extended thinking."""
|
||||
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-3-7-sonnet-latest",
|
||||
CONF_THINKING_BUDGET: 1500,
|
||||
},
|
||||
)
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry_with_adaptive_thinking(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry with adaptive thinking."""
|
||||
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-opus-4-6",
|
||||
CONF_THINKING_EFFORT: "medium",
|
||||
},
|
||||
)
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry_with_web_search(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry with server tools enabled."""
|
||||
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-sonnet-4-5",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
CONF_WEB_SEARCH_CITY: "San Francisco",
|
||||
CONF_WEB_SEARCH_REGION: "California",
|
||||
CONF_WEB_SEARCH_COUNTRY: "US",
|
||||
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
|
||||
},
|
||||
)
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry_with_no_structured_output(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry with a model without structured outputs support."""
|
||||
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",
|
||||
},
|
||||
)
|
||||
return mock_config_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_init_component(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# serializer version: 1
|
||||
# name: test_generate_structured_data
|
||||
dict({
|
||||
'max_tokens': 3000,
|
||||
'messages': list([
|
||||
dict({
|
||||
'content': 'Generate test data',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
dict({
|
||||
'text': '{"characters": ["Mario", "Luigi"]}',
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'role': 'assistant',
|
||||
}),
|
||||
]),
|
||||
'model': 'claude-haiku-4-5',
|
||||
'output_config': dict({
|
||||
'format': dict({
|
||||
'schema': dict({
|
||||
'additionalProperties': False,
|
||||
'properties': dict({
|
||||
'characters': dict({
|
||||
'items': dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
'type': 'array',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'characters',
|
||||
]),
|
||||
'type': 'object',
|
||||
}),
|
||||
'type': 'json_schema',
|
||||
}),
|
||||
}),
|
||||
'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',
|
||||
}),
|
||||
]),
|
||||
'temperature': 1.0,
|
||||
'thinking': dict({
|
||||
'type': 'disabled',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_generate_structured_data_legacy
|
||||
dict({
|
||||
'max_tokens': 3000,
|
||||
'messages': list([
|
||||
dict({
|
||||
'content': 'Generate test data',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
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',
|
||||
}),
|
||||
]),
|
||||
'temperature': 1.0,
|
||||
'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({
|
||||
'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({
|
||||
'max_tokens': 3000,
|
||||
'messages': list([
|
||||
dict({
|
||||
'content': 'Generate test data',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
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,
|
||||
'type': 'enabled',
|
||||
}),
|
||||
'tool_choice': dict({
|
||||
'type': 'auto',
|
||||
}),
|
||||
'tools': list([
|
||||
dict({
|
||||
'description': 'Use this tool to reply to the user',
|
||||
'input_schema': dict({
|
||||
'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({
|
||||
'max_tokens': 3000,
|
||||
'messages': list([
|
||||
dict({
|
||||
'content': 'Generate test data',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
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',
|
||||
}),
|
||||
]),
|
||||
'temperature': 1.0,
|
||||
'thinking': dict({
|
||||
'type': 'disabled',
|
||||
}),
|
||||
'tool_choice': dict({
|
||||
'type': 'any',
|
||||
}),
|
||||
'tools': list([
|
||||
dict({
|
||||
'max_uses': None,
|
||||
'name': 'web_search',
|
||||
'type': 'web_search_20250305',
|
||||
}),
|
||||
dict({
|
||||
'description': 'Use this tool to reply to the user',
|
||||
'input_schema': dict({
|
||||
'properties': dict({
|
||||
'characters': dict({
|
||||
'items': dict({
|
||||
'type': 'string',
|
||||
}),
|
||||
'type': 'array',
|
||||
}),
|
||||
}),
|
||||
'required': list([
|
||||
'characters',
|
||||
]),
|
||||
'type': 'object',
|
||||
}),
|
||||
'name': 'test_task',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
@@ -1,4 +1,118 @@
|
||||
# serializer version: 1
|
||||
# name: test_disabled_thinking
|
||||
list([
|
||||
dict({
|
||||
'content': '''
|
||||
You are a voice assistant for Home Assistant.
|
||||
Answer questions about the world truthfully.
|
||||
Answer in plain text. Keep it simple and to the point.
|
||||
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
|
||||
''',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'system',
|
||||
}),
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'hello',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': 'Hello, how can I help you today?',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_disabled_thinking.1
|
||||
dict({
|
||||
'max_tokens': 3000,
|
||||
'messages': list([
|
||||
dict({
|
||||
'content': 'hello',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
dict({
|
||||
'text': 'Hello, how can I help you today?',
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'role': 'assistant',
|
||||
}),
|
||||
]),
|
||||
'model': 'claude-opus-4-6',
|
||||
'stream': True,
|
||||
'system': list([
|
||||
dict({
|
||||
'cache_control': dict({
|
||||
'type': 'ephemeral',
|
||||
}),
|
||||
'text': '''
|
||||
You are a voice assistant for Home Assistant.
|
||||
Answer questions about the world truthfully.
|
||||
Answer in plain text. Keep it simple and to the point.
|
||||
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
|
||||
''',
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'temperature': 1.0,
|
||||
'thinking': dict({
|
||||
'type': 'disabled',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_extended_thinking
|
||||
dict({
|
||||
'max_tokens': 3000,
|
||||
'messages': list([
|
||||
dict({
|
||||
'content': 'hello',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
dict({
|
||||
'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
|
||||
'thinking': 'The user has just greeted me with "Hi". This is a simple greeting and doesn\'t require any Home Assistant function calls. I should respond with a friendly greeting and let them know I\'m available to help with their smart home.',
|
||||
'type': 'thinking',
|
||||
}),
|
||||
dict({
|
||||
'text': 'Hello, how can I help you today?',
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'role': 'assistant',
|
||||
}),
|
||||
]),
|
||||
'model': 'claude-3-7-sonnet-latest',
|
||||
'stream': True,
|
||||
'system': list([
|
||||
dict({
|
||||
'cache_control': dict({
|
||||
'type': 'ephemeral',
|
||||
}),
|
||||
'text': '''
|
||||
You are a voice assistant for Home Assistant.
|
||||
Answer questions about the world truthfully.
|
||||
Answer in plain text. Keep it simple and to the point.
|
||||
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
|
||||
''',
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'thinking': dict({
|
||||
'budget_tokens': 1500,
|
||||
'type': 'enabled',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_extended_thinking_tool_call
|
||||
list([
|
||||
dict({
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ai_task, media_source
|
||||
from homeassistant.components.anthropic.const import CONF_CHAT_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
@@ -51,13 +54,45 @@ async def test_generate_data(
|
||||
assert result.data == "The test data"
|
||||
|
||||
|
||||
async def test_generate_structured_data_legacy(
|
||||
async def test_empty_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_no_structured_output: MockConfigEntry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test AI Task data generation but the data returned is empty."""
|
||||
mock_create_stream.return_value = [create_content_block(0, [""])]
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="Last content in chat log is not an AssistantContent"
|
||||
):
|
||||
await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.claude_ai_task",
|
||||
instructions="Generate test data",
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2026-01-01 12:00:00")
|
||||
async def test_generate_structured_data_legacy(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
mock_create_stream.return_value = [
|
||||
create_tool_use_block(
|
||||
1,
|
||||
@@ -86,15 +121,122 @@ async def test_generate_structured_data_legacy(
|
||||
)
|
||||
|
||||
assert result.data == {"characters": ["Mario", "Luigi"]}
|
||||
assert mock_create_stream.call_args.kwargs.copy() == snapshot
|
||||
|
||||
|
||||
@freeze_time("2026-01-01 12:00:00")
|
||||
async def test_generate_structured_data_legacy_tools(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
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(
|
||||
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", "web_search": True},
|
||||
)
|
||||
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.claude_ai_task",
|
||||
instructions="Generate test data",
|
||||
structure=vol.Schema(
|
||||
{
|
||||
vol.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")
|
||||
async def test_generate_structured_data_legacy_extended_thinking(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test AI Task structured data generation with legacy method and extended_thinking."""
|
||||
mock_create_stream.return_value = [
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.claude_ai_task",
|
||||
instructions="Generate test data",
|
||||
structure=vol.Schema(
|
||||
{
|
||||
vol.Required("characters"): selector.selector(
|
||||
{
|
||||
"text": {
|
||||
"multiple": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert result.data == {"characters": ["Mario", "Luigi"]}
|
||||
assert mock_create_stream.call_args.kwargs.copy() == snapshot
|
||||
|
||||
|
||||
async def test_generate_invalid_structured_data_legacy(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_no_structured_output: MockConfigEntry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
mock_create_stream.return_value = [
|
||||
create_tool_use_block(
|
||||
1,
|
||||
@@ -126,11 +268,13 @@ async def test_generate_invalid_structured_data_legacy(
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2026-01-01 12:00:00")
|
||||
async def test_generate_structured_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test AI Task structured data generation."""
|
||||
mock_create_stream.return_value = [
|
||||
@@ -156,6 +300,7 @@ async def test_generate_structured_data(
|
||||
)
|
||||
|
||||
assert result.data == {"characters": ["Mario", "Luigi"]}
|
||||
assert mock_create_stream.call_args.kwargs.copy() == snapshot
|
||||
|
||||
|
||||
async def test_generate_data_with_attachments(
|
||||
@@ -177,7 +322,7 @@ async def test_generate_data_with_attachments(
|
||||
side_effect=[
|
||||
media_source.PlayMedia(
|
||||
url="http://example.com/doorbell_snapshot.jpg",
|
||||
mime_type="image/jpeg",
|
||||
mime_type="image/jpg",
|
||||
path=Path("doorbell_snapshot.jpg"),
|
||||
),
|
||||
media_source.PlayMedia(
|
||||
@@ -188,10 +333,6 @@ async def test_generate_data_with_attachments(
|
||||
],
|
||||
),
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch(
|
||||
"homeassistant.components.openai_conversation.entity.guess_file_type",
|
||||
return_value=("image/jpeg", None),
|
||||
),
|
||||
patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
|
||||
):
|
||||
result = await ai_task.async_generate_data(
|
||||
@@ -242,3 +383,75 @@ async def test_generate_data_with_attachments(
|
||||
assert document_block["source"]["data"] == "ZmFrZV9pbWFnZV9kYXRh"
|
||||
assert document_block["source"]["media_type"] == "application/pdf"
|
||||
assert document_block["source"]["type"] == "base64"
|
||||
|
||||
|
||||
async def test_generate_data_invalid_attachments(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test AI Task data generation with attachments of unsupported type."""
|
||||
entity_id = "ai_task.claude_ai_task"
|
||||
|
||||
mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])]
|
||||
|
||||
# Test path that doesn't exist
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.media_source.async_resolve_media",
|
||||
side_effect=[
|
||||
media_source.PlayMedia(
|
||||
url="http://example.com/doorbell_snapshot.jpg",
|
||||
mime_type="image/jpeg",
|
||||
path=Path("doorbell_snapshot.jpg"),
|
||||
)
|
||||
],
|
||||
),
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
pytest.raises(
|
||||
HomeAssistantError, match="`doorbell_snapshot.jpg` does not exist"
|
||||
),
|
||||
):
|
||||
await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Test prompt",
|
||||
attachments=[
|
||||
{"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
|
||||
],
|
||||
)
|
||||
|
||||
# Test unsupported file type
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.media_source.async_resolve_media",
|
||||
side_effect=[
|
||||
media_source.PlayMedia(
|
||||
url="http://example.com/doorbell_snapshot.txt",
|
||||
mime_type=None,
|
||||
path=Path("doorbell_snapshot.txt"),
|
||||
)
|
||||
],
|
||||
),
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch(
|
||||
"homeassistant.components.anthropic.entity.guess_file_type",
|
||||
return_value=("text/plain", None),
|
||||
),
|
||||
pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="Only images and PDF are supported by the Anthropic API",
|
||||
),
|
||||
):
|
||||
await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Test prompt",
|
||||
attachments=[
|
||||
{"media_content_id": "media-source://media/doorbell_snapshot.txt"},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -18,6 +18,18 @@ from syrupy.assertion import SnapshotAssertion
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.anthropic.const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_THINKING_BUDGET,
|
||||
CONF_THINKING_EFFORT,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_MAX_USES,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
)
|
||||
from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
@@ -517,11 +529,22 @@ async def test_refusal(
|
||||
|
||||
async def test_extended_thinking(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_extended_thinking: MockConfigEntry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test extended thinking support."""
|
||||
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-3-7-sonnet-latest",
|
||||
CONF_THINKING_BUDGET: 1500,
|
||||
},
|
||||
)
|
||||
|
||||
mock_create_stream.return_value = [
|
||||
(
|
||||
*create_thinking_block(
|
||||
@@ -550,12 +573,52 @@ async def test_extended_thinking(
|
||||
assert len(chat_log.content) == 3
|
||||
assert chat_log.content[1].content == "hello"
|
||||
assert chat_log.content[2].content == "Hello, how can I help you today?"
|
||||
call_args = mock_create_stream.call_args.kwargs.copy()
|
||||
call_args.pop("tools", None)
|
||||
assert call_args == snapshot
|
||||
|
||||
|
||||
@freeze_time("2024-05-24 12:00:00")
|
||||
async def test_disabled_thinking(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test conversation with thinking effort disabled."""
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
next(iter(mock_config_entry.subentries.values())),
|
||||
data={
|
||||
CONF_LLM_HASS_API: "assist",
|
||||
CONF_CHAT_MODEL: "claude-opus-4-6",
|
||||
CONF_THINKING_EFFORT: "none",
|
||||
},
|
||||
)
|
||||
|
||||
mock_create_stream.return_value = [
|
||||
create_content_block(1, ["Hello, how can I help you today?"])
|
||||
]
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
|
||||
)
|
||||
|
||||
chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get(
|
||||
result.conversation_id
|
||||
)
|
||||
assert len(chat_log.content) == 3
|
||||
assert chat_log.content == snapshot
|
||||
call_args = mock_create_stream.call_args.kwargs.copy()
|
||||
call_args.pop("tools", None)
|
||||
assert call_args == snapshot
|
||||
|
||||
|
||||
@freeze_time("2024-05-24 12:00:00")
|
||||
async def test_redacted_thinking(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_extended_thinking: MockConfigEntry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
@@ -590,12 +653,22 @@ async def test_redacted_thinking(
|
||||
async def test_extended_thinking_tool_call(
|
||||
mock_get_tools,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_adaptive_thinking: MockConfigEntry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test that thinking blocks and their order are preserved in with tool calls."""
|
||||
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-opus-4-6",
|
||||
CONF_THINKING_EFFORT: "medium",
|
||||
},
|
||||
)
|
||||
|
||||
agent_id = "conversation.claude_conversation"
|
||||
context = Context()
|
||||
|
||||
@@ -658,12 +731,28 @@ async def test_extended_thinking_tool_call(
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_web_search(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_web_search: MockConfigEntry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test web search."""
|
||||
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-sonnet-4-5",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
CONF_WEB_SEARCH_CITY: "San Francisco",
|
||||
CONF_WEB_SEARCH_REGION: "California",
|
||||
CONF_WEB_SEARCH_COUNTRY: "US",
|
||||
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
|
||||
},
|
||||
)
|
||||
|
||||
web_search_results = [
|
||||
WebSearchResultBlock(
|
||||
type="web_search_result",
|
||||
|
||||
@@ -12,11 +12,11 @@ from anthropic import (
|
||||
from httpx import URL, Request, Response
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.anthropic.const import DOMAIN
|
||||
from homeassistant.components.anthropic.const import DATA_REPAIR_DEFER_RELOAD, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er, llm
|
||||
from homeassistant.helpers.device_registry import DeviceEntryDisabler
|
||||
from homeassistant.helpers.entity_registry import RegistryEntryDisabler
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -70,6 +70,113 @@ async def test_init_error(
|
||||
assert error in caplog.text
|
||||
|
||||
|
||||
async def test_deferred_update(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
) -> None:
|
||||
"""Test that update is deferred."""
|
||||
for subentry in mock_config_entry.subentries.values():
|
||||
if subentry.subentry_type == "conversation":
|
||||
conversation_subentry = subentry
|
||||
elif subentry.subentry_type == "ai_task_data":
|
||||
ai_task_subentry = subentry
|
||||
|
||||
old_client = mock_config_entry.runtime_data
|
||||
|
||||
# Set deferred update
|
||||
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
|
||||
DATA_REPAIR_DEFER_RELOAD, set()
|
||||
)
|
||||
defer_reload_entries.add(mock_config_entry.entry_id)
|
||||
|
||||
# Update the conversation subentry
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
conversation_subentry,
|
||||
data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify that the entry is not reloaded yet
|
||||
assert mock_config_entry.runtime_data is old_client
|
||||
|
||||
# Clear deferred update
|
||||
defer_reload_entries.discard(mock_config_entry.entry_id)
|
||||
|
||||
# Update the AI Task subentry
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
ai_task_subentry,
|
||||
data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify that the entry is reloaded
|
||||
assert mock_config_entry.runtime_data is not old_client
|
||||
|
||||
|
||||
async def test_downgrade_from_v3_to_v2(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test that migration from future versions is skipped."""
|
||||
# Create a v3 config entry
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_API_KEY: "test-api-key"},
|
||||
version=3,
|
||||
minor_version=0,
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {
|
||||
"recommended": True,
|
||||
"llm_hass_api": ["assist"],
|
||||
"prompt": "You are a helpful assistant",
|
||||
"chat_model": "claude-3-haiku-20240307",
|
||||
},
|
||||
"subentry_id": "mock_id",
|
||||
"subentry_type": "conversation",
|
||||
"title": "Claude haiku",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
conversation_device = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
config_subentry_id="mock_id",
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)},
|
||||
name=mock_config_entry.title,
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
mock_config_entry.entry_id,
|
||||
config_entry=mock_config_entry,
|
||||
config_subentry_id="mock_id",
|
||||
device_id=conversation_device.id,
|
||||
suggested_object_id="claude",
|
||||
)
|
||||
|
||||
# Run migration
|
||||
with patch(
|
||||
"homeassistant.components.anthropic.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify migration was skipped and version was not updated
|
||||
assert mock_config_entry.version == 3
|
||||
assert mock_config_entry.minor_version == 0
|
||||
|
||||
|
||||
async def test_migration_from_v1_to_v2(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
|
||||
Reference in New Issue
Block a user