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
102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
"""Model discovery: turn an endpoint's GET /v1/models into a picker."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import openai
|
|
import pytest
|
|
|
|
from homeassistant.helpers.selector import SelectSelector
|
|
|
|
from custom_components.openai_compatible.model_list import (
|
|
async_fetch_model_ids,
|
|
model_field,
|
|
)
|
|
|
|
|
|
def _client(model_ids: list[str] | None = None, *, error: Exception | None = None):
|
|
"""Return a stub AsyncOpenAI whose models.list yields model_ids or raises."""
|
|
client = MagicMock()
|
|
# Production calls client.with_options(...).models.list(); returning the
|
|
# same stub keeps the per-request options visible to assertions.
|
|
client.with_options.return_value = client
|
|
if error is not None:
|
|
client.models.list = AsyncMock(side_effect=error)
|
|
else:
|
|
page = MagicMock()
|
|
page.data = [MagicMock(id=model_id) for model_id in (model_ids or [])]
|
|
client.models.list = AsyncMock(return_value=page)
|
|
return client
|
|
|
|
|
|
async def test_fetch_returns_sorted_unique_ids() -> None:
|
|
"""A proxy can list the same alias twice; the picker should show it once."""
|
|
client = _client(["gpt-4o-mini", "claude-sonnet", "gpt-4o-mini", "llama-3.3"])
|
|
|
|
assert await async_fetch_model_ids(client) == [
|
|
"claude-sonnet",
|
|
"gpt-4o-mini",
|
|
"llama-3.3",
|
|
]
|
|
|
|
|
|
async def test_fetch_ignores_entries_without_an_id() -> None:
|
|
"""Don't put a blank option in the dropdown."""
|
|
page = MagicMock()
|
|
page.data = [MagicMock(id="real-model"), MagicMock(id=None), MagicMock(id="")]
|
|
client = MagicMock()
|
|
client.with_options.return_value = client
|
|
client.models.list = AsyncMock(return_value=page)
|
|
|
|
assert await async_fetch_model_ids(client) == ["real-model"]
|
|
|
|
|
|
async def test_fetch_does_not_retry() -> None:
|
|
"""This runs while the user waits on a dialog; retries would stall it."""
|
|
client = _client(["gpt-4o-mini"])
|
|
|
|
await async_fetch_model_ids(client, timeout=3.0)
|
|
|
|
client.with_options.assert_called_once_with(max_retries=0, timeout=3.0)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
openai.APIConnectionError(request=MagicMock()),
|
|
openai.AuthenticationError(
|
|
"nope", response=MagicMock(status_code=401), body=None
|
|
),
|
|
],
|
|
)
|
|
async def test_fetch_survives_an_endpoint_that_will_not_list(error: Exception) -> None:
|
|
"""Model discovery is a convenience; it must never break the config flow."""
|
|
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:
|
|
"""A proxy may serve a model it does not advertise, so allow free text."""
|
|
field = model_field(["claude-sonnet", "gpt-4o-mini"])
|
|
|
|
assert isinstance(field, SelectSelector)
|
|
assert field.config["options"] == ["claude-sonnet", "gpt-4o-mini"]
|
|
assert field.config["custom_value"] is True
|
|
|
|
|
|
async def test_field_falls_back_to_free_text_when_no_models_are_known() -> None:
|
|
"""An endpoint that won't list models must still be configurable by hand."""
|
|
assert model_field([]) is str
|