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
140 lines
4.9 KiB
Python
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"
|