Critical (review finding 1): async_step_reauth_confirm showed STEP_USER_DATA_SCHEMA directly instead of prefilling it via add_suggested_values_to_schema (unlike async_step_user, which does), so the reauth form displayed DEFAULT_BASE_URL rather than the entry's configured endpoint. A user rotating their API key would have their integration silently repointed from e.g. LiteLLM back to api.openai.com on save. Fixed by prefilling from self._get_reauth_entry().data, same as the user step. strings.json's reauth_confirm block now documents the base_url field too. Regression test: test_reauth_preserves_base_url (asserts both the shown form's suggested_value and the final stored base_url). Important (review finding 2): base_url is user-suppliable and could carry embedded user:pass@ credentials that later surface through vendored exception logging (e.g. entity.py's "Error talking to OpenAI: %s") this fork does not and should not edit. Rather than touch vendored logging, reject credentials at the boundary: normalize_base_url now raises the new CredentialsInBaseURL (a subclass of InvalidBaseURL) when the URL has a username or password, mapped to a distinct "credentials_in_url" form error telling the user to use the API key field instead. Tests added in test_url_util.py and test_config_flow.py. Important (review finding 3): documented on sanitize_url_for_logging that it is not a general-purpose credential scrubber -- it only masks what urlparse recognizes as a netloc's user:pass@ authority component, so a scheme-less/relative string with embedded credentials passes through unmasked. Safe today because every caller normalizes first (which now also rejects embedded credentials outright), but it is exported for a later task's use and must not be assumed bulletproof on an arbitrary unvalidated string. Not fixed (reviewer's Minor 4, by direction): the default-config setup call reaching api.openai.com with Authorization: Bearer not-required when both base_url and api_key are left at defaults -- cosmetic, and correctly surfaces as invalid_auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01739Seac5eMAfNwFeiwV5r4
1726 lines
59 KiB
Python
1726 lines
59 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}
|
||
|
||
|
||
async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
||
"""A base_url with embedded credentials must be rejected, not accepted."""
|
||
result = await hass.config_entries.flow.async_init(
|
||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||
)
|
||
|
||
result2 = await hass.config_entries.flow.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
CONF_BASE_URL: "https://user:secret@192.0.2.10:4000/v1",
|
||
"api_key": "bla",
|
||
},
|
||
)
|
||
|
||
assert result2["type"] is FlowResultType.FORM
|
||
assert result2["errors"] == {"base": "credentials_in_url"}
|
||
|
||
|
||
@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"
|
||
|
||
|
||
async def test_reauth_preserves_base_url(hass: HomeAssistant) -> None:
|
||
"""Reauth must not silently reset a configured base_url to the default.
|
||
|
||
Regression test: async_step_reauth_confirm used to show
|
||
STEP_USER_DATA_SCHEMA without prefilling suggested values (unlike
|
||
async_step_user, which does), so the reauth form displayed
|
||
DEFAULT_BASE_URL instead of the entry's actual base_url -- and a user
|
||
re-authenticating after a key rotation would have their integration
|
||
silently repointed from their configured endpoint (e.g. LiteLLM) back to
|
||
api.openai.com.
|
||
"""
|
||
hass.config.components.add("openai_compatible")
|
||
configured_base_url = "http://192.0.2.10:4000/v1"
|
||
mock_config_entry = MockConfigEntry(
|
||
domain=DOMAIN,
|
||
data={CONF_API_KEY: "old-key", CONF_BASE_URL: configured_base_url},
|
||
state=config_entries.ConfigEntryState.LOADED,
|
||
version=2,
|
||
minor_version=7,
|
||
)
|
||
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"
|
||
|
||
# The shown form must suggest the entry's own base_url, not the schema
|
||
# default -- this is the actual bug. Without the fix this assertion
|
||
# fails because no suggested_value is set at all.
|
||
schema_keys = result["data_schema"].schema
|
||
base_url_marker = next(k for k in schema_keys if k == CONF_BASE_URL)
|
||
assert base_url_marker.description == {"suggested_value": configured_base_url}
|
||
|
||
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,
|
||
),
|
||
):
|
||
# A real browser resubmits whatever is displayed in the form, so an
|
||
# unmodified base_url field is submitted back as-is alongside the
|
||
# new key -- this is what "the user didn't touch the field" means.
|
||
result = await hass.config_entries.flow.async_configure(
|
||
result["flow_id"],
|
||
{
|
||
CONF_API_KEY: "new_api_key",
|
||
CONF_BASE_URL: configured_base_url,
|
||
},
|
||
)
|
||
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"
|
||
assert mock_config_entry.data[CONF_BASE_URL] == configured_base_url
|
||
|
||
|
||
@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
|