From bb113f832c107059274219c1cf9d4dba8f82c577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=98verli?= Date: Tue, 4 Aug 2026 14:41:06 +0200 Subject: [PATCH] Add websocket command to list LLM APIs (#177903) --- .strict-typing | 1 + homeassistant/components/llm/__init__.py | 2 + homeassistant/components/llm/websocket_api.py | 34 +++++++++ mypy.ini | 10 +++ tests/components/llm/test_websocket_api.py | 74 +++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 homeassistant/components/llm/websocket_api.py create mode 100644 tests/components/llm/test_websocket_api.py diff --git a/.strict-typing b/.strict-typing index 5295d7c9363c..4a7e5c1e8dee 100644 --- a/.strict-typing +++ b/.strict-typing @@ -354,6 +354,7 @@ homeassistant.components.litejet.* homeassistant.components.litellm.* homeassistant.components.litterrobot.* homeassistant.components.llama_cpp.* +homeassistant.components.llm.* homeassistant.components.local_ip.* homeassistant.components.local_todo.* homeassistant.components.lock.* diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py index 7b907762f89c..1f91b9960a11 100644 --- a/homeassistant/components/llm/__init__.py +++ b/homeassistant/components/llm/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import DOMAIN +from .websocket_api import async_setup as async_setup_ws_api _LOGGER = logging.getLogger(__name__) @@ -57,6 +58,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, _process_llm_tools_platform ) async_register_api(hass, AssistAPI(hass)) + async_setup_ws_api(hass) return True diff --git a/homeassistant/components/llm/websocket_api.py b/homeassistant/components/llm/websocket_api.py new file mode 100644 index 000000000000..889c93df71a4 --- /dev/null +++ b/homeassistant/components/llm/websocket_api.py @@ -0,0 +1,34 @@ +"""Websocket API for the LLM integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.llm import async_get_apis + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the LLM websocket API.""" + websocket_api.async_register_command(hass, websocket_list_apis) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "llm/api/list"}) +@callback +def websocket_list_apis( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List the registered LLM APIs. + + Each API is described by the ID used to select it and the name shown to + the user. APIs are listed in registration order. + """ + connection.send_result( + msg["id"], + {"apis": [{"id": api.id, "name": api.name} for api in async_get_apis(hass)]}, + ) diff --git a/mypy.ini b/mypy.ini index 02c15d95cbf4..0e446c8b3f26 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3297,6 +3297,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.llm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.local_ip.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/llm/test_websocket_api.py b/tests/components/llm/test_websocket_api.py new file mode 100644 index 000000000000..0575cfd2fa36 --- /dev/null +++ b/tests/components/llm/test_websocket_api.py @@ -0,0 +1,74 @@ +"""Tests for the LLM integration websocket API.""" + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +class _StubAPI(llm.API): + """Minimal LLM API used to populate the registry.""" + + async def async_get_api_instance( + self, llm_context: llm.LLMContext + ) -> llm.APIInstance: + """Return the instance of the API.""" + return llm.APIInstance( + api=self, api_prompt="", llm_context=llm_context, tools=[] + ) + + +@pytest.fixture(autouse=True) +async def setup_llm(hass: HomeAssistant) -> None: + """Set up the LLM integration.""" + assert await async_setup_component(hass, "llm", {}) + + +@pytest.mark.parametrize( + ("registered_apis", "expected_apis"), + [ + pytest.param([], [{"id": "assist", "name": "Assist"}], id="assist_only"), + pytest.param( + [("test-api", "Test API")], + [ + {"id": "assist", "name": "Assist"}, + {"id": "test-api", "name": "Test API"}, + ], + id="registered_api", + ), + ], +) +async def test_list_apis( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + registered_apis: list[tuple[str, str]], + expected_apis: list[dict[str, str]], +) -> None: + """Test listing the registered LLM APIs.""" + for api_id, name in registered_apis: + llm.async_register_api(hass, _StubAPI(hass=hass, id=api_id, name=name)) + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "llm/api/list"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"apis": expected_apis} + + +async def test_list_apis_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """Test listing the LLM APIs is only allowed for admins.""" + client = await hass_ws_client(hass, hass_read_only_access_token) + + await client.send_json_auto_id({"type": "llm/api/list"}) + response = await client.receive_json() + + assert not response["success"] + assert response["error"]["code"] == "unauthorized"