Files
ha-openai-compatible/tests/test_model_list.py
T
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

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