Files
Adam SmithandClaude Opus 5 6a599e092a 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
2026-09-17 23:18:02 -04:00

140 lines
4.9 KiB
Python

"""Each endpoint type picks its model from what the provider advertises.
Upstream defaults every model field to an OpenAI name and offers no way to see
what the endpoint actually serves, so configuring a proxy meant typing exact
model ids from memory -- and for conversation/AI Task the field was skipped
entirely unless you knew to untick "recommended" first.
"""
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.selector import SelectSelector
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.openai_compatible.const import (
CONF_CHAT_MODEL,
CONF_RECOMMENDED,
)
PROVIDER_MODELS = ["claude-sonnet-4-6", "gpt-4o-mini", "whisper-1"]
DISCOVERY = "custom_components.openai_compatible.config_flow.async_fetch_model_ids"
def _field(result: dict, key: str):
"""Return the schema field for key in a shown form."""
schema = result["data_schema"].schema
return next(value for marker, value in schema.items() if marker == key)
def _marker(result: dict, key: str):
"""Return the schema marker (which carries the default) for key."""
schema = result["data_schema"].schema
return next(marker for marker in schema if marker == key)
async def _model_step(
hass: HomeAssistant,
entry: MockConfigEntry,
subentry_type: str,
models: list[str],
) -> dict:
"""Return the form that asks for the model, for any endpoint type.
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"
return result
@pytest.mark.parametrize(
"subentry_type", ["conversation", "ai_task_data", "stt", "tts"]
)
async def test_every_type_offers_the_providers_models(
hass: HomeAssistant,
mock_init_component: None,
mock_config_entry: MockConfigEntry,
subentry_type: str,
) -> None:
"""All four endpoint types get a dropdown, not a free-text box."""
result = await _model_step(
hass, mock_config_entry, subentry_type, PROVIDER_MODELS
)
field = _field(result, CONF_CHAT_MODEL)
assert isinstance(field, SelectSelector)
assert field.config["options"] == PROVIDER_MODELS
# A provider may route a model it does not advertise.
assert field.config["custom_value"] is True
@pytest.mark.parametrize(
"subentry_type", ["conversation", "ai_task_data", "stt", "tts"]
)
async def test_falls_back_to_free_text_when_provider_lists_nothing(
hass: HomeAssistant,
mock_init_component: None,
mock_config_entry: MockConfigEntry,
subentry_type: str,
) -> None:
"""An endpoint without /v1/models must still be configurable by hand."""
result = await _model_step(hass, mock_config_entry, subentry_type, [])
assert _field(result, CONF_CHAT_MODEL) is str
async def test_new_endpoint_starts_with_recommended_unticked(
hass: HomeAssistant,
mock_init_component: None,
mock_config_entry: MockConfigEntry,
) -> None:
"""Ticked, the flow ends on the first step and never asks for a model."""
with patch(DISCOVERY, return_value=PROVIDER_MODELS):
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "conversation"),
context={"source": config_entries.SOURCE_USER},
)
assert _marker(result, CONF_RECOMMENDED).default() is False
async def test_a_non_openai_model_name_survives_the_flow(
hass: HomeAssistant,
mock_init_component: None,
mock_config_entry: MockConfigEntry,
) -> None:
"""The point of the fork: the stored model is the provider's, and none of
upstream's OpenAI-specific model gates reject it."""
with patch(DISCOVERY, return_value=PROVIDER_MODELS):
result = await hass.config_entries.subentries.async_init(
(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_CHAT_MODEL: "claude-sonnet-4-6",
CONF_RECOMMENDED: True,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Sonnet via LiteLLM"
assert result["data"][CONF_CHAT_MODEL] == "claude-sonnet-4-6"