Files
core/tests/components/openai_conversation/test_init.py
T

1457 lines
48 KiB
Python

"""Tests for the OpenAI integration."""
from typing import Any
from unittest.mock import AsyncMock, patch
import attr
import httpx
from openai import APIConnectionError, AuthenticationError, BadRequestError
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.components.openai_conversation import CONF_CHAT_MODEL
from homeassistant.components.openai_conversation.const import (
CONF_REASONING_SUMMARY,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_STT_NAME,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TTS_OPTIONS,
)
from homeassistant.config_entries import (
ConfigEntryDisabler,
ConfigEntryState,
ConfigSubentryData,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryDisabler
from homeassistant.helpers.entity_registry import RegistryEntryDisabler
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("side_effect", "error"),
[
(
APIConnectionError(request=httpx.Request(method="GET", url="test")),
"Connection error",
),
(
BadRequestError(
response=httpx.Response(
status_code=500, request=httpx.Request(method="GET", url="test")
),
body=None,
message="",
),
"openai_conversation integration not ready yet",
),
],
)
async def test_init_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
side_effect: APIConnectionError | BadRequestError,
error: str,
) -> None:
"""Test initialization errors."""
with patch(
"openai.resources.models.AsyncModels.list",
new_callable=AsyncMock,
side_effect=side_effect,
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert error in caplog.text
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_init_auth_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test auth error during init errors."""
with patch(
"openai.resources.models.AsyncModels.list",
new_callable=AsyncMock,
side_effect=AuthenticationError(
response=httpx.Response(
status_code=500, request=httpx.Request(method="GET", url="test")
),
body=None,
message="",
),
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_migration_from_v1(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 1 to version 2."""
# Create a v1 config entry with conversation options and an entity
OPTIONS = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
options=OPTIONS,
version=1,
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity = entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry.entry_id,
config_entry=mock_config_entry,
device_id=device.id,
suggested_object_id="chatgpt",
)
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.version == 2
assert mock_config_entry.minor_version == 7
assert mock_config_entry.data == {"api_key": "1234"}
assert mock_config_entry.options == {}
assert len(mock_config_entry.subentries) == 4
# Find the subentries
conversation_subentry = None
ai_task_subentry = None
stt_subentry = None
tts_subentry = None
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
elif subentry.subentry_type == "stt":
stt_subentry = subentry
elif subentry.subentry_type == "tts":
tts_subentry = subentry
assert conversation_subentry is not None
assert conversation_subentry.unique_id is None
assert conversation_subentry.title == "ChatGPT"
assert conversation_subentry.subentry_type == "conversation"
assert conversation_subentry.data == OPTIONS
assert ai_task_subentry is not None
assert ai_task_subentry.unique_id is None
assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME
assert ai_task_subentry.subentry_type == "ai_task_data"
assert stt_subentry is not None
assert stt_subentry.unique_id is None
assert stt_subentry.title == DEFAULT_STT_NAME
assert stt_subentry.subentry_type == "stt"
assert tts_subentry is not None
assert tts_subentry.unique_id is None
assert tts_subentry.title == DEFAULT_TTS_NAME
assert tts_subentry.subentry_type == "tts"
# Use conversation subentry for the rest of the assertions
subentry = conversation_subentry
migrated_entity = entity_registry.async_get(entity.entity_id)
assert migrated_entity is not None
assert migrated_entity.config_entry_id == mock_config_entry.entry_id
assert migrated_entity.config_subentry_id == subentry.subentry_id
assert migrated_entity.unique_id == subentry.subentry_id
# Check device migration
assert not device_registry.async_get_devices(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
assert (
migrated_device := device_registry.async_get_device_by_identifier(
(DOMAIN, subentry.subentry_id), mock_config_entry.entry_id
)
)
assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert migrated_device.id == device.id
assert migrated_device.config_entry_id == mock_config_entry.entry_id
assert migrated_device.config_subentry_id == subentry.subentry_id
async def test_migration_from_v1_with_multiple_keys(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 1 with different API keys."""
# Create two v1 config entries with different API keys
options = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
options=options,
version=1,
title="ChatGPT 1",
)
mock_config_entry.add_to_hass(hass)
mock_config_entry_2 = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "12345"},
options=options,
version=1,
title="ChatGPT 2",
)
mock_config_entry_2.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="OpenAI",
model="ChatGPT 1",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry.entry_id,
config_entry=mock_config_entry,
device_id=device.id,
suggested_object_id="chatgpt_1",
)
device_2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry_2.entry_id,
identifiers={(DOMAIN, mock_config_entry_2.entry_id)},
name=mock_config_entry_2.title,
manufacturer="OpenAI",
model="ChatGPT 2",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry_2.entry_id,
config_entry=mock_config_entry_2,
device_id=device_2.id,
suggested_object_id="chatgpt_2",
)
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 2
for idx, entry in enumerate(entries):
assert entry.version == 2
assert entry.minor_version == 7
assert not entry.options
assert len(entry.subentries) == 4
conversation_subentry = None
for subentry in entry.subentries.values():
if subentry.subentry_type == "conversation":
conversation_subentry = subentry
break
assert conversation_subentry is not None
assert conversation_subentry.subentry_type == "conversation"
assert conversation_subentry.data == options
assert conversation_subentry.title == f"ChatGPT {idx + 1}"
# Use conversation subentry for device assertions
subentry = conversation_subentry
dev = device_registry.async_get_device_by_identifier(
(DOMAIN, subentry.subentry_id), entry.entry_id
)
assert dev is not None
assert dev.config_entry_id == entry.entry_id
assert dev.config_subentry_id == subentry.subentry_id
async def test_migration_from_v1_with_same_keys(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 1 with same API keys consolidates entries."""
# Create two v1 config entries with the same API key
options = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
options=options,
version=1,
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
mock_config_entry_2 = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"}, # Same API key
options=options,
version=1,
title="ChatGPT 2",
)
mock_config_entry_2.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry.entry_id,
config_entry=mock_config_entry,
device_id=device.id,
suggested_object_id="chatgpt",
)
device_2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry_2.entry_id,
identifiers={(DOMAIN, mock_config_entry_2.entry_id)},
name=mock_config_entry_2.title,
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry_2.entry_id,
config_entry=mock_config_entry_2,
device_id=device_2.id,
suggested_object_id="chatgpt_2",
)
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
# Should have only one entry left (consolidated)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 7
assert not entry.options
assert (
len(entry.subentries) == 5
) # 2 conversation + 1 AI task + 1 STT + 1 TTS subentry
# Check both conversation subentries exist with correct data
conversation_subentries = [
sub for sub in entry.subentries.values() if sub.subentry_type == "conversation"
]
ai_task_subentries = [
sub for sub in entry.subentries.values() if sub.subentry_type == "ai_task_data"
]
stt_subentries = [
sub for sub in entry.subentries.values() if sub.subentry_type == "stt"
]
tts_subentries = [
sub for sub in entry.subentries.values() if sub.subentry_type == "tts"
]
assert len(conversation_subentries) == 2
assert len(ai_task_subentries) == 1
assert len(stt_subentries) == 1
assert len(tts_subentries) == 1
titles = [sub.title for sub in conversation_subentries]
assert "ChatGPT" in titles
assert "ChatGPT 2" in titles
for subentry in conversation_subentries:
assert subentry.subentry_type == "conversation"
assert subentry.data == options
# Check devices were migrated correctly
dev = device_registry.async_get_device_by_identifier(
(DOMAIN, subentry.subentry_id), mock_config_entry.entry_id
)
assert dev is not None
assert dev.config_entry_id == mock_config_entry.entry_id
assert dev.config_subentry_id == subentry.subentry_id
@pytest.mark.parametrize(
(
"config_entry_disabled_by",
"device_disabled_by",
"entity_disabled_by",
"merged_config_entry_disabled_by",
"conversation_subentry_data",
"main_config_entry",
),
[
(
[ConfigEntryDisabler.USER, None],
[DeviceEntryDisabler.CONFIG_ENTRY, None],
[RegistryEntryDisabler.CONFIG_ENTRY, None],
None,
[
{
"conversation_entity_id": "conversation.chatgpt_2",
"device_disabled_by": None,
"entity_disabled_by": None,
"device": 1,
},
{
"conversation_entity_id": "conversation.chatgpt",
"device_disabled_by": DeviceEntryDisabler.USER,
"entity_disabled_by": RegistryEntryDisabler.DEVICE,
"device": 0,
},
],
1,
),
(
[None, ConfigEntryDisabler.USER],
[None, DeviceEntryDisabler.CONFIG_ENTRY],
[None, RegistryEntryDisabler.CONFIG_ENTRY],
None,
[
{
"conversation_entity_id": "conversation.chatgpt",
"device_disabled_by": None,
"entity_disabled_by": None,
"device": 0,
},
{
"conversation_entity_id": "conversation.chatgpt_2",
"device_disabled_by": DeviceEntryDisabler.USER,
"entity_disabled_by": RegistryEntryDisabler.DEVICE,
"device": 1,
},
],
0,
),
(
[ConfigEntryDisabler.USER, ConfigEntryDisabler.USER],
[DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY],
[RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY],
ConfigEntryDisabler.USER,
[
{
"conversation_entity_id": "conversation.chatgpt",
"device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY,
"entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY,
"device": 0,
},
{
"conversation_entity_id": "conversation.chatgpt_2",
"device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY,
"entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY,
"device": 1,
},
],
0,
),
],
)
async def test_migration_from_v1_disabled(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
config_entry_disabled_by: list[ConfigEntryDisabler | None],
device_disabled_by: list[DeviceEntryDisabler | None],
entity_disabled_by: list[RegistryEntryDisabler | None],
merged_config_entry_disabled_by: ConfigEntryDisabler | None,
conversation_subentry_data: list[dict[str, Any]],
main_config_entry: int,
) -> None:
"""Test migration where the config entries are disabled."""
# Create a v1 config entry with conversation options and an entity
options = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_API_KEY: "1234"},
options=options,
version=1,
title="ChatGPT",
disabled_by=config_entry_disabled_by[0],
)
mock_config_entry.add_to_hass(hass)
mock_config_entry_2 = MockConfigEntry(
domain=DOMAIN,
data={CONF_API_KEY: "1234"},
options=options,
version=1,
title="ChatGPT 2",
disabled_by=config_entry_disabled_by[1],
)
mock_config_entry_2.add_to_hass(hass)
mock_config_entries = [mock_config_entry, mock_config_entry_2]
device_1 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
disabled_by=device_disabled_by[0],
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry.entry_id,
config_entry=mock_config_entry,
device_id=device_1.id,
suggested_object_id="chatgpt",
disabled_by=entity_disabled_by[0],
)
device_2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry_2.entry_id,
identifiers={(DOMAIN, mock_config_entry_2.entry_id)},
name=mock_config_entry_2.title,
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
disabled_by=device_disabled_by[1],
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry_2.entry_id,
config_entry=mock_config_entry_2,
device_id=device_2.id,
suggested_object_id="chatgpt_2",
disabled_by=entity_disabled_by[1],
)
devices = [device_1, device_2]
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.disabled_by is merged_config_entry_disabled_by
assert entry.version == 2
assert entry.minor_version == (
4 if merged_config_entry_disabled_by is not None else 7
)
assert not entry.options
assert entry.title == "OpenAI Conversation"
assert len(entry.subentries) == (3 if entry.minor_version == 4 else 5)
conversation_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "conversation"
]
assert len(conversation_subentries) == 2
for subentry in conversation_subentries:
assert subentry.subentry_type == "conversation"
assert subentry.data == options
assert "ChatGPT" in subentry.title
ai_task_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "ai_task_data"
]
assert len(ai_task_subentries) == 1
assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS
assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME
stt_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "stt"
]
tts_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "tts"
]
# pylint: disable-next=home-assistant-test-non-deterministic
if entry.minor_version == 4:
assert len(stt_subentries) == 0
assert len(tts_subentries) == 0
else:
assert len(stt_subentries) == 1
assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS
assert stt_subentries[0].title == DEFAULT_STT_NAME
assert len(tts_subentries) == 1
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
assert tts_subentries[0].title == DEFAULT_TTS_NAME
assert not device_registry.async_get_devices(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
assert not device_registry.async_get_devices(
identifiers={(DOMAIN, mock_config_entry_2.entry_id)}
)
for idx, subentry in enumerate(conversation_subentries):
subentry_data = conversation_subentry_data[idx]
entity = entity_registry.async_get(subentry_data["conversation_entity_id"])
assert entity.unique_id == subentry.subentry_id
assert entity.config_subentry_id == subentry.subentry_id
assert entity.config_entry_id == entry.entry_id
assert entity.disabled_by is subentry_data["entity_disabled_by"]
assert (
device := device_registry.async_get_device_by_identifier(
(DOMAIN, subentry.subentry_id),
mock_config_entries[main_config_entry].entry_id,
)
)
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert device.id == devices[subentry_data["device"]].id
assert device.config_entry_id == mock_config_entries[main_config_entry].entry_id
assert device.config_subentry_id == subentry.subentry_id
assert device.disabled_by is subentry_data["device_disabled_by"]
async def test_migration_from_v2_1(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 2.1."""
# Create a v2.1 config entry with 2 subentries, devices and entities
options = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
entry_id="mock_entry_id",
version=2,
minor_version=1,
subentries_data=[
ConfigSubentryData(
data=options,
subentry_id="mock_id_1",
subentry_type="conversation",
title="ChatGPT",
unique_id=None,
),
ConfigSubentryData(
data=options,
subentry_id="mock_id_2",
subentry_type="conversation",
title="ChatGPT 2",
unique_id=None,
),
],
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
device_1 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
config_subentry_id="mock_id_1",
identifiers={(DOMAIN, "mock_id_1")},
name="ChatGPT",
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
"mock_id_1",
config_entry=mock_config_entry,
config_subentry_id="mock_id_1",
device_id=device_1.id,
suggested_object_id="chatgpt",
)
device_2 = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
config_subentry_id="mock_id_2",
identifiers={(DOMAIN, "mock_id_2")},
name="ChatGPT 2",
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
"mock_id_2",
config_entry=mock_config_entry,
config_subentry_id="mock_id_2",
device_id=device_2.id,
suggested_object_id="chatgpt_2",
)
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 5 # 2 conversation + 1 AI task + 1 STT + 1 TTS
conversation_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "conversation"
]
ai_task_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "ai_task_data"
]
stt_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "stt"
]
tts_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "tts"
]
assert len(conversation_subentries) == 2
assert len(ai_task_subentries) == 1
assert len(stt_subentries) == 1
assert len(tts_subentries) == 1
for subentry in conversation_subentries:
assert subentry.subentry_type == "conversation"
assert subentry.data == options
assert "ChatGPT" in subentry.title
subentry = conversation_subentries[0]
entity = entity_registry.async_get("conversation.chatgpt")
assert entity.unique_id == subentry.subentry_id
assert entity.config_subentry_id == subentry.subentry_id
assert entity.config_entry_id == entry.entry_id
assert not device_registry.async_get_devices(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
assert (
device := device_registry.async_get_device_by_identifier(
(DOMAIN, subentry.subentry_id), mock_config_entry.entry_id
)
)
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert device.id == device_1.id
assert device.config_entry_id == mock_config_entry.entry_id
assert device.config_subentry_id == subentry.subentry_id
subentry = conversation_subentries[1]
entity = entity_registry.async_get("conversation.chatgpt_2")
assert entity.unique_id == subentry.subentry_id
assert entity.config_subentry_id == subentry.subentry_id
assert entity.config_entry_id == entry.entry_id
assert not device_registry.async_get_devices(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
assert (
device := device_registry.async_get_device_by_identifier(
(DOMAIN, subentry.subentry_id), mock_config_entry.entry_id
)
)
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
assert device.id == device_2.id
assert device.config_entry_id == mock_config_entry.entry_id
assert device.config_subentry_id == subentry.subentry_id
@pytest.mark.parametrize(
"mock_conversation_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]
)
async def test_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test devices are correctly created for subentries."""
devices = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
assert len(devices) == 4 # One for conversation, AI task, STT, and TTS
# Find the conversation subentry device specifically, since device ordering
# from concurrent platform setup is non-deterministic.
conversation_subentry = next(
subentry
for subentry in mock_config_entry.subentries.values()
if subentry.subentry_type == "conversation"
)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, conversation_subentry.subentry_id), mock_config_entry.entry_id
)
assert device is not None
assert device == snapshot(exclude=props("identifiers"))
async def test_migration_from_v2_2(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 2.2."""
# Create a v2.2 config entry with a conversation subentry
options = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
entry_id="mock_entry_id",
version=2,
minor_version=2,
subentries_data=[
ConfigSubentryData(
data=options,
subentry_id="mock_id_1",
subentry_type="conversation",
title="ChatGPT",
unique_id=None,
),
],
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 4
# Check conversation subentry is still there
conversation_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "conversation"
]
assert len(conversation_subentries) == 1
conversation_subentry = conversation_subentries[0]
assert conversation_subentry.data == options
# Check AI Task subentry was added
ai_task_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "ai_task_data"
]
assert len(ai_task_subentries) == 1
ai_task_subentry = ai_task_subentries[0]
assert ai_task_subentry.data == {"recommended": True}
assert ai_task_subentry.title == "OpenAI AI Task"
@pytest.mark.parametrize(
(
"config_entry_disabled_by",
"device_disabled_by",
"entity_disabled_by",
"setup_result",
"minor_version_after_migration",
"config_entry_disabled_by_after_migration",
"device_disabled_by_after_migration",
"entity_disabled_by_after_migration",
),
[
# Config entry not disabled, update device and entity disabled by config entry
(
None,
DeviceEntryDisabler.CONFIG_ENTRY,
RegistryEntryDisabler.CONFIG_ENTRY,
True,
7,
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
),
(
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
True,
7,
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
),
(
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.USER,
True,
7,
None,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.USER,
),
(
None,
None,
None,
True,
7,
None,
None,
None,
),
# Config entry disabled, migration does not run
(
ConfigEntryDisabler.USER,
DeviceEntryDisabler.CONFIG_ENTRY,
RegistryEntryDisabler.CONFIG_ENTRY,
False,
3,
ConfigEntryDisabler.USER,
DeviceEntryDisabler.CONFIG_ENTRY,
RegistryEntryDisabler.CONFIG_ENTRY,
),
(
ConfigEntryDisabler.USER,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
False,
3,
ConfigEntryDisabler.USER,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.DEVICE,
),
(
ConfigEntryDisabler.USER,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.USER,
False,
3,
ConfigEntryDisabler.USER,
DeviceEntryDisabler.USER,
RegistryEntryDisabler.USER,
),
(
ConfigEntryDisabler.USER,
None,
None,
False,
3,
ConfigEntryDisabler.USER,
None,
None,
),
],
)
async def test_migrate_entry_from_v2_3(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
config_entry_disabled_by: ConfigEntryDisabler | None,
device_disabled_by: DeviceEntryDisabler | None,
entity_disabled_by: RegistryEntryDisabler | None,
setup_result: bool,
minor_version_after_migration: int,
config_entry_disabled_by_after_migration: ConfigEntryDisabler | None,
device_disabled_by_after_migration: ConfigEntryDisabler | None,
entity_disabled_by_after_migration: RegistryEntryDisabler | None,
) -> None:
"""Test migration from version 2.3."""
# Create a v2.3 config entry with conversation subentries
conversation_subentry_id = "blabla"
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_API_KEY: "test-api-key"},
disabled_by=config_entry_disabled_by,
version=2,
minor_version=3,
subentries_data=[
{
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"subentry_id": conversation_subentry_id,
"subentry_type": "conversation",
"title": DEFAULT_CONVERSATION_NAME,
"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=conversation_subentry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="OpenAI",
model="ChatGPT",
entry_type=dr.DeviceEntryType.SERVICE,
)
# A stale disabled_by flag can't be set through the registry API, which
# validates it against the config entry's disabled state; write it
# directly to simulate existing storage.
conversation_device = attr.evolve(
conversation_device, disabled_by=device_disabled_by
)
device_registry._devices[conversation_device.id] = conversation_device
conversation_entity = entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry.entry_id,
config_entry=mock_config_entry,
config_subentry_id=conversation_subentry_id,
disabled_by=entity_disabled_by,
device_id=conversation_device.id,
suggested_object_id="chatgpt",
)
# Verify initial state
assert mock_config_entry.version == 2
assert mock_config_entry.minor_version == 3
assert len(mock_config_entry.subentries) == 1
assert mock_config_entry.disabled_by == config_entry_disabled_by
assert conversation_device.disabled_by == device_disabled_by
assert conversation_entity.disabled_by == entity_disabled_by
# Run setup to trigger migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert result is setup_result
await hass.async_block_till_done()
# Verify migration completed
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
# Check version and subversion were updated
assert entry.version == 2
assert entry.minor_version == minor_version_after_migration
# Check the disabled_by flag on config entry, device and entity are as expected
conversation_device = device_registry.async_get(conversation_device.id)
conversation_entity = entity_registry.async_get(conversation_entity.entity_id)
assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration
assert conversation_device.disabled_by == device_disabled_by_after_migration
assert conversation_entity.disabled_by == entity_disabled_by_after_migration
async def test_migration_from_v2_4(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 2.4."""
# Create a v2.4 config entry with a conversation and AI Task subentries
conversation_options = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
ai_task_options = {
"recommended": True,
"chat_model": "gpt-5-mini",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
entry_id="mock_entry_id",
version=2,
minor_version=4,
subentries_data=[
ConfigSubentryData(
data=conversation_options,
subentry_id="mock_id_1",
subentry_type="conversation",
title="ChatGPT",
unique_id=None,
),
ConfigSubentryData(
data=ai_task_options,
subentry_id="mock_id_2",
subentry_type="ai_task_data",
title="OpenAI AI Task",
unique_id=None,
),
],
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 4
# Check conversation subentry is still there
conversation_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "conversation"
]
assert len(conversation_subentries) == 1
conversation_subentry = conversation_subentries[0]
assert conversation_subentry.data == conversation_options
# Check AI Task subentry is still there
ai_task_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "ai_task_data"
]
assert len(ai_task_subentries) == 1
ai_task_subentry = ai_task_subentries[0]
assert ai_task_subentry.data == ai_task_options
# Check TTS subentry was added
tts_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "tts"
]
assert len(tts_subentries) == 1
tts_subentry = tts_subentries[0]
assert tts_subentry.data == {"chat_model": "gpt-4o-mini-tts", "prompt": ""}
assert tts_subentry.title == "OpenAI TTS"
async def test_migration_from_v2_5(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration from version 2.5."""
# Create a v2.5 config entry with a conversation, AI Task, and TTS subentries
conversation_options = {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "gpt-4o-mini",
}
ai_task_options = {
"recommended": True,
"chat_model": "gpt-5-mini",
}
tts_options = {
"prompt": "Be friendly",
"chat_model": "gpt-4o-mini-tts",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
entry_id="mock_entry_id",
version=2,
minor_version=5,
subentries_data=[
ConfigSubentryData(
data=conversation_options,
subentry_id="mock_id_1",
subentry_type="conversation",
title="ChatGPT",
unique_id=None,
),
ConfigSubentryData(
data=ai_task_options,
subentry_id="mock_id_2",
subentry_type="ai_task_data",
title="OpenAI AI Task",
unique_id=None,
),
ConfigSubentryData(
data=tts_options,
subentry_id="mock_id_3",
subentry_type="tts",
title="OpenAI TTS",
unique_id=None,
),
],
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
# Run migration
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 7
assert not entry.options
assert entry.title == "ChatGPT"
assert len(entry.subentries) == 4
# Check conversation subentry is still there
conversation_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "conversation"
]
assert len(conversation_subentries) == 1
conversation_subentry = conversation_subentries[0]
assert conversation_subentry.data == conversation_options
# Check AI Task subentry is still there
ai_task_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "ai_task_data"
]
assert len(ai_task_subentries) == 1
ai_task_subentry = ai_task_subentries[0]
assert ai_task_subentry.data == ai_task_options
# Check TTS subentry is still there
tts_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "tts"
]
assert len(tts_subentries) == 1
tts_subentry = tts_subentries[0]
assert tts_subentry.data == tts_options
# Check STT subentry was added
stt_subentries = [
subentry
for subentry in entry.subentries.values()
if subentry.subentry_type == "stt"
]
assert len(stt_subentries) == 1
stt_subentry = stt_subentries[0]
assert stt_subentry.data == {}
assert stt_subentry.title == "OpenAI STT"
async def test_migration_from_v2_6(
hass: HomeAssistant,
) -> None:
"""Test migration from version 2.6.
Ensures reasoning_summary "short" is renamed to "concise" for gpt-5 models,
and that "concise" (whether from "short" or already stored) is reset to "auto"
for o* models where it is unsupported. Other values and unrelated subentries
are unchanged.
"""
conversation_options_short_o = {
"chat_model": "o4-mini",
CONF_REASONING_SUMMARY: "short",
}
conversation_options_auto = {
"chat_model": "gpt-5-mini",
CONF_REASONING_SUMMARY: "auto",
}
ai_task_options_short_o = {
"chat_model": "o3",
CONF_REASONING_SUMMARY: "short",
}
tts_options = {
"chat_model": "gpt-4o-mini-tts",
}
conversation_options_short_gpt5 = {
"chat_model": "gpt-5",
CONF_REASONING_SUMMARY: "short",
}
conversation_options_concise_o = {
"chat_model": "o3",
CONF_REASONING_SUMMARY: "concise",
}
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={"api_key": "1234"},
entry_id="mock_entry_id",
version=2,
minor_version=6,
subentries_data=[
ConfigSubentryData(
data=conversation_options_short_o,
subentry_id="mock_id_1",
subentry_type="conversation",
title="ChatGPT short o",
unique_id=None,
),
ConfigSubentryData(
data=conversation_options_auto,
subentry_id="mock_id_2",
subentry_type="conversation",
title="ChatGPT auto",
unique_id=None,
),
ConfigSubentryData(
data=ai_task_options_short_o,
subentry_id="mock_id_3",
subentry_type="ai_task_data",
title="OpenAI AI Task",
unique_id=None,
),
ConfigSubentryData(
data=tts_options,
subentry_id="mock_id_4",
subentry_type="tts",
title="OpenAI TTS",
unique_id=None,
),
ConfigSubentryData(
data=conversation_options_short_gpt5,
subentry_id="mock_id_5",
subentry_type="conversation",
title="ChatGPT short gpt5",
unique_id=None,
),
ConfigSubentryData(
data=conversation_options_concise_o,
subentry_id="mock_id_6",
subentry_type="conversation",
title="ChatGPT concise o",
unique_id=None,
),
],
title="ChatGPT",
)
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.openai_conversation.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
entry = entries[0]
assert entry.version == 2
assert entry.minor_version == 7
subentries_by_id = entry.subentries
# "short" on an o* model: short→concise→auto (concise unsupported on o*)
assert subentries_by_id["mock_id_1"].data[CONF_REASONING_SUMMARY] == "auto"
# "auto" on a gpt-5 model: unchanged
assert subentries_by_id["mock_id_2"].data[CONF_REASONING_SUMMARY] == "auto"
# "short" on an o* ai_task_data subentry: short→concise→auto
assert subentries_by_id["mock_id_3"].data[CONF_REASONING_SUMMARY] == "auto"
# TTS subentry is unaffected
assert subentries_by_id["mock_id_4"].data == tts_options
# "short" on a gpt-5 model: short→concise (concise is valid for gpt-5)
assert subentries_by_id["mock_id_5"].data[CONF_REASONING_SUMMARY] == "concise"
# "concise" already stored on an o* model: concise→auto
assert subentries_by_id["mock_id_6"].data[CONF_REASONING_SUMMARY] == "auto"