Adds CONF_BASE_URL/DEFAULT_BASE_URL and threads base_url through every AsyncOpenAI construction site: config_flow.validate_input, __init__.async_setup_entry, and config_flow's web-search geolocation helper (_get_location_data) — the easy one to miss, since it would otherwise silently talk to api.openai.com when the user configured a different endpoint. Fixes upstream-ported fixtures (conftest.mock_config_entry, test_config_flow's inline MockConfigEntry/data-schema assertions) to supply base_url now that the config-flow schema requires it. No translations/en.json exists in this repo (only strings.json), so only strings.json got the new "base_url" UI strings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01739Seac5eMAfNwFeiwV5r4
1647 lines
56 KiB
Python
1647 lines
56 KiB
Python
"""Test the OpenAI Conversation config flow."""
|
||
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import httpx
|
||
from openai import APIConnectionError, AuthenticationError, BadRequestError
|
||
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText
|
||
import pytest
|
||
|
||
from homeassistant import config_entries
|
||
from custom_components.openai_compatible.config_flow import (
|
||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||
)
|
||
from custom_components.openai_compatible.const import (
|
||
CONF_BASE_URL,
|
||
CONF_CHAT_MODEL,
|
||
CONF_CODE_INTERPRETER,
|
||
CONF_IMAGE_MODEL,
|
||
CONF_MAX_TOKENS,
|
||
CONF_PRO_MODE,
|
||
CONF_REASONING_EFFORT,
|
||
CONF_REASONING_SUMMARY,
|
||
CONF_RECOMMENDED,
|
||
CONF_SERVICE_TIER,
|
||
CONF_STORE_RESPONSES,
|
||
CONF_TEMPERATURE,
|
||
CONF_TOP_P,
|
||
CONF_TTS_SPEED,
|
||
CONF_VERBOSITY,
|
||
CONF_WEB_SEARCH,
|
||
CONF_WEB_SEARCH_CITY,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE,
|
||
CONF_WEB_SEARCH_COUNTRY,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS,
|
||
CONF_WEB_SEARCH_REGION,
|
||
CONF_WEB_SEARCH_TIMEZONE,
|
||
CONF_WEB_SEARCH_USER_LOCATION,
|
||
DEFAULT_AI_TASK_NAME,
|
||
DEFAULT_BASE_URL,
|
||
DEFAULT_CONVERSATION_NAME,
|
||
DEFAULT_STT_NAME,
|
||
DEFAULT_TTS_NAME,
|
||
DOMAIN,
|
||
RECOMMENDED_AI_TASK_OPTIONS,
|
||
RECOMMENDED_CHAT_MODEL,
|
||
RECOMMENDED_MAX_TOKENS,
|
||
RECOMMENDED_REASONING_SUMMARY,
|
||
RECOMMENDED_STT_OPTIONS,
|
||
RECOMMENDED_TOP_P,
|
||
RECOMMENDED_TTS_OPTIONS,
|
||
)
|
||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT
|
||
from homeassistant.core import HomeAssistant
|
||
from homeassistant.data_entry_flow import FlowResultType
|
||
|
||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||
|
||
|
||
async def test_form(hass: HomeAssistant) -> None:
|
||
"""Test we get the form."""
|
||
# Pretend we already set up a config entry.
|
||
hass.config.components.add("openai_compatible")
|
||
MockConfigEntry(
|
||
domain=DOMAIN,
|
||
state=config_entries.ConfigEntryState.LOADED,
|
||
).add_to_hass(hass)
|
||
|
||
result = await hass.config_entries.flow.async_init(
|
||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||
)
|
||
assert result["type"] is FlowResultType.FORM
|
||
assert result["errors"] == {}
|
||
|
||
with (
|
||
patch(
|
||
"custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list",
|
||
new_callable=AsyncMock,
|
||
),
|
||
patch(
|
||
"custom_components.openai_compatible.async_setup_entry",
|
||
return_value=True,
|
||
) as mock_setup_entry,
|
||
):
|
||
result2 = await hass.config_entries.flow.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
"api_key": "bla",
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||
assert result2["data"] == {
|
||
"api_key": "bla",
|
||
"base_url": DEFAULT_BASE_URL,
|
||
}
|
||
assert result2["options"] == {}
|
||
assert result2["subentries"] == [
|
||
{
|
||
"subentry_type": "conversation",
|
||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||
"title": DEFAULT_CONVERSATION_NAME,
|
||
"unique_id": None,
|
||
},
|
||
{
|
||
"subentry_type": "ai_task_data",
|
||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||
"title": DEFAULT_AI_TASK_NAME,
|
||
"unique_id": None,
|
||
},
|
||
{
|
||
"subentry_type": "stt",
|
||
"data": RECOMMENDED_STT_OPTIONS,
|
||
"title": DEFAULT_STT_NAME,
|
||
"unique_id": None,
|
||
},
|
||
{
|
||
"subentry_type": "tts",
|
||
"data": RECOMMENDED_TTS_OPTIONS,
|
||
"title": DEFAULT_TTS_NAME,
|
||
"unique_id": None,
|
||
},
|
||
]
|
||
assert result2["version"] == 2
|
||
assert result2["minor_version"] == 7
|
||
assert len(mock_setup_entry.mock_calls) == 1
|
||
|
||
|
||
async def test_duplicate_entry(hass: HomeAssistant) -> None:
|
||
"""Test we abort on duplicate config entry."""
|
||
MockConfigEntry(
|
||
domain=DOMAIN,
|
||
data={CONF_API_KEY: "bla", CONF_BASE_URL: DEFAULT_BASE_URL},
|
||
).add_to_hass(hass)
|
||
|
||
result = await hass.config_entries.flow.async_init(
|
||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||
)
|
||
assert result["type"] is FlowResultType.FORM
|
||
assert not result["errors"]
|
||
|
||
with patch(
|
||
"custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list",
|
||
new_callable=AsyncMock,
|
||
):
|
||
result = await hass.config_entries.flow.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
CONF_API_KEY: "bla",
|
||
},
|
||
)
|
||
|
||
assert result["type"] is FlowResultType.ABORT
|
||
assert result["reason"] == "already_configured"
|
||
|
||
|
||
async def test_creating_conversation_subentry(
|
||
hass: HomeAssistant,
|
||
mock_init_component: None,
|
||
mock_config_entry: MockConfigEntry,
|
||
) -> None:
|
||
"""Test creating a conversation subentry."""
|
||
mock_config_entry.add_to_hass(hass)
|
||
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "conversation"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result["type"] is FlowResultType.FORM
|
||
assert result["step_id"] == "init"
|
||
assert not result["errors"]
|
||
|
||
result2 = await hass.config_entries.subentries.async_configure(
|
||
result["flow_id"],
|
||
{"name": "My Custom Agent", **RECOMMENDED_CONVERSATION_OPTIONS},
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||
assert result2["title"] == "My Custom Agent"
|
||
|
||
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
|
||
|
||
assert result2["data"] == processed_options
|
||
|
||
|
||
async def test_creating_conversation_subentry_not_loaded(
|
||
hass: HomeAssistant,
|
||
mock_init_component,
|
||
mock_config_entry: MockConfigEntry,
|
||
) -> None:
|
||
"""Test creating a conversation subentry when entry is not loaded."""
|
||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||
with patch(
|
||
"custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list",
|
||
new_callable=AsyncMock,
|
||
return_value=[],
|
||
):
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "conversation"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result["type"] is FlowResultType.ABORT
|
||
assert result["reason"] == "entry_not_loaded"
|
||
|
||
|
||
async def test_subentry_recommended(
|
||
hass: HomeAssistant, mock_config_entry, mock_init_component
|
||
) -> None:
|
||
"""Test the subentry flow with recommended settings."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
options = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
"prompt": "Speak like a pirate",
|
||
"recommended": True,
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
assert options["type"] is FlowResultType.ABORT
|
||
assert options["reason"] == "reconfigure_successful"
|
||
assert subentry.data["prompt"] == "Speak like a pirate"
|
||
|
||
|
||
async def test_subentry_unsupported_model(
|
||
hass: HomeAssistant, mock_config_entry, mock_init_component
|
||
) -> None:
|
||
"""Test the subentry form giving error about models not supported."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
# Configure initial step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
# Configure additional step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_CHAT_MODEL: "o1-mini",
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["errors"] == {"chat_model": "model_not_supported"}
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("model", "reasoning_effort_options"),
|
||
[
|
||
("o4-mini", ["low", "medium", "high"]),
|
||
("gpt-5", ["minimal", "low", "medium", "high"]),
|
||
("gpt-5.1", ["none", "low", "medium", "high"]),
|
||
("gpt-5.2", ["none", "low", "medium", "high", "xhigh"]),
|
||
("gpt-5.2-pro", ["medium", "high", "xhigh"]),
|
||
("gpt-5.3-codex", ["none", "low", "medium", "high", "xhigh"]),
|
||
("gpt-5.4", ["none", "low", "medium", "high", "xhigh"]),
|
||
("gpt-5.4-pro", ["medium", "high", "xhigh"]),
|
||
("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]),
|
||
("gpt-5.5-pro", ["medium", "high", "xhigh"]),
|
||
("gpt-5.6", ["none", "low", "medium", "high", "xhigh", "max"]),
|
||
],
|
||
)
|
||
async def test_subentry_reasoning_effort_list(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component: None,
|
||
model: str,
|
||
reasoning_effort_options: list[str],
|
||
) -> None:
|
||
"""Test the list reasoning effort options."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
# Configure initial step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
# Configure additional step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_CHAT_MODEL: model,
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "model"
|
||
assert (
|
||
subentry_flow["data_schema"].schema[CONF_REASONING_EFFORT].config["options"]
|
||
== reasoning_effort_options
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("model", "has_reasoning_summary"),
|
||
[
|
||
("o3", True),
|
||
("o4-mini", True),
|
||
("gpt-5", True),
|
||
("gpt-5-mini", True),
|
||
("gpt-5-pro", True),
|
||
("gpt-4o", False),
|
||
("gpt-4.1", False),
|
||
],
|
||
)
|
||
async def test_subentry_reasoning_summary_visibility(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component: None,
|
||
model: str,
|
||
has_reasoning_summary: bool,
|
||
) -> None:
|
||
"""Test that reasoning_summary option is shown for all reasoning models."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
# Configure initial step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
# Configure additional step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_CHAT_MODEL: model,
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "model"
|
||
assert (CONF_REASONING_SUMMARY in subentry_flow["data_schema"].schema) == (
|
||
has_reasoning_summary
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("model", "reasoning_summary_options"),
|
||
[
|
||
("o3", ["off", "auto", "detailed"]),
|
||
("o4-mini", ["off", "auto", "detailed"]),
|
||
("gpt-5", ["off", "auto", "concise", "detailed"]),
|
||
("gpt-5-mini", ["off", "auto", "concise", "detailed"]),
|
||
],
|
||
)
|
||
async def test_subentry_reasoning_summary_options(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component: None,
|
||
model: str,
|
||
reasoning_summary_options: list[str],
|
||
) -> None:
|
||
"""Test the list of reasoning summary options for reasoning models."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_CHAT_MODEL: model,
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "model"
|
||
assert (
|
||
subentry_flow["data_schema"].schema[CONF_REASONING_SUMMARY].config["options"]
|
||
== reasoning_summary_options
|
||
)
|
||
|
||
|
||
async def test_subentry_reasoning_summary_default_sanitized_on_model_switch(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component: None,
|
||
) -> None:
|
||
"""Test that a stored 'concise' default is sanitized to 'auto' for o* models."""
|
||
subentry = next(
|
||
s
|
||
for s in mock_config_entry.subentries.values()
|
||
if s.subentry_type == "conversation"
|
||
)
|
||
hass.config_entries.async_update_subentry(
|
||
mock_config_entry,
|
||
subentry,
|
||
data={**subentry.data, CONF_REASONING_SUMMARY: "concise"},
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
},
|
||
)
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{CONF_CHAT_MODEL: "o3"},
|
||
)
|
||
assert subentry_flow["step_id"] == "model"
|
||
|
||
schema = subentry_flow["data_schema"].schema
|
||
summary_key = next(k for k in schema if k == CONF_REASONING_SUMMARY)
|
||
assert summary_key.default() == RECOMMENDED_REASONING_SUMMARY
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("model", "service_tier_options"),
|
||
[
|
||
("gpt-5.6", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5.5", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5.4", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5.4-pro", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5.2", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5.1", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5-mini", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5-nano", ["auto", "flex", "default"]),
|
||
("o3", ["auto", "flex", "default", "priority"]),
|
||
("o4-mini", ["auto", "flex", "default", "priority"]),
|
||
("gpt-5.3-codex", ["auto", "default", "priority"]),
|
||
("gpt-5.2-codex", ["auto", "default", "priority"]),
|
||
("gpt-5.1-codex-max", ["auto", "default", "priority"]),
|
||
("gpt-5-codex", ["auto", "default", "priority"]),
|
||
("gpt-4.1", ["auto", "default", "priority"]),
|
||
("gpt-4.1-mini", ["auto", "default", "priority"]),
|
||
("gpt-4.1-nano", ["auto", "default", "priority"]),
|
||
("gpt-4o", ["auto", "default", "priority"]),
|
||
("gpt-4o-2024-05-13", ["auto", "default", "priority"]),
|
||
("gpt-4o-mini", ["auto", "default", "priority"]),
|
||
("gpt-5-chat-latest", []),
|
||
("gpt-5.2-pro", []),
|
||
("o3-mini", []),
|
||
],
|
||
)
|
||
async def test_subentry_service_tier_list(
|
||
hass: HomeAssistant,
|
||
mock_config_entry,
|
||
mock_init_component,
|
||
model,
|
||
service_tier_options,
|
||
) -> None:
|
||
"""Test the list of service tier options."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
# Configure initial step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
# Configure additional step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_CHAT_MODEL: model,
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "model"
|
||
assert (
|
||
subentry_flow["data_schema"].schema[CONF_SERVICE_TIER].config["options"]
|
||
if subentry_flow["data_schema"].schema.get(CONF_SERVICE_TIER)
|
||
else []
|
||
) == service_tier_options
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("parameter", "error"),
|
||
[
|
||
(CONF_WEB_SEARCH, "web_search_minimal_reasoning"),
|
||
(CONF_CODE_INTERPRETER, "code_interpreter_minimal_reasoning"),
|
||
],
|
||
)
|
||
async def test_subentry_unsupported_reasoning_effort(
|
||
hass: HomeAssistant, mock_config_entry, mock_init_component, parameter, error
|
||
) -> None:
|
||
"""Test subentry form error on unsupported reasoning effort."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
# Configure initial step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
# Configure additional step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_CHAT_MODEL: "gpt-5",
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "model"
|
||
|
||
# Configure model step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_REASONING_EFFORT: "minimal",
|
||
parameter: True,
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["errors"] == {parameter: error}
|
||
|
||
# Reconfigure model step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_REASONING_EFFORT: "low",
|
||
parameter: True,
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.ABORT
|
||
assert subentry_flow["reason"] == "reconfigure_successful"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("side_effect", "error"),
|
||
[
|
||
(APIConnectionError(request=None), "cannot_connect"),
|
||
(
|
||
AuthenticationError(
|
||
response=httpx.Response(status_code=None, request=""),
|
||
body=None,
|
||
message=None,
|
||
),
|
||
"invalid_auth",
|
||
),
|
||
(
|
||
BadRequestError(
|
||
response=httpx.Response(status_code=None, request=""),
|
||
body=None,
|
||
message=None,
|
||
),
|
||
"unknown",
|
||
),
|
||
],
|
||
)
|
||
async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> None:
|
||
"""Test we handle invalid auth."""
|
||
result = await hass.config_entries.flow.async_init(
|
||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||
)
|
||
|
||
with patch(
|
||
"custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list",
|
||
new_callable=AsyncMock,
|
||
side_effect=side_effect,
|
||
):
|
||
result2 = await hass.config_entries.flow.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
"api_key": "bla",
|
||
},
|
||
)
|
||
|
||
assert result2["type"] is FlowResultType.FORM
|
||
assert result2["errors"] == {"base": error}
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("current_options", "new_options", "expected_options"),
|
||
[
|
||
( # Test clearing every llm api is stored as an empty list
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_PROMPT: "",
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: [],
|
||
CONF_PROMPT: "",
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: [],
|
||
CONF_PROMPT: "",
|
||
},
|
||
),
|
||
( # Test converting single llm api format to list
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: "assist",
|
||
CONF_PROMPT: "",
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_PROMPT: "",
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_PROMPT: "",
|
||
},
|
||
),
|
||
( # options for reasoning models
|
||
{},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pro",
|
||
},
|
||
{
|
||
CONF_TEMPERATURE: 1.0,
|
||
CONF_CHAT_MODEL: "o1-pro",
|
||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||
CONF_MAX_TOKENS: 10000,
|
||
},
|
||
{
|
||
CONF_REASONING_EFFORT: "high",
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pro",
|
||
CONF_TEMPERATURE: 1.0,
|
||
CONF_CHAT_MODEL: "o1-pro",
|
||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||
CONF_MAX_TOKENS: 10000,
|
||
CONF_STORE_RESPONSES: False,
|
||
CONF_REASONING_EFFORT: "high",
|
||
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
),
|
||
( # options for web search without user location
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_PROMPT: "bla",
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
{
|
||
CONF_TEMPERATURE: 0.3,
|
||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||
},
|
||
{
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_SERVICE_TIER: "auto",
|
||
CONF_CODE_INTERPRETER: False,
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 0.3,
|
||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||
CONF_STORE_RESPONSES: False,
|
||
CONF_SERVICE_TIER: "auto",
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_CODE_INTERPRETER: False,
|
||
},
|
||
),
|
||
# Test that current options are showed as suggested values
|
||
( # Case 1: web search
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like super Mario",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_STORE_RESPONSES: True,
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
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",
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_SERVICE_TIER: "auto",
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like super Mario",
|
||
},
|
||
{
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_STORE_RESPONSES: True,
|
||
},
|
||
{
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_SERVICE_TIER: "default",
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like super Mario",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_STORE_RESPONSES: True,
|
||
CONF_SERVICE_TIER: "default",
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
),
|
||
( # Case 2: reasoning model
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_REASONING_EFFORT: "low",
|
||
CONF_REASONING_SUMMARY: "auto",
|
||
CONF_VERBOSITY: "high",
|
||
CONF_CODE_INTERPRETER: False,
|
||
CONF_SERVICE_TIER: "flex",
|
||
CONF_WEB_SEARCH: False,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
{
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5.6",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
},
|
||
{
|
||
CONF_REASONING_EFFORT: "max",
|
||
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
|
||
CONF_CODE_INTERPRETER: False,
|
||
CONF_VERBOSITY: "high",
|
||
CONF_SERVICE_TIER: "flex",
|
||
CONF_WEB_SEARCH: False,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_PRO_MODE: True,
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5.6",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_STORE_RESPONSES: False,
|
||
CONF_REASONING_EFFORT: "max",
|
||
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
|
||
CONF_CODE_INTERPRETER: False,
|
||
CONF_VERBOSITY: "high",
|
||
CONF_SERVICE_TIER: "flex",
|
||
CONF_WEB_SEARCH: False,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_PRO_MODE: True,
|
||
},
|
||
),
|
||
# Test that old options are removed after reconfiguration
|
||
( # Case 1: web search to recommended
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_CODE_INTERPRETER: True,
|
||
CONF_SERVICE_TIER: "priority",
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
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",
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_PROMPT: "",
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_PROMPT: "",
|
||
},
|
||
),
|
||
( # Case 2: reasoning to recommended
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_REASONING_EFFORT: "high",
|
||
CONF_SERVICE_TIER: "auto",
|
||
CONF_CODE_INTERPRETER: True,
|
||
CONF_VERBOSITY: "low",
|
||
CONF_WEB_SEARCH: False,
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: True,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
),
|
||
( # Case 3: web search to reasoning
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_SERVICE_TIER: "auto",
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
|
||
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",
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
{
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "o3-mini",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
},
|
||
{
|
||
CONF_REASONING_EFFORT: "low",
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "o3-mini",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_STORE_RESPONSES: False,
|
||
CONF_REASONING_EFFORT: "low",
|
||
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
|
||
CONF_CODE_INTERPRETER: True,
|
||
},
|
||
),
|
||
( # Case 4: reasoning to web search
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5.6",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_REASONING_EFFORT: "low",
|
||
CONF_REASONING_SUMMARY: "auto",
|
||
CONF_SERVICE_TIER: "flex",
|
||
CONF_CODE_INTERPRETER: True,
|
||
CONF_VERBOSITY: "medium",
|
||
CONF_PRO_MODE: True,
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
{
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
},
|
||
{
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_SERVICE_TIER: "priority",
|
||
CONF_CODE_INTERPRETER: False,
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_STORE_RESPONSES: False,
|
||
CONF_SERVICE_TIER: "priority",
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
CONF_CODE_INTERPRETER: False,
|
||
},
|
||
),
|
||
( # Case 5: code interpreter supported to not supported model
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_LLM_HASS_API: ["assist"],
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_REASONING_EFFORT: "low",
|
||
CONF_REASONING_SUMMARY: "auto",
|
||
CONF_CODE_INTERPRETER: True,
|
||
CONF_VERBOSITY: "medium",
|
||
CONF_SERVICE_TIER: "auto",
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
},
|
||
(
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
{
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5-pro",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
},
|
||
{
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
},
|
||
),
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 0.8,
|
||
CONF_CHAT_MODEL: "gpt-5-pro",
|
||
CONF_TOP_P: 0.9,
|
||
CONF_MAX_TOKENS: 1000,
|
||
CONF_STORE_RESPONSES: False,
|
||
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
|
||
CONF_VERBOSITY: "medium",
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
|
||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: True,
|
||
},
|
||
),
|
||
],
|
||
)
|
||
async def test_subentry_switching(
|
||
hass: HomeAssistant,
|
||
mock_config_entry,
|
||
mock_init_component,
|
||
current_options,
|
||
new_options,
|
||
expected_options,
|
||
) -> None:
|
||
"""Test the subentry form."""
|
||
subentry = next(
|
||
sub
|
||
for sub in mock_config_entry.subentries.values()
|
||
if sub.subentry_type == "conversation"
|
||
)
|
||
hass.config_entries.async_update_subentry(
|
||
mock_config_entry, subentry, data=current_options
|
||
)
|
||
await hass.async_block_till_done()
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
for step_options in new_options:
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
|
||
# Test that current options are showed as suggested values:
|
||
for key in subentry_flow["data_schema"].schema:
|
||
if (
|
||
isinstance(key.description, dict)
|
||
and "suggested_value" in key.description
|
||
and key in current_options
|
||
):
|
||
current_option = current_options[key]
|
||
if key == CONF_LLM_HASS_API and isinstance(current_option, str):
|
||
current_option = [current_option]
|
||
assert key.description["suggested_value"] == current_option
|
||
|
||
# Configure current step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
step_options,
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
assert subentry_flow["type"] is FlowResultType.ABORT
|
||
assert subentry_flow["reason"] == "reconfigure_successful"
|
||
assert subentry.data == expected_options
|
||
|
||
|
||
@pytest.mark.parametrize("store_responses", [False, True])
|
||
async def test_subentry_web_search_user_location(
|
||
hass: HomeAssistant,
|
||
mock_config_entry,
|
||
mock_init_component,
|
||
store_responses: bool,
|
||
) -> None:
|
||
"""Test fetching user location."""
|
||
subentry = next(
|
||
sub
|
||
for sub in mock_config_entry.subentries.values()
|
||
if sub.subentry_type == "conversation"
|
||
)
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
# Configure initial step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
},
|
||
)
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "additional"
|
||
|
||
# Configure additional step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_TEMPERATURE: 1.0,
|
||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||
CONF_STORE_RESPONSES: store_responses,
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "model"
|
||
|
||
hass.config.country = "US"
|
||
hass.config.time_zone = "America/Los_Angeles"
|
||
hass.states.async_set(
|
||
"zone.home", "0", {"latitude": 37.7749, "longitude": -122.4194}
|
||
)
|
||
with patch(
|
||
"openai.resources.responses.AsyncResponses.create",
|
||
new_callable=AsyncMock,
|
||
) as mock_create:
|
||
mock_create.return_value = Response(
|
||
object="response",
|
||
id="resp_A",
|
||
created_at=1700000000,
|
||
model="gpt-4o-mini",
|
||
parallel_tool_calls=True,
|
||
tool_choice="auto",
|
||
tools=[],
|
||
output=[
|
||
ResponseOutputMessage(
|
||
type="message",
|
||
id="msg_A",
|
||
content=[
|
||
ResponseOutputText(
|
||
type="output_text",
|
||
text='{"city": "San Francisco", "region": "California"}',
|
||
annotations=[],
|
||
)
|
||
],
|
||
role="assistant",
|
||
status="completed",
|
||
)
|
||
],
|
||
)
|
||
|
||
# Configure model step
|
||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "medium",
|
||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
assert (
|
||
mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following"
|
||
" coordinates located: (37.7749, -122.4194)?"
|
||
)
|
||
assert mock_create.call_args.kwargs["store"] is store_responses
|
||
assert subentry_flow["type"] is FlowResultType.ABORT
|
||
assert subentry_flow["reason"] == "reconfigure_successful"
|
||
assert subentry.data == {
|
||
CONF_RECOMMENDED: False,
|
||
CONF_PROMPT: "Speak like a pirate",
|
||
CONF_TEMPERATURE: 1.0,
|
||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||
CONF_SERVICE_TIER: "auto",
|
||
CONF_STORE_RESPONSES: store_responses,
|
||
CONF_WEB_SEARCH: True,
|
||
CONF_WEB_SEARCH_CONTEXT_SIZE: "medium",
|
||
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",
|
||
CONF_WEB_SEARCH_INLINE_CITATIONS: False,
|
||
CONF_CODE_INTERPRETER: False,
|
||
}
|
||
|
||
|
||
async def test_creating_ai_task_subentry(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component,
|
||
) -> None:
|
||
"""Test creating an AI task subentry."""
|
||
old_subentries = set(mock_config_entry.subentries)
|
||
# Original conversation + ai_task + stt + tts
|
||
assert len(mock_config_entry.subentries) == 4
|
||
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "ai_task_data"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.FORM
|
||
assert result.get("step_id") == "init"
|
||
assert not result.get("errors")
|
||
|
||
result2 = await hass.config_entries.subentries.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
"name": "Custom AI Task",
|
||
CONF_RECOMMENDED: True,
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
assert result2.get("type") is FlowResultType.CREATE_ENTRY
|
||
assert result2.get("title") == "Custom AI Task"
|
||
assert result2.get("data") == {
|
||
CONF_RECOMMENDED: True,
|
||
}
|
||
|
||
assert (
|
||
len(mock_config_entry.subentries) == 5
|
||
) # Original conversation + stt + tts + ai_task + new ai_task
|
||
|
||
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
|
||
new_subentry = mock_config_entry.subentries[new_subentry_id]
|
||
assert new_subentry.subentry_type == "ai_task_data"
|
||
assert new_subentry.title == "Custom AI Task"
|
||
|
||
|
||
async def test_ai_task_subentry_not_loaded(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
) -> None:
|
||
"""Test creating an AI task subentry when entry is not loaded."""
|
||
# Don't call mock_init_component to simulate not loaded state
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "ai_task_data"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.ABORT
|
||
assert result.get("reason") == "entry_not_loaded"
|
||
|
||
|
||
async def test_creating_ai_task_subentry_additional(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component,
|
||
) -> None:
|
||
"""Test creating an AI task subentry with additional settings."""
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "ai_task_data"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.FORM
|
||
assert result.get("step_id") == "init"
|
||
|
||
# Go to additional settings
|
||
result2 = await hass.config_entries.subentries.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
"name": "Advanced AI Task",
|
||
CONF_RECOMMENDED: False,
|
||
},
|
||
)
|
||
|
||
assert result2.get("type") is FlowResultType.FORM
|
||
assert result2.get("step_id") == "additional"
|
||
|
||
# Configure additional settings
|
||
result3 = await hass.config_entries.subentries.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_MAX_TOKENS: 200,
|
||
CONF_STORE_RESPONSES: True,
|
||
CONF_TEMPERATURE: 0.5,
|
||
CONF_TOP_P: 0.9,
|
||
},
|
||
)
|
||
|
||
assert result3.get("type") is FlowResultType.FORM
|
||
assert result3.get("step_id") == "model"
|
||
|
||
# Configure model settings
|
||
result4 = await hass.config_entries.subentries.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
CONF_CODE_INTERPRETER: False,
|
||
},
|
||
)
|
||
|
||
assert result4.get("type") is FlowResultType.CREATE_ENTRY
|
||
assert result4.get("title") == "Advanced AI Task"
|
||
assert result4.get("data") == {
|
||
CONF_RECOMMENDED: False,
|
||
CONF_CHAT_MODEL: "gpt-4o",
|
||
CONF_IMAGE_MODEL: "gpt-image-2",
|
||
CONF_MAX_TOKENS: 200,
|
||
CONF_STORE_RESPONSES: True,
|
||
CONF_TEMPERATURE: 0.5,
|
||
CONF_TOP_P: 0.9,
|
||
CONF_CODE_INTERPRETER: False,
|
||
CONF_SERVICE_TIER: "auto",
|
||
}
|
||
|
||
|
||
async def test_creating_stt_subentry(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component,
|
||
) -> None:
|
||
"""Test creating a STT subentry."""
|
||
old_subentries = set(mock_config_entry.subentries)
|
||
# Original conversation + ai_task + stt + tts
|
||
assert len(mock_config_entry.subentries) == 4
|
||
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "stt"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.FORM
|
||
assert result.get("step_id") == "init"
|
||
assert not result.get("errors")
|
||
|
||
result = await hass.config_entries.subentries.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
"name": "Custom STT",
|
||
CONF_PROMPT: (
|
||
"Umm, let me think like, hmm\u2026 Okay,"
|
||
" here’s what I’m, like, thinking."
|
||
),
|
||
CONF_CHAT_MODEL: "gpt-4o-transcribe",
|
||
},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
||
assert result.get("title") == "Custom STT"
|
||
assert result.get("data") == {
|
||
CONF_PROMPT: (
|
||
"Umm, let me think like, hmm\u2026 Okay, here’s what I’m, like, thinking."
|
||
),
|
||
CONF_CHAT_MODEL: "gpt-4o-transcribe",
|
||
}
|
||
|
||
assert (
|
||
len(mock_config_entry.subentries) == 5
|
||
) # Original conversation + ai_task + tts + original stt + new stt
|
||
|
||
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
|
||
new_subentry = mock_config_entry.subentries[new_subentry_id]
|
||
assert new_subentry.subentry_type == "stt"
|
||
assert new_subentry.title == "Custom STT"
|
||
|
||
|
||
async def test_stt_subentry_not_loaded(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
) -> None:
|
||
"""Test creating a STT subentry when entry is not loaded."""
|
||
# Don't call mock_init_component to simulate not loaded state
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "stt"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.ABORT
|
||
assert result.get("reason") == "entry_not_loaded"
|
||
|
||
|
||
async def test_stt_reconfigure(
|
||
hass: HomeAssistant, mock_config_entry, mock_init_component
|
||
) -> None:
|
||
"""Test reconfiguring the STT subentry updates prompt and chat model."""
|
||
subentry = [
|
||
s for s in mock_config_entry.subentries.values() if s.subentry_type == "stt"
|
||
][0]
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
options = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
"prompt": "This is a conversation about smart pirate ships.",
|
||
"chat_model": "gpt-4o-mini-transcribe-2025-12-15",
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
assert options["type"] is FlowResultType.ABORT
|
||
assert options["reason"] == "reconfigure_successful"
|
||
assert subentry.data["prompt"] == "This is a conversation about smart pirate ships."
|
||
assert subentry.data["chat_model"] == "gpt-4o-mini-transcribe-2025-12-15"
|
||
|
||
|
||
async def test_creating_tts_subentry(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component,
|
||
) -> None:
|
||
"""Test creating a TTS subentry."""
|
||
old_subentries = set(mock_config_entry.subentries)
|
||
# Original conversation + ai_task + stt + tts
|
||
assert len(mock_config_entry.subentries) == 4
|
||
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "tts"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.FORM
|
||
assert result.get("step_id") == "init"
|
||
assert not result.get("errors")
|
||
|
||
result = await hass.config_entries.subentries.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
"name": "Custom TTS",
|
||
CONF_PROMPT: "Speak like a drunk pirate",
|
||
CONF_TTS_SPEED: 0.85,
|
||
},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.CREATE_ENTRY
|
||
assert result.get("title") == "Custom TTS"
|
||
assert result.get("data") == {
|
||
CONF_PROMPT: "Speak like a drunk pirate",
|
||
CONF_TTS_SPEED: 0.85,
|
||
CONF_CHAT_MODEL: "gpt-4o-mini-tts",
|
||
}
|
||
|
||
assert (
|
||
len(mock_config_entry.subentries) == 5
|
||
) # Original conversation + ai_task + stt + tts + new tts
|
||
|
||
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
|
||
new_subentry = mock_config_entry.subentries[new_subentry_id]
|
||
assert new_subentry.subentry_type == "tts"
|
||
assert new_subentry.title == "Custom TTS"
|
||
|
||
|
||
async def test_tts_subentry_not_loaded(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
) -> None:
|
||
"""Test creating a TTS subentry when entry is not loaded."""
|
||
# Don't call mock_init_component to simulate not loaded state
|
||
result = await hass.config_entries.subentries.async_init(
|
||
(mock_config_entry.entry_id, "tts"),
|
||
context={"source": config_entries.SOURCE_USER},
|
||
)
|
||
|
||
assert result.get("type") is FlowResultType.ABORT
|
||
assert result.get("reason") == "entry_not_loaded"
|
||
|
||
|
||
async def test_tts_reconfigure(
|
||
hass: HomeAssistant, mock_config_entry, mock_init_component
|
||
) -> None:
|
||
"""Test the tts subentry reconfigure flow."""
|
||
subentry = [
|
||
s for s in mock_config_entry.subentries.values() if s.subentry_type == "tts"
|
||
][0]
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
options = await hass.config_entries.subentries.async_configure(
|
||
subentry_flow["flow_id"],
|
||
{
|
||
"prompt": "Speak like a pirate",
|
||
"tts_speed": 0.5,
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
assert options["type"] is FlowResultType.ABORT
|
||
assert options["reason"] == "reconfigure_successful"
|
||
assert subentry.data["prompt"] == "Speak like a pirate"
|
||
assert subentry.data["tts_speed"] == 0.5
|
||
|
||
|
||
async def test_reauth(hass: HomeAssistant) -> None:
|
||
"""Test we can reauthenticate."""
|
||
# Pretend we already set up a config entry.
|
||
hass.config.components.add("openai_compatible")
|
||
mock_config_entry = MockConfigEntry(
|
||
domain=DOMAIN,
|
||
state=config_entries.ConfigEntryState.LOADED,
|
||
)
|
||
|
||
mock_config_entry.add_to_hass(hass)
|
||
result = await mock_config_entry.start_reauth_flow(hass)
|
||
|
||
assert result["type"] is FlowResultType.FORM
|
||
assert result["step_id"] == "reauth_confirm"
|
||
|
||
with (
|
||
patch(
|
||
"custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list",
|
||
new_callable=AsyncMock,
|
||
),
|
||
patch(
|
||
"custom_components.openai_compatible.async_setup_entry",
|
||
return_value=True,
|
||
),
|
||
):
|
||
result = await hass.config_entries.flow.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
CONF_API_KEY: "new_api_key",
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
assert result["type"] is FlowResultType.ABORT
|
||
assert result["reason"] == "reauth_successful"
|
||
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("current_llm_apis", "suggested_llm_apis", "expected_options"),
|
||
[
|
||
("assist", ["assist"], ["assist"]),
|
||
(["assist"], ["assist"], ["assist"]),
|
||
("non-existent", [], ["assist"]),
|
||
(["non-existent"], [], ["assist"]),
|
||
(["assist", "non-existent"], ["assist"], ["assist"]),
|
||
],
|
||
)
|
||
async def test_reconfigure_conversation_subentry_llm_api_schema(
|
||
hass: HomeAssistant,
|
||
mock_config_entry: MockConfigEntry,
|
||
mock_init_component,
|
||
current_llm_apis: list[str],
|
||
suggested_llm_apis: list[str],
|
||
expected_options: list[str],
|
||
) -> None:
|
||
"""Test llm_hass_api field values when reconfiguring a conversation subentry."""
|
||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||
hass.config_entries.async_update_subentry(
|
||
mock_config_entry,
|
||
subentry,
|
||
data={**subentry.data, CONF_LLM_HASS_API: current_llm_apis},
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
with patch(
|
||
"custom_components.openai_compatible.config_flow.openai.AsyncOpenAI.models",
|
||
):
|
||
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||
hass, subentry.subentry_id
|
||
)
|
||
|
||
assert subentry_flow["type"] is FlowResultType.FORM
|
||
assert subentry_flow["step_id"] == "init"
|
||
|
||
# Only valid LLM APIs should be suggested and shown as options
|
||
schema = subentry_flow["data_schema"].schema
|
||
key = next(k for k in schema if k == CONF_LLM_HASS_API)
|
||
assert key.description
|
||
assert key.description.get("suggested_value") == suggested_llm_apis
|
||
field_schema = schema[key]
|
||
assert field_schema.config
|
||
assert [
|
||
opt["value"] for opt in field_schema.config.get("options")
|
||
] == expected_options
|