diff --git a/README.md b/README.md index 49420e8..716512b 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,15 @@ control, routing, key management or privacy, or you self-host models, there is n tell the core integration about it. This fork adds a **Base URL** field to the config flow and threads it to every client it creates. -## What you get +## How it is organised -Everything the core integration provides, as config **subentries** you can add more than -one of: +**One entry per server or provider**, and **one endpoint per model** underneath it. +Setup asks only for the connection; nothing is created on your behalf. You then add +endpoints yourself, picking the type and the model each time — so a single LiteLLM entry +can carry a conversation agent on one model, another on a second, and a TTS endpoint on +a third. + +Each endpoint is one of four types, and each becomes its own Home Assistant entity: - **Conversation agent** — an Assist conversation entity, with the LLM Assist API, tool calling, and the full upstream option set (model, temperature, top-p, max tokens, @@ -30,7 +35,9 @@ one of: - **AI Task** — for `ai_task.generate_data` / image generation - **Speech-to-text** — an STT entity - **Text-to-speech** — a TTS entity, with speed control -- **Actions** — `openai_compatible.generate_content` and `openai_compatible.generate_image` + +Plus two actions on the entry itself: `openai_compatible.generate_content` and +`openai_compatible.generate_image`. ## Requirements @@ -53,15 +60,32 @@ Copy `custom_components/openai_compatible/` into your Home Assistant ## Configuration -The initial dialog asks for two things: +### 1. Add the provider | Field | Notes | | --- | --- | +| **Name** | Label for this provider, e.g. `LiteLLM`. Leave blank to use the endpoint's host. | | **Base URL** | Full URL including any version path, e.g. `https://api.openai.com/v1`. Passed to the OpenAI SDK as-is. | | **API key** | Optional — **leave blank** for an endpoint that does not authenticate. | -Then add one subentry per capability you want (conversation, AI Task, STT, TTS), each -with its own model and options. +Submitting it checks the connection with `GET /v1/models` and creates the entry. No +entities are created yet, deliberately: guessing them would mean pointing STT and TTS at +OpenAI model names your provider probably does not serve. + +You can add **several entries for the same server** — one per provider-side key, say. +Upstream refuses this as a duplicate; this fork does not. + +### 2. Add endpoints for the models you want + +On the entry, use **Add conversation agent** / **Add AI task** / **Add speech-to-text** / +**Add text-to-speech**. Each flow offers a **model dropdown populated from that +provider's own `GET /v1/models`**, read when you open the form, so it lists what is +actually available right then. The dropdown also accepts a typed-in name, for a provider +that routes models it does not advertise (LiteLLM wildcard routes, for instance), and +falls back to a plain text box if the endpoint will not list models at all. + +Repeat per model. Name each endpoint something you will recognise in Assist — the +subentry title becomes the entity name. ### Base URL rules @@ -109,12 +133,16 @@ versions; several popular local servers still do not. ### Model names -The default and "recommended" model names come from upstream and are OpenAI's -(`gpt-4o-mini`, `gpt-4o-mini-tts`, `gpt-4o-mini-transcribe`, …). Set the model your -endpoint actually serves in each subentry's options. Upstream's lists of models that -don't support web search, image generation, and so on are also keyed to OpenAI names, so -they won't match a third-party model string — the endpoint's own error is what you'll -see if you ask for something it can't do. +Pick from the dropdown and this mostly takes care of itself. Two upstream behaviours to +know about anyway: + +- The fallback defaults are OpenAI's (`gpt-4o-mini`, `gpt-4o-mini-tts`, + `gpt-4o-mini-transcribe`), used only if you never choose a model. A new endpoint starts + with **Recommended settings** unticked so the flow always asks. +- Upstream's lists of models that don't support web search, image generation, reasoning + effort and so on are keyed to OpenAI names, so they won't match a third-party model + string. Options are offered based on those name patterns, and the endpoint's own error + is what you'll see if you ask for something it can't do. ## Relationship to Home Assistant core diff --git a/custom_components/openai_compatible/config_flow.py b/custom_components/openai_compatible/config_flow.py index 4f0df15..0f1dc13 100644 --- a/custom_components/openai_compatible/config_flow.py +++ b/custom_components/openai_compatible/config_flow.py @@ -104,12 +104,31 @@ from .const import ( UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS, UNSUPPORTED_WEB_SEARCH_MODELS, ) -from .url_util import CredentialsInBaseURL, InvalidBaseURL, normalize_base_url +from .model_list import async_fetch_model_ids, model_field +from .url_util import ( + CredentialsInBaseURL, + InvalidBaseURL, + normalize_base_url, + title_from_base_url, +) _LOGGER = logging.getLogger(__name__) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str, + vol.Optional(CONF_API_KEY, default=""): str, + } +) + +# The user step additionally names the provider, since there is one entry per +# server and the host alone is a poor label. Reauth deliberately reuses the +# schema *without* the name: that step writes its input straight back into +# entry.data via data_updates, so a name field there would persist the title +# as connection data on every key rotation. STEP_USER_DATA_SCHEMA = vol.Schema( { + vol.Optional(CONF_NAME, default=""): str, vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str, vol.Optional(CONF_API_KEY, default=""): str, } @@ -145,7 +164,11 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match(user_input) + # Deliberately no _async_abort_entries_match() here: upstream + # aborts when url+key match an existing entry, which blocks + # legitimate setups such as one entry per provider-side key, or a + # second entry against the same server kept for a different + # purpose. Duplicate entries are the user's call to make. try: await validate_input(self.hass, user_input) except CredentialsInBaseURL: @@ -164,35 +187,10 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort( self._get_reauth_entry(), data_updates=user_input ) + name = (user_input.pop(CONF_NAME, "") or "").strip() return self.async_create_entry( - title="ChatGPT", + title=name or title_from_base_url(user_input[CONF_BASE_URL]), data=user_input, - subentries=[ - { - "subentry_type": "conversation", - "data": RECOMMENDED_CONVERSATION_OPTIONS, - "title": DEFAULT_CONVERSATION_NAME, - "unique_id": None, - }, - { - "subentry_type": "ai_task_data", - "data": RECOMMENDED_AI_TASK_OPTIONS, - "title": DEFAULT_AI_TASK_NAME, - "unique_id": None, - }, - { - "subentry_type": "stt", - "data": RECOMMENDED_STT_OPTIONS, - "title": DEFAULT_STT_NAME, - "unique_id": None, - }, - { - "subentry_type": "tts", - "data": RECOMMENDED_TTS_OPTIONS, - "title": DEFAULT_TTS_NAME, - "unique_id": None, - }, - ], ) return self.async_show_form( @@ -202,7 +200,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, description_placeholders={ - "instructions_url": "https://github.com/adamjs83/ha-openai-compatible#generate-an-api-key", + "instructions_url": "https://github.com/adamjs83/ha-openai-compatible#configuration", }, ) @@ -220,7 +218,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", data_schema=self.add_suggested_values_to_schema( - STEP_USER_DATA_SCHEMA, self._get_reauth_entry().data + STEP_REAUTH_DATA_SCHEMA, self._get_reauth_entry().data ), ) @@ -259,6 +257,14 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): self.options = RECOMMENDED_AI_TASK_OPTIONS.copy() else: self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + # Start a new endpoint with "recommended" unticked. Ticked (upstream's + # default) creates the subentry straight from this first step, which + # silently pins RECOMMENDED_CHAT_MODEL -- an OpenAI model name that a + # third-party provider will not serve -- and never shows the model + # field at all. Unticked, the next step asks which model to use. + # Reconfigure is untouched: it keeps whatever the subentry was saved + # with, via async_step_reconfigure. + self.options[CONF_RECOMMENDED] = False return await self.async_step_init() async def async_step_reconfigure( @@ -359,10 +365,14 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): errors: dict[str, str] = {} step_schema: VolDictType = { + # Upstream makes this a free-text box defaulting to an OpenAI + # model name. Offer what the provider actually serves instead. vol.Optional( CONF_CHAT_MODEL, default=RECOMMENDED_CHAT_MODEL, - ): str, + ): model_field( + await async_fetch_model_ids(self._get_entry().runtime_data) + ), vol.Optional( CONF_MAX_TOKENS, default=RECOMMENDED_MAX_TOKENS, @@ -406,7 +416,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): step_schema: VolDictType = {} - model = options[CONF_CHAT_MODEL] + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) if not model.startswith(tuple(UNSUPPORTED_CODE_INTERPRETER_MODELS)): step_schema.update( @@ -763,18 +773,12 @@ class OpenAISubentrySTTFlowHandler(ConfigSubentryFlow): ): TextSelector( TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT) ), + # Upstream hardcodes OpenAI's three transcribe models here; + # ask the provider instead. vol.Optional( CONF_CHAT_MODEL, default=RECOMMENDED_STT_MODEL - ): SelectSelector( - SelectSelectorConfig( - options=[ - "gpt-4o-transcribe", - "gpt-4o-mini-transcribe", - "whisper-1", - ], - mode=SelectSelectorMode.DROPDOWN, - custom_value=True, - ) + ): model_field( + await async_fetch_model_ids(self._get_entry().runtime_data) ), } ) @@ -847,6 +851,15 @@ class OpenAISubentryTTSFlowHandler(ConfigSubentryFlow): vol.Optional(CONF_PROMPT): TextSelector( TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT) ), + # Upstream offers no model field for TTS at all -- it silently + # uses RECOMMENDED_TTS_OPTIONS' gpt-4o-mini-tts, which a + # third-party endpoint is unlikely to serve. + vol.Optional( + CONF_CHAT_MODEL, + default=RECOMMENDED_TTS_OPTIONS[CONF_CHAT_MODEL], + ): model_field( + await async_fetch_model_ids(self._get_entry().runtime_data) + ), vol.Optional( CONF_TTS_SPEED, default=RECOMMENDED_TTS_SPEED ): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)), diff --git a/custom_components/openai_compatible/model_list.py b/custom_components/openai_compatible/model_list.py new file mode 100644 index 0000000..d8268b1 --- /dev/null +++ b/custom_components/openai_compatible/model_list.py @@ -0,0 +1,73 @@ +"""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 + +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 [] + + 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, + ) + ) diff --git a/custom_components/openai_compatible/strings.json b/custom_components/openai_compatible/strings.json index 56f17bb..29d0531 100644 --- a/custom_components/openai_compatible/strings.json +++ b/custom_components/openai_compatible/strings.json @@ -26,13 +26,15 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "base_url": "API base URL" + "base_url": "API base URL", + "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "api_key": "Your OpenAI API key.", - "base_url": "The base URL of the OpenAI-compatible API, including any version path such as /v1. The endpoint must implement the Responses API." + "api_key": "The API key for this endpoint. Leave blank if it does not require authentication.", + "base_url": "The base URL of the OpenAI-compatible API, including any version path such as /v1. The endpoint must implement the Responses API.", + "name": "A label for this provider, for example LiteLLM. Defaults to the endpoint's host." }, - "description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})." + "description": "Connect to an OpenAI-compatible API. One entry per server or provider; you add endpoints for individual models afterwards. See the [setup notes]({instructions_url}) for what the endpoint has to support." } } }, diff --git a/custom_components/openai_compatible/translations/en.json b/custom_components/openai_compatible/translations/en.json index 5b27b19..774e1db 100644 --- a/custom_components/openai_compatible/translations/en.json +++ b/custom_components/openai_compatible/translations/en.json @@ -18,7 +18,7 @@ "base_url": "API base URL" }, "data_description": { - "api_key": "Your OpenAI API key.", + "api_key": "The API key for this endpoint. Leave blank if it does not require authentication.", "base_url": "The base URL of the OpenAI-compatible API, including any version path such as /v1. The endpoint must implement the Responses API." }, "description": "Reauthentication required. Please enter your updated API key and confirm the API base URL." @@ -26,13 +26,15 @@ "user": { "data": { "api_key": "API key", - "base_url": "API base URL" + "base_url": "API base URL", + "name": "Name" }, "data_description": { - "api_key": "Your OpenAI API key.", - "base_url": "The base URL of the OpenAI-compatible API, including any version path such as /v1. The endpoint must implement the Responses API." + "api_key": "The API key for this endpoint. Leave blank if it does not require authentication.", + "base_url": "The base URL of the OpenAI-compatible API, including any version path such as /v1. The endpoint must implement the Responses API.", + "name": "A label for this provider, for example LiteLLM. Defaults to the endpoint's host." }, - "description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})." + "description": "Connect to an OpenAI-compatible API. One entry per server or provider; you add endpoints for individual models afterwards. See the [setup notes]({instructions_url}) for what the endpoint has to support." } } }, diff --git a/custom_components/openai_compatible/url_util.py b/custom_components/openai_compatible/url_util.py index 3b9b799..cbf0788 100644 --- a/custom_components/openai_compatible/url_util.py +++ b/custom_components/openai_compatible/url_util.py @@ -72,3 +72,19 @@ def sanitize_url_for_logging(url: str) -> str: if parsed.port: host = f"{host}:{parsed.port}" return urlunparse(parsed._replace(netloc=f"***@{host}")) + + +def title_from_base_url(url: str) -> str: + """Return a config entry title identifying the provider. + + Upstream titles every entry "ChatGPT", which is wrong the moment the + endpoint is not OpenAI's and useless when there is one entry per provider. + The host (with port, when given) is what actually distinguishes them. + Falls back to the whole string if it cannot be parsed, so this never + raises in the middle of creating an entry. + """ + try: + netloc = urlparse(url).netloc + except ValueError: + return url + return netloc or url diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 37cc3ee..17c37bf 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -95,42 +95,30 @@ async def test_form(hass: HomeAssistant) -> None: "base_url": DEFAULT_BASE_URL, } assert result2["options"] == {} - assert result2["subentries"] == [ - { - "subentry_type": "conversation", - "data": RECOMMENDED_CONVERSATION_OPTIONS, - "title": DEFAULT_CONVERSATION_NAME, - "unique_id": None, - }, - { - "subentry_type": "ai_task_data", - "data": RECOMMENDED_AI_TASK_OPTIONS, - "title": DEFAULT_AI_TASK_NAME, - "unique_id": None, - }, - { - "subentry_type": "stt", - "data": RECOMMENDED_STT_OPTIONS, - "title": DEFAULT_STT_NAME, - "unique_id": None, - }, - { - "subentry_type": "tts", - "data": RECOMMENDED_TTS_OPTIONS, - "title": DEFAULT_TTS_NAME, - "unique_id": None, - }, - ] + # Setup creates the provider only. Entities are added afterwards, one per + # type and model, so nothing here is guessed on the user's behalf. + assert list(result2["subentries"]) == [] + assert result2["title"] == "api.openai.com" assert result2["version"] == 2 assert result2["minor_version"] == 8 assert len(mock_setup_entry.mock_calls) == 1 -async def test_duplicate_entry(hass: HomeAssistant) -> None: - """Test we abort on duplicate config entry.""" +async def test_duplicate_entry_is_allowed(hass: HomeAssistant) -> None: + """Several entries may point at one provider. + + Upstream aborts with already_configured when url+key match an existing + entry. This fork drops that check -- see async_step_user -- because one + entry per provider-side key, or a second entry against the same server, + are both legitimate here. + """ + # Pinned to the current version: the v1 -> v2 migration collapses multiple + # entries, which would remove one and mask what this test is checking. MockConfigEntry( domain=DOMAIN, data={CONF_API_KEY: "bla", CONF_BASE_URL: DEFAULT_BASE_URL}, + version=2, + minor_version=8, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -139,9 +127,15 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert not result["errors"] - with patch( - "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", - new_callable=AsyncMock, + with ( + patch( + "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ), + patch( + "custom_components.openai_compatible.async_setup_entry", + return_value=True, + ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -149,9 +143,10 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: CONF_API_KEY: "bla", }, ) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 async def test_creating_conversation_subentry( diff --git a/tests/test_model_list.py b/tests/test_model_list.py new file mode 100644 index 0000000..8ba8c37 --- /dev/null +++ b/tests/test_model_list.py @@ -0,0 +1,87 @@ +"""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_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 diff --git a/tests/test_model_picker.py b/tests/test_model_picker.py new file mode 100644 index 0000000..0d92989 --- /dev/null +++ b/tests/test_model_picker.py @@ -0,0 +1,152 @@ +"""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. + + stt and tts are single-step flows that ask on init; conversation and + ai_task_data ask on the step after it. + """ + 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" + + if subentry_type in ("stt", "tts"): + return result + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "An endpoint", CONF_RECOMMENDED: False}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "additional" + 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}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Sonnet via LiteLLM", CONF_RECOMMENDED: False}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_CHAT_MODEL: "claude-sonnet-4-6"} + ) + # remaining steps take their defaults + while result["type"] is FlowResultType.FORM: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {} + ) + 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" diff --git a/tests/test_provider_setup.py b/tests/test_provider_setup.py new file mode 100644 index 0000000..316c2b2 --- /dev/null +++ b/tests/test_provider_setup.py @@ -0,0 +1,117 @@ +"""Setup creates a provider, not a pile of guessed endpoints. + +One config entry per server/provider (LiteLLM, OpenAI, anything else): the +flow asks for a URL and a key and stops there. Entities are added afterwards, +one per type and model, by the subentry flows. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from homeassistant.const import CONF_NAME + +from custom_components.openai_compatible.const import CONF_BASE_URL, DOMAIN + +LITELLM = "http://192.0.2.10:4000/v1" + + +async def _run_user_step( + hass: HomeAssistant, base_url: str, api_key: str, name: str | None = None +) -> dict: + """Drive the user step to completion and return the flow result.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "custom_components.openai_compatible.config_flow.openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ), + patch( + "custom_components.openai_compatible.async_setup_entry", + return_value=True, + ), + ): + user_input = {CONF_BASE_URL: base_url, "api_key": api_key} + if name is not None: + user_input[CONF_NAME] = name + done = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + return done + + +async def test_setup_creates_no_subentries(hass: HomeAssistant) -> None: + """Auto-created stt/tts entities pointed at OpenAI-only model names were + dead on arrival against a third-party endpoint.""" + done = await _run_user_step(hass, LITELLM, "sk-test") + + assert done["type"] is FlowResultType.CREATE_ENTRY + assert list(done["subentries"]) == [] + + +async def test_entry_is_titled_after_the_endpoint(hass: HomeAssistant) -> None: + """The title said "ChatGPT" regardless of which provider it pointed at.""" + done = await _run_user_step(hass, LITELLM, "sk-test") + + assert done["title"] == "192.0.2.10:4000" + + +async def test_a_second_entry_for_the_same_provider_is_allowed( + hass: HomeAssistant, +) -> None: + """Upstream aborts as already_configured on matching url+key; several + entries against one server have to be possible here.""" + first = await _run_user_step(hass, LITELLM, "sk-test") + assert first["type"] is FlowResultType.CREATE_ENTRY + + second = await _run_user_step(hass, LITELLM, "sk-test") + assert second["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + +@pytest.mark.parametrize( + ("base_url", "expected"), + [ + ("http://192.0.2.10:4000/v1", "192.0.2.10:4000"), + ("https://api.openai.com/v1", "api.openai.com"), + ("https://openrouter.ai/api/v1", "openrouter.ai"), + ], +) +async def test_title_from_base_url(base_url: str, expected: str) -> None: + """The host distinguishes one provider entry from another.""" + from custom_components.openai_compatible.url_util import title_from_base_url + + assert title_from_base_url(base_url) == expected + + +async def test_name_becomes_the_entry_title(hass: HomeAssistant) -> None: + """With one entry per provider, the user names the provider.""" + done = await _run_user_step(hass, LITELLM, "sk-test", name="LiteLLM") + + assert done["title"] == "LiteLLM" + + +async def test_name_is_not_stored_as_connection_data(hass: HomeAssistant) -> None: + """The name is the entry title; it is not part of how we reach the API.""" + done = await _run_user_step(hass, LITELLM, "sk-test", name="LiteLLM") + + assert done["data"] == {CONF_BASE_URL: LITELLM, "api_key": "sk-test"} + assert CONF_NAME not in done["data"] + + +async def test_blank_name_falls_back_to_the_endpoint_host( + hass: HomeAssistant, +) -> None: + """Leaving it empty should not produce an entry titled "".""" + done = await _run_user_step(hass, LITELLM, "sk-test", name=" ") + + assert done["title"] == "192.0.2.10:4000"