feat: ask for the model on the first screen of the endpoint flow
The model field was one screen too late. Reported directly: "this is where I would have expected to see the model dropdown", on the AI Task init step that showed only Name and the recommended checkbox. - CONF_CHAT_MODEL moves from async_step_additional to async_step_init for conversation and ai_task_data, so all four endpoint types now ask on the first screen. stt and tts already did. - UNSUPPORTED_MODELS is validated there too, since the recommended path creates the subentry from that step and would otherwise skip the check. - model_list.default_model() picks what to preselect: RECOMMENDED_CHAT_MODEL only when the provider actually serves it (or discovery returned nothing), otherwise the first advertised model. Offering a stranger's gpt-4o-mini to a LiteLLM user preselects a model that fails on first use. This is the trade I got wrong last commit. I kept the field on the second step to avoid touching 71 ported tests, having judged them unfixable mechanically. They were not: the 71 were ~13 functions under parametrize, and test_subentry_switching's data is already a list of per-step dicts, so moving a model between steps is moving a key between two dicts. Two scripted passes moved 14 of them; 5 needed reasoning about (an error that now surfaces on init rather than the next step, and four cases whose expected data gains the now-always-recorded model). async_fetch_model_ids now also catches non-OpenAIError exceptions. Found via a ported test whose client is a MagicMock: the TypeError from awaiting it escaped and broke the whole form. A real proxy returning a payload the SDK cannot parse would do the same, and this runs inside a dialog with a working free-text fallback, so nothing it raises justifies taking the form down. Also fixes a string that was simply false on a third-party provider, again reported from the UI: "Store requests and responses in OpenAI" / "stored by OpenAI and visible in your OpenAI dashboard logs". It now describes the Responses API store flag and says support varies. Swept the other 43 OpenAI-mentioning strings while there -- the rest are [%key:...%] self-references or this fork's own action names, and no other option text makes a claim about who stores the data. Local: 170 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxJYWgGJtCw13qUHZtG8ki
This commit is contained in:
1 parent
030822c845
commit
6a599e092a
7 files changed
+112
-75
No files matched your search
@@ -104,7 +104,7 @@ from .const import (
|
|||||||
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS,
|
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS,
|
||||||
UNSUPPORTED_WEB_SEARCH_MODELS,
|
UNSUPPORTED_WEB_SEARCH_MODELS,
|
||||||
)
|
)
|
||||||
from .model_list import async_fetch_model_ids, model_field
|
from .model_list import async_fetch_model_ids, default_model, model_field
|
||||||
from .url_util import (
|
from .url_util import (
|
||||||
CredentialsInBaseURL,
|
CredentialsInBaseURL,
|
||||||
InvalidBaseURL,
|
InvalidBaseURL,
|
||||||
@@ -325,14 +325,35 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The model belongs on the first screen. Picking one is the entire
|
||||||
|
# point of adding an endpoint, and behind the "recommended" checkbox
|
||||||
|
# it was unreachable: ticked, this step creates the subentry and the
|
||||||
|
# model silently stays RECOMMENDED_CHAT_MODEL -- an OpenAI name a
|
||||||
|
# third-party provider will not serve. entity.py reads
|
||||||
|
# CONF_CHAT_MODEL regardless of the recommended flag, so a model
|
||||||
|
# chosen here takes effect on either path.
|
||||||
|
model_ids = await async_fetch_model_ids(self._get_entry().runtime_data)
|
||||||
|
step_schema[
|
||||||
|
vol.Optional(CONF_CHAT_MODEL, default=default_model(model_ids))
|
||||||
|
] = model_field(model_ids)
|
||||||
|
|
||||||
step_schema[
|
step_schema[
|
||||||
vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False))
|
vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False))
|
||||||
] = bool
|
] = bool
|
||||||
|
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if user_input.get(CONF_LLM_HASS_API) is None:
|
if user_input.get(CONF_LLM_HASS_API) is None:
|
||||||
user_input.pop(CONF_LLM_HASS_API, None)
|
user_input.pop(CONF_LLM_HASS_API, None)
|
||||||
|
|
||||||
|
# Checked here rather than on the next step, because the
|
||||||
|
# recommended path creates the subentry from this one and would
|
||||||
|
# otherwise skip the check entirely.
|
||||||
|
if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS:
|
||||||
|
errors[CONF_CHAT_MODEL] = "model_not_supported"
|
||||||
|
|
||||||
|
if not errors:
|
||||||
if user_input[CONF_RECOMMENDED]:
|
if user_input[CONF_RECOMMENDED]:
|
||||||
if self._is_new:
|
if self._is_new:
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
@@ -355,6 +376,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
data_schema=self.add_suggested_values_to_schema(
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
vol.Schema(step_schema), options
|
vol.Schema(step_schema), options
|
||||||
),
|
),
|
||||||
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_additional(
|
async def async_step_additional(
|
||||||
@@ -365,14 +387,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
step_schema: VolDictType = {
|
step_schema: VolDictType = {
|
||||||
# Upstream makes this a free-text box defaulting to an OpenAI
|
# CONF_CHAT_MODEL is asked on the init step, so that it is
|
||||||
# model name. Offer what the provider actually serves instead.
|
# reachable without unticking "recommended" first.
|
||||||
vol.Optional(
|
|
||||||
CONF_CHAT_MODEL,
|
|
||||||
default=RECOMMENDED_CHAT_MODEL,
|
|
||||||
): model_field(
|
|
||||||
await async_fetch_model_ids(self._get_entry().runtime_data)
|
|
||||||
),
|
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_MAX_TOKENS,
|
CONF_MAX_TOKENS,
|
||||||
default=RECOMMENDED_MAX_TOKENS,
|
default=RECOMMENDED_MAX_TOKENS,
|
||||||
@@ -393,8 +409,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
options.update(user_input)
|
options.update(user_input)
|
||||||
if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS:
|
|
||||||
errors[CONF_CHAT_MODEL] = "model_not_supported"
|
|
||||||
|
|
||||||
if not errors:
|
if not errors:
|
||||||
return await self.async_step_model()
|
return await self.async_step_model()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from homeassistant.helpers.selector import (
|
|||||||
SelectSelectorMode,
|
SelectSelectorMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .const import LOGGER
|
from .const import LOGGER, RECOMMENDED_CHAT_MODEL
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 10.0
|
DEFAULT_TIMEOUT = 10.0
|
||||||
|
|
||||||
@@ -48,6 +48,14 @@ async def async_fetch_model_ids(
|
|||||||
except openai.OpenAIError as err:
|
except openai.OpenAIError as err:
|
||||||
LOGGER.debug("Could not list models: %s", err)
|
LOGGER.debug("Could not list models: %s", err)
|
||||||
return []
|
return []
|
||||||
|
except Exception: # noqa: BLE001 - see below
|
||||||
|
# Deliberately broad. This runs inside a config-flow form and has a
|
||||||
|
# working fallback (a free-text model field), so nothing it can raise
|
||||||
|
# justifies taking the dialog down: a proxy may return a payload the
|
||||||
|
# SDK cannot parse, and the failure modes of arbitrary
|
||||||
|
# OpenAI-compatible servers are not enumerable in advance.
|
||||||
|
LOGGER.debug("Unexpected error listing models", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
return sorted({model.id for model in page.data if getattr(model, "id", None)})
|
return sorted({model.id for model in page.data if getattr(model, "id", None)})
|
||||||
|
|
||||||
@@ -71,3 +79,17 @@ def model_field(model_ids: list[str]) -> Any:
|
|||||||
sort=True,
|
sort=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_model(model_ids: list[str]) -> str:
|
||||||
|
"""Return the model to preselect for a new endpoint.
|
||||||
|
|
||||||
|
Upstream's default is RECOMMENDED_CHAT_MODEL, an OpenAI name. Offering it
|
||||||
|
to someone configuring a third-party provider preselects a model that will
|
||||||
|
fail on first use, so prefer it only when the provider actually serves it
|
||||||
|
(or when discovery told us nothing) and otherwise start from what is
|
||||||
|
advertised.
|
||||||
|
"""
|
||||||
|
if not model_ids or RECOMMENDED_CHAT_MODEL in model_ids:
|
||||||
|
return RECOMMENDED_CHAT_MODEL
|
||||||
|
return model_ids[0]
|
||||||
@@ -123,12 +123,12 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"chat_model": "[%key:common::generic::model%]",
|
"chat_model": "[%key:common::generic::model%]",
|
||||||
"max_tokens": "Maximum tokens to return in response",
|
"max_tokens": "Maximum tokens to return in response",
|
||||||
"store_responses": "Store requests and responses in OpenAI",
|
"store_responses": "Store requests and responses on the provider",
|
||||||
"temperature": "Temperature",
|
"temperature": "Temperature",
|
||||||
"top_p": "Top P"
|
"top_p": "Top P"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs"
|
"store_responses": "Sets the Responses API store flag, asking the provider to keep requests and responses in its own logs. Support varies: OpenAI shows them in its dashboard, while many compatible servers ignore the flag entirely."
|
||||||
},
|
},
|
||||||
"title": "Additional settings"
|
"title": "Additional settings"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,12 +59,12 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"chat_model": "Model",
|
"chat_model": "Model",
|
||||||
"max_tokens": "Maximum tokens to return in response",
|
"max_tokens": "Maximum tokens to return in response",
|
||||||
"store_responses": "Store requests and responses in OpenAI",
|
"store_responses": "Store requests and responses on the provider",
|
||||||
"temperature": "Temperature",
|
"temperature": "Temperature",
|
||||||
"top_p": "Top P"
|
"top_p": "Top P"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs"
|
"store_responses": "Sets the Responses API store flag, asking the provider to keep requests and responses in its own logs. Support varies: OpenAI shows them in its dashboard, while many compatible servers ignore the flag entirely."
|
||||||
},
|
},
|
||||||
"title": "Additional settings"
|
"title": "Additional settings"
|
||||||
},
|
},
|
||||||
@@ -123,12 +123,12 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"chat_model": "Model",
|
"chat_model": "Model",
|
||||||
"max_tokens": "Maximum tokens to return in response",
|
"max_tokens": "Maximum tokens to return in response",
|
||||||
"store_responses": "Store requests and responses in OpenAI",
|
"store_responses": "Store requests and responses on the provider",
|
||||||
"temperature": "Temperature",
|
"temperature": "Temperature",
|
||||||
"top_p": "Top P"
|
"top_p": "Top P"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs"
|
"store_responses": "Sets the Responses API store flag, asking the provider to keep requests and responses in its own logs. Support varies: OpenAI shows them in its dashboard, while many compatible servers ignore the flag entirely."
|
||||||
},
|
},
|
||||||
"title": "Additional settings"
|
"title": "Additional settings"
|
||||||
},
|
},
|
||||||
|
|||||||
+36
-36
@@ -177,6 +177,9 @@ async def test_creating_conversation_subentry(
|
|||||||
|
|
||||||
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||||
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
|
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
|
||||||
|
# The init step now asks for the model, so the created subentry records
|
||||||
|
# one explicitly instead of falling back to the default at request time.
|
||||||
|
processed_options[CONF_CHAT_MODEL] = RECOMMENDED_CHAT_MODEL
|
||||||
|
|
||||||
assert result2["data"] == processed_options
|
assert result2["data"] == processed_options
|
||||||
|
|
||||||
@@ -239,23 +242,17 @@ async def test_subentry_unsupported_model(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "o1-mini",
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
CONF_LLM_HASS_API: ["assist"],
|
CONF_LLM_HASS_API: ["assist"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert subentry_flow["type"] is FlowResultType.FORM
|
|
||||||
assert subentry_flow["step_id"] == "additional"
|
|
||||||
|
|
||||||
# Configure additional step
|
# The model is asked for, and so validated, on the init step -- the flow
|
||||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
# stays there and reports the error against the field.
|
||||||
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["type"] is FlowResultType.FORM
|
||||||
|
assert subentry_flow["step_id"] == "init"
|
||||||
assert subentry_flow["errors"] == {"chat_model": "model_not_supported"}
|
assert subentry_flow["errors"] == {"chat_model": "model_not_supported"}
|
||||||
|
|
||||||
|
|
||||||
@@ -295,6 +292,7 @@ async def test_subentry_reasoning_effort_list(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: model,
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
CONF_LLM_HASS_API: ["assist"],
|
CONF_LLM_HASS_API: ["assist"],
|
||||||
},
|
},
|
||||||
@@ -305,9 +303,7 @@ async def test_subentry_reasoning_effort_list(
|
|||||||
# Configure additional step
|
# Configure additional step
|
||||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{},
|
||||||
CONF_CHAT_MODEL: model,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert subentry_flow["type"] is FlowResultType.FORM
|
assert subentry_flow["type"] is FlowResultType.FORM
|
||||||
assert subentry_flow["step_id"] == "model"
|
assert subentry_flow["step_id"] == "model"
|
||||||
@@ -349,6 +345,7 @@ async def test_subentry_reasoning_summary_visibility(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: model,
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
CONF_LLM_HASS_API: ["assist"],
|
CONF_LLM_HASS_API: ["assist"],
|
||||||
},
|
},
|
||||||
@@ -359,9 +356,7 @@ async def test_subentry_reasoning_summary_visibility(
|
|||||||
# Configure additional step
|
# Configure additional step
|
||||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{},
|
||||||
CONF_CHAT_MODEL: model,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert subentry_flow["type"] is FlowResultType.FORM
|
assert subentry_flow["type"] is FlowResultType.FORM
|
||||||
assert subentry_flow["step_id"] == "model"
|
assert subentry_flow["step_id"] == "model"
|
||||||
@@ -398,6 +393,7 @@ async def test_subentry_reasoning_summary_options(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: model,
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
CONF_LLM_HASS_API: ["assist"],
|
CONF_LLM_HASS_API: ["assist"],
|
||||||
},
|
},
|
||||||
@@ -407,9 +403,7 @@ async def test_subentry_reasoning_summary_options(
|
|||||||
|
|
||||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{},
|
||||||
CONF_CHAT_MODEL: model,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert subentry_flow["type"] is FlowResultType.FORM
|
assert subentry_flow["type"] is FlowResultType.FORM
|
||||||
assert subentry_flow["step_id"] == "model"
|
assert subentry_flow["step_id"] == "model"
|
||||||
@@ -446,6 +440,7 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "o3",
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
CONF_LLM_HASS_API: ["assist"],
|
CONF_LLM_HASS_API: ["assist"],
|
||||||
},
|
},
|
||||||
@@ -454,7 +449,7 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch(
|
|||||||
|
|
||||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{CONF_CHAT_MODEL: "o3"},
|
{},
|
||||||
)
|
)
|
||||||
assert subentry_flow["step_id"] == "model"
|
assert subentry_flow["step_id"] == "model"
|
||||||
|
|
||||||
@@ -512,6 +507,7 @@ async def test_subentry_service_tier_list(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: model,
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
CONF_LLM_HASS_API: ["assist"],
|
CONF_LLM_HASS_API: ["assist"],
|
||||||
},
|
},
|
||||||
@@ -522,9 +518,7 @@ async def test_subentry_service_tier_list(
|
|||||||
# Configure additional step
|
# Configure additional step
|
||||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{},
|
||||||
CONF_CHAT_MODEL: model,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert subentry_flow["type"] is FlowResultType.FORM
|
assert subentry_flow["type"] is FlowResultType.FORM
|
||||||
assert subentry_flow["step_id"] == "model"
|
assert subentry_flow["step_id"] == "model"
|
||||||
@@ -558,6 +552,7 @@ async def test_subentry_unsupported_reasoning_effort(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "gpt-5",
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
CONF_LLM_HASS_API: ["assist"],
|
CONF_LLM_HASS_API: ["assist"],
|
||||||
},
|
},
|
||||||
@@ -568,9 +563,7 @@ async def test_subentry_unsupported_reasoning_effort(
|
|||||||
# Configure additional step
|
# Configure additional step
|
||||||
subentry_flow = await hass.config_entries.subentries.async_configure(
|
subentry_flow = await hass.config_entries.subentries.async_configure(
|
||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{},
|
||||||
CONF_CHAT_MODEL: "gpt-5",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert subentry_flow["type"] is FlowResultType.FORM
|
assert subentry_flow["type"] is FlowResultType.FORM
|
||||||
assert subentry_flow["step_id"] == "model"
|
assert subentry_flow["step_id"] == "model"
|
||||||
@@ -706,11 +699,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "o1-pro",
|
||||||
CONF_PROMPT: "Speak like a pro",
|
CONF_PROMPT: "Speak like a pro",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 1.0,
|
CONF_TEMPERATURE: 1.0,
|
||||||
CONF_CHAT_MODEL: "o1-pro",
|
|
||||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||||
CONF_MAX_TOKENS: 10000,
|
CONF_MAX_TOKENS: 10000,
|
||||||
},
|
},
|
||||||
@@ -740,11 +733,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 0.3,
|
CONF_TEMPERATURE: 0.3,
|
||||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
|
||||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||||
},
|
},
|
||||||
@@ -797,11 +790,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "gpt-4o",
|
||||||
CONF_PROMPT: "Speak like super Mario",
|
CONF_PROMPT: "Speak like super Mario",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 0.8,
|
CONF_TEMPERATURE: 0.8,
|
||||||
CONF_CHAT_MODEL: "gpt-4o",
|
|
||||||
CONF_TOP_P: 0.9,
|
CONF_TOP_P: 0.9,
|
||||||
CONF_MAX_TOKENS: 1000,
|
CONF_MAX_TOKENS: 1000,
|
||||||
CONF_STORE_RESPONSES: True,
|
CONF_STORE_RESPONSES: True,
|
||||||
@@ -852,11 +845,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "gpt-5.6",
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 0.8,
|
CONF_TEMPERATURE: 0.8,
|
||||||
CONF_CHAT_MODEL: "gpt-5.6",
|
|
||||||
CONF_TOP_P: 0.9,
|
CONF_TOP_P: 0.9,
|
||||||
CONF_MAX_TOKENS: 1000,
|
CONF_MAX_TOKENS: 1000,
|
||||||
},
|
},
|
||||||
@@ -975,11 +968,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "o3-mini",
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 0.8,
|
CONF_TEMPERATURE: 0.8,
|
||||||
CONF_CHAT_MODEL: "o3-mini",
|
|
||||||
CONF_TOP_P: 0.9,
|
CONF_TOP_P: 0.9,
|
||||||
CONF_MAX_TOKENS: 1000,
|
CONF_MAX_TOKENS: 1000,
|
||||||
},
|
},
|
||||||
@@ -1020,11 +1013,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "gpt-4o",
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 0.8,
|
CONF_TEMPERATURE: 0.8,
|
||||||
CONF_CHAT_MODEL: "gpt-4o",
|
|
||||||
CONF_TOP_P: 0.9,
|
CONF_TOP_P: 0.9,
|
||||||
CONF_MAX_TOKENS: 1000,
|
CONF_MAX_TOKENS: 1000,
|
||||||
},
|
},
|
||||||
@@ -1075,11 +1068,11 @@ async def test_form_credentials_in_base_url(hass: HomeAssistant) -> None:
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "gpt-5-pro",
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 0.8,
|
CONF_TEMPERATURE: 0.8,
|
||||||
CONF_CHAT_MODEL: "gpt-5-pro",
|
|
||||||
CONF_TOP_P: 0.9,
|
CONF_TOP_P: 0.9,
|
||||||
CONF_MAX_TOKENS: 1000,
|
CONF_MAX_TOKENS: 1000,
|
||||||
},
|
},
|
||||||
@@ -1155,7 +1148,13 @@ async def test_subentry_switching(
|
|||||||
|
|
||||||
assert subentry_flow["type"] is FlowResultType.ABORT
|
assert subentry_flow["type"] is FlowResultType.ABORT
|
||||||
assert subentry_flow["reason"] == "reconfigure_successful"
|
assert subentry_flow["reason"] == "reconfigure_successful"
|
||||||
assert subentry.data == expected_options
|
# The init step always asks for a model, so every subentry records one.
|
||||||
|
# Cases that do not name one get the default, which is what the flow
|
||||||
|
# submits for them; cases that do still win, being unpacked second.
|
||||||
|
assert subentry.data == {
|
||||||
|
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||||
|
**expected_options,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("store_responses", [False, True])
|
@pytest.mark.parametrize("store_responses", [False, True])
|
||||||
@@ -1183,6 +1182,7 @@ async def test_subentry_web_search_user_location(
|
|||||||
{
|
{
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
CONF_PROMPT: "Speak like a pirate",
|
CONF_PROMPT: "Speak like a pirate",
|
||||||
|
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert subentry_flow["type"] is FlowResultType.FORM
|
assert subentry_flow["type"] is FlowResultType.FORM
|
||||||
@@ -1193,7 +1193,6 @@ async def test_subentry_web_search_user_location(
|
|||||||
subentry_flow["flow_id"],
|
subentry_flow["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_TEMPERATURE: 1.0,
|
CONF_TEMPERATURE: 1.0,
|
||||||
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
|
||||||
CONF_TOP_P: RECOMMENDED_TOP_P,
|
CONF_TOP_P: RECOMMENDED_TOP_P,
|
||||||
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
|
||||||
CONF_STORE_RESPONSES: store_responses,
|
CONF_STORE_RESPONSES: store_responses,
|
||||||
@@ -1307,6 +1306,7 @@ async def test_creating_ai_task_subentry(
|
|||||||
assert result2.get("title") == "Custom AI Task"
|
assert result2.get("title") == "Custom AI Task"
|
||||||
assert result2.get("data") == {
|
assert result2.get("data") == {
|
||||||
CONF_RECOMMENDED: True,
|
CONF_RECOMMENDED: True,
|
||||||
|
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
|
||||||
}
|
}
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
@@ -1354,6 +1354,7 @@ async def test_creating_ai_task_subentry_additional(
|
|||||||
{
|
{
|
||||||
"name": "Advanced AI Task",
|
"name": "Advanced AI Task",
|
||||||
CONF_RECOMMENDED: False,
|
CONF_RECOMMENDED: False,
|
||||||
|
CONF_CHAT_MODEL: "gpt-4o",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1364,7 +1365,6 @@ async def test_creating_ai_task_subentry_additional(
|
|||||||
result3 = await hass.config_entries.subentries.async_configure(
|
result3 = await hass.config_entries.subentries.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
CONF_CHAT_MODEL: "gpt-4o",
|
|
||||||
CONF_MAX_TOKENS: 200,
|
CONF_MAX_TOKENS: 200,
|
||||||
CONF_STORE_RESPONSES: True,
|
CONF_STORE_RESPONSES: True,
|
||||||
CONF_TEMPERATURE: 0.5,
|
CONF_TEMPERATURE: 0.5,
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ async def test_fetch_survives_an_endpoint_that_will_not_list(error: Exception) -
|
|||||||
assert await async_fetch_model_ids(_client(error=error)) == []
|
assert await async_fetch_model_ids(_client(error=error)) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_survives_a_non_openai_error() -> None:
|
||||||
|
"""Discovery must never break the form it runs inside.
|
||||||
|
|
||||||
|
A proxy can return a payload the SDK cannot parse, or a client can be
|
||||||
|
wired in a way that raises something other than an OpenAIError; either way
|
||||||
|
the dropdown should degrade to a text box, not take the dialog down.
|
||||||
|
"""
|
||||||
|
client = MagicMock()
|
||||||
|
client.with_options.return_value = client
|
||||||
|
client.models.list = MagicMock(return_value="not awaitable")
|
||||||
|
|
||||||
|
assert await async_fetch_model_ids(client) == []
|
||||||
|
|
||||||
|
|
||||||
async def test_field_is_a_dropdown_that_still_accepts_a_typed_name() -> None:
|
async def test_field_is_a_dropdown_that_still_accepts_a_typed_name() -> None:
|
||||||
"""A proxy may serve a model it does not advertise, so allow free text."""
|
"""A proxy may serve a model it does not advertise, so allow free text."""
|
||||||
field = model_field(["claude-sonnet", "gpt-4o-mini"])
|
field = model_field(["claude-sonnet", "gpt-4o-mini"])
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ async def _model_step(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return the form that asks for the model, for any endpoint type.
|
"""Return the form that asks for the model, for any endpoint type.
|
||||||
|
|
||||||
stt and tts are single-step flows that ask on init; conversation and
|
All four types ask on the first screen, so that picking a model never
|
||||||
ai_task_data ask on the step after it.
|
depends on noticing the "recommended" checkbox first.
|
||||||
"""
|
"""
|
||||||
with patch(DISCOVERY, return_value=models):
|
with patch(DISCOVERY, return_value=models):
|
||||||
result = await hass.config_entries.subentries.async_init(
|
result = await hass.config_entries.subentries.async_init(
|
||||||
@@ -57,16 +57,6 @@ async def _model_step(
|
|||||||
)
|
)
|
||||||
assert result["type"] is FlowResultType.FORM
|
assert result["type"] is FlowResultType.FORM
|
||||||
assert result["step_id"] == "init"
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
if subentry_type in ("stt", "tts"):
|
|
||||||
return result
|
|
||||||
|
|
||||||
result = await hass.config_entries.subentries.async_configure(
|
|
||||||
result["flow_id"],
|
|
||||||
{CONF_NAME: "An endpoint", CONF_RECOMMENDED: False},
|
|
||||||
)
|
|
||||||
assert result["type"] is FlowResultType.FORM
|
|
||||||
assert result["step_id"] == "additional"
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -133,17 +123,14 @@ async def test_a_non_openai_model_name_survives_the_flow(
|
|||||||
(mock_config_entry.entry_id, "conversation"),
|
(mock_config_entry.entry_id, "conversation"),
|
||||||
context={"source": config_entries.SOURCE_USER},
|
context={"source": config_entries.SOURCE_USER},
|
||||||
)
|
)
|
||||||
|
# The model is chosen on the very first screen, alongside the name.
|
||||||
result = await hass.config_entries.subentries.async_configure(
|
result = await hass.config_entries.subentries.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{CONF_NAME: "Sonnet via LiteLLM", CONF_RECOMMENDED: False},
|
{
|
||||||
)
|
CONF_NAME: "Sonnet via LiteLLM",
|
||||||
result = await hass.config_entries.subentries.async_configure(
|
CONF_CHAT_MODEL: "claude-sonnet-4-6",
|
||||||
result["flow_id"], {CONF_CHAT_MODEL: "claude-sonnet-4-6"}
|
CONF_RECOMMENDED: True,
|
||||||
)
|
},
|
||||||
# remaining steps take their defaults
|
|
||||||
while result["type"] is FlowResultType.FORM:
|
|
||||||
result = await hass.config_entries.subentries.async_configure(
|
|
||||||
result["flow_id"], {}
|
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user