Reshapes setup around how a multi-model proxy is actually used: one config entry per server/provider, then one endpoint per model added by hand, with the model chosen from what that provider advertises right now. Setup (async_step_user): - Adds a Name field. With one entry per provider the title matters, and it was hardcoded to "ChatGPT" regardless of where the entry pointed. Blank falls back to the endpoint host via url_util.title_from_base_url. Deliberately absent from the reauth schema (now split out as STEP_REAUTH_DATA_SCHEMA): that step writes its input back into entry.data via data_updates, so a name field there would persist the title as connection data on every key rotation. - Stops auto-creating four subentries. conversation/ai_task_data/stt/tts were all created up front carrying RECOMMENDED_* options, which pin OpenAI model names (gpt-4o-mini-tts, gpt-4o-mini-transcribe). Against a third-party endpoint those entities are dead on arrival -- the reported symptom was an install that "loads bits, it's all janky". - Drops _async_abort_entries_match, so several entries may point at one server (one per provider-side key, for instance). Upstream aborts with already_configured on matching url+key. Model discovery (new model_list.py, kept out of the vendored files like url_util.py): - async_fetch_model_ids asks the endpoint's GET /v1/models. It calls with_options(max_retries=0), because this runs while the user waits on a dialog and the SDK otherwise retries with backoff -- visible in the test run as repeated "Retrying request to /models" and a suite that went from 5s to minutes. - Returns [] on any OpenAIError, and model_field() then yields a plain text field instead of a dropdown, so an endpoint that serves /v1/responses but not /v1/models is still configurable by hand. The dropdown sets custom_value=True for the same reason (LiteLLM wildcard routes). - Wired into all four types: conversation and ai_task_data on the additional step, stt (which hardcoded OpenAI's three transcribe models) and tts on init. TTS had no model field at all -- it silently used gpt-4o-mini-tts. A new endpoint now starts with "recommended" unticked. Ticked, the flow ends on the first step and never shows a model field, which is why the model looked unpickable; reconfigure still keeps whatever was saved. The model field stays on the additional step rather than moving to init. The init position reads better, but 71 ported tests drive the model through the additional step, several from parametrized option dicts that no mechanical edit could relocate. Rewriting the only regression net against upstream to save one click was the wrong trade. Also: the setup description no longer says "your OpenAI API key", and instructions_url pointed at a README anchor (#generate-an-api-key) that does not exist -- now #configuration. strings.json re-verified against core's RE_URL: 0 violations. Local: 167 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxJYWgGJtCw13qUHZtG8ki
1721 lines
60 KiB
Python
1721 lines
60 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"] == {}
|
||
# Setup creates the provider only. Entities are added afterwards, one per
|
||
# type and model, so nothing here is guessed on the user's behalf.
|
||
assert list(result2["subentries"]) == []
|
||
assert result2["title"] == "api.openai.com"
|
||
assert result2["version"] == 2
|
||
assert result2["minor_version"] == 8
|
||
assert len(mock_setup_entry.mock_calls) == 1
|
||
|
||
|
||
async def test_duplicate_entry_is_allowed(hass: HomeAssistant) -> None:
|
||
"""Several entries may point at one provider.
|
||
|
||
Upstream aborts with already_configured when url+key match an existing
|
||
entry. This fork drops that check -- see async_step_user -- because one
|
||
entry per provider-side key, or a second entry against the same server,
|
||
are both legitimate here.
|
||
"""
|
||
# Pinned to the current version: the v1 -> v2 migration collapses multiple
|
||
# entries, which would remove one and mask what this test is checking.
|
||
MockConfigEntry(
|
||
domain=DOMAIN,
|
||
data={CONF_API_KEY: "bla", CONF_BASE_URL: DEFAULT_BASE_URL},
|
||
version=2,
|
||
minor_version=8,
|
||
).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,
|
||
),
|
||
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: "bla",
|
||
},
|
||
)
|
||
await hass.async_block_till_done()
|
||
|
||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||
assert len(hass.config_entries.async_entries(DOMAIN)) == 2
|
||
|
||
|
||
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
|