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

+11 -24
View File
@@ -47,27 +47,17 @@ async def _model_step(
) -> dict:
"""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
ai_task_data ask on the step after it.
All four types ask on the first screen, so that picking a model never
depends on noticing the "recommended" checkbox first.
"""
with patch(DISCOVERY, return_value=models):
result = await hass.config_entries.subentries.async_init(
(entry.entry_id, subentry_type),
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
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
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
return result
@pytest.mark.parametrize(
@@ -133,18 +123,15 @@ async def test_a_non_openai_model_name_survives_the_flow(
(mock_config_entry.entry_id, "conversation"),
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["flow_id"],
{CONF_NAME: "Sonnet via LiteLLM", CONF_RECOMMENDED: False},
{
CONF_NAME: "Sonnet via LiteLLM",
CONF_CHAT_MODEL: "claude-sonnet-4-6",
CONF_RECOMMENDED: True,
},
)
result = await hass.config_entries.subentries.async_configure(
result["flow_id"], {CONF_CHAT_MODEL: "claude-sonnet-4-6"}
)
# 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()
assert result["type"] is FlowResultType.CREATE_ENTRY