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:
Adam SmithandClaude Opus 5 committed 2026-09-17 23:18:02 -04:00
1 parent 030822c845
commit 6a599e092a
7 files changed
+128 -91

No files matched your search

@@ -104,7 +104,7 @@ from .const import (
UNSUPPORTED_PRIORITY_SERVICE_TIERS_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 (
CredentialsInBaseURL,
InvalidBaseURL,
@@ -325,36 +325,58 @@ 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[
vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False))
] = bool
errors: dict[str, str] = {}
if user_input is not None:
if user_input.get(CONF_LLM_HASS_API) is None:
user_input.pop(CONF_LLM_HASS_API, None)
if user_input[CONF_RECOMMENDED]:
if self._is_new:
return self.async_create_entry(
title=user_input.pop(CONF_NAME),
# 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 self._is_new:
return self.async_create_entry(
title=user_input.pop(CONF_NAME),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
)
options.update(user_input)
if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input:
options.pop(CONF_LLM_HASS_API)
return await self.async_step_additional()
options.update(user_input)
if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input:
options.pop(CONF_LLM_HASS_API)
return await self.async_step_additional()
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(step_schema), options
),
errors=errors,
)
async def async_step_additional(
@@ -365,14 +387,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
errors: dict[str, str] = {}
step_schema: VolDictType = {
# Upstream makes this a free-text box defaulting to an OpenAI
# model name. Offer what the provider actually serves instead.
vol.Optional(
CONF_CHAT_MODEL,
default=RECOMMENDED_CHAT_MODEL,
): model_field(
await async_fetch_model_ids(self._get_entry().runtime_data)
),
# CONF_CHAT_MODEL is asked on the init step, so that it is
# reachable without unticking "recommended" first.
vol.Optional(
CONF_MAX_TOKENS,
default=RECOMMENDED_MAX_TOKENS,
@@ -393,8 +409,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
if user_input is not None:
options.update(user_input)
if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS:
errors[CONF_CHAT_MODEL] = "model_not_supported"
if not errors:
return await self.async_step_model()