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
96 lines
3.5 KiB
Python
96 lines
3.5 KiB
Python
"""Model discovery for an OpenAI-compatible endpoint.
|
|
|
|
Upstream hardcodes OpenAI's model names as defaults and validates them
|
|
against lists of OpenAI models, which is useless against a proxy or a
|
|
self-hosted server serving entirely different names. This asks the endpoint
|
|
what it actually serves, so the config flow can offer a dropdown instead of
|
|
a free-text box the user has to get exactly right.
|
|
|
|
Kept separate from the vendored upstream files so re-syncing with
|
|
home-assistant/core never conflicts with it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import openai
|
|
|
|
from homeassistant.helpers.selector import (
|
|
SelectSelector,
|
|
SelectSelectorConfig,
|
|
SelectSelectorMode,
|
|
)
|
|
|
|
from .const import LOGGER, RECOMMENDED_CHAT_MODEL
|
|
|
|
DEFAULT_TIMEOUT = 10.0
|
|
|
|
|
|
async def async_fetch_model_ids(
|
|
client: openai.AsyncOpenAI, *, timeout: float = DEFAULT_TIMEOUT
|
|
) -> list[str]:
|
|
"""Return the endpoint's model ids, sorted and de-duplicated.
|
|
|
|
Returns an empty list if the endpoint cannot or will not list them. Model
|
|
discovery is a convenience: an endpoint that serves /v1/responses but not
|
|
/v1/models, or a key scoped so narrowly it cannot enumerate, must still be
|
|
configurable by typing a model name in by hand.
|
|
"""
|
|
try:
|
|
# max_retries=0: the SDK otherwise retries with backoff, and this call
|
|
# runs while the user waits on a config-flow dialog. Discovery is a
|
|
# convenience with a free-text fallback, so one quick attempt is the
|
|
# right trade -- retrying would just stall the form.
|
|
page = await client.with_options(
|
|
max_retries=0, timeout=timeout
|
|
).models.list()
|
|
except openai.OpenAIError as err:
|
|
LOGGER.debug("Could not list models: %s", err)
|
|
return []
|
|
except Exception: # noqa: BLE001 - see below
|
|
# Deliberately broad. This runs inside a config-flow form and has a
|
|
# working fallback (a free-text model field), so nothing it can raise
|
|
# justifies taking the dialog down: a proxy may return a payload the
|
|
# SDK cannot parse, and the failure modes of arbitrary
|
|
# OpenAI-compatible servers are not enumerable in advance.
|
|
LOGGER.debug("Unexpected error listing models", exc_info=True)
|
|
return []
|
|
|
|
return sorted({model.id for model in page.data if getattr(model, "id", None)})
|
|
|
|
|
|
def model_field(model_ids: list[str]) -> Any:
|
|
"""Return a schema field for choosing a model.
|
|
|
|
A dropdown of what the endpoint advertises when that is known, otherwise a
|
|
plain string field. The dropdown accepts a typed-in value too, since an
|
|
endpoint may route a model it does not list (LiteLLM wildcard routes, for
|
|
instance).
|
|
"""
|
|
if not model_ids:
|
|
return str
|
|
|
|
return SelectSelector(
|
|
SelectSelectorConfig(
|
|
options=model_ids,
|
|
custom_value=True,
|
|
mode=SelectSelectorMode.DROPDOWN,
|
|
sort=True,
|
|
)
|
|
)
|
|
|
|
|
|
def default_model(model_ids: list[str]) -> str:
|
|
"""Return the model to preselect for a new endpoint.
|
|
|
|
Upstream's default is RECOMMENDED_CHAT_MODEL, an OpenAI name. Offering it
|
|
to someone configuring a third-party provider preselects a model that will
|
|
fail on first use, so prefer it only when the provider actually serves it
|
|
(or when discovery told us nothing) and otherwise start from what is
|
|
advertised.
|
|
"""
|
|
if not model_ids or RECOMMENDED_CHAT_MODEL in model_ids:
|
|
return RECOMMENDED_CHAT_MODEL
|
|
return model_ids[0]
|