|
49 | 49 | import asyncio |
50 | 50 | import importlib |
51 | 51 | import logging |
52 | | -from collections.abc import AsyncIterator, Iterable |
| 52 | +from collections.abc import AsyncIterator, Callable, Iterable |
53 | 53 | from contextlib import asynccontextmanager |
54 | 54 | from dataclasses import dataclass |
| 55 | +from functools import cache |
55 | 56 | from typing import TYPE_CHECKING, Any, cast |
56 | 57 |
|
57 | 58 | import voluptuous as vol |
58 | 59 | from homeassistant.core import HomeAssistant |
59 | 60 | from homeassistant.exceptions import HomeAssistantError |
60 | 61 | from homeassistant.helpers import llm |
61 | 62 | from homeassistant.helpers.httpx_client import get_async_client |
62 | | -from voluptuous_openapi import convert_to_voluptuous |
63 | 63 |
|
64 | 64 | from .const import ( |
65 | 65 | DATA_LLM_API_UNSUB, |
|
128 | 128 | _CALL_TOOL_NAME = "ha_call_tool" |
129 | 129 | _SEARCH_RESULT_LIMIT = 8 |
130 | 130 |
|
| 131 | + |
| 132 | +@cache |
| 133 | +def _schema_converter() -> Callable[[Any], Any]: |
| 134 | + """Resolve the Core-provided schema converter once, off the event loop.""" |
| 135 | + try: |
| 136 | + legacy = importlib.import_module("voluptuous_openapi") |
| 137 | + except ModuleNotFoundError as err: |
| 138 | + if err.name != "voluptuous_openapi": |
| 139 | + raise |
| 140 | + probatio = importlib.import_module("probatio") |
| 141 | + return cast(Callable[[Any], Any], probatio.from_openapi) |
| 142 | + return cast(Callable[[Any], Any], legacy.convert_to_voluptuous) |
| 143 | + |
| 144 | + |
| 145 | +def convert_to_voluptuous(schema: Any) -> vol.Schema: |
| 146 | + """Convert an OpenAPI schema on stable and Probatio-based HA Core.""" |
| 147 | + return cast(vol.Schema, _schema_converter()(schema)) |
| 148 | + |
| 149 | + |
131 | 150 | # Used when the server's initialize result carries no instructions (it always |
132 | 151 | # should — ha-mcp ships server-level instructions — but never render an empty |
133 | 152 | # prompt if a build does not). |
@@ -199,24 +218,25 @@ def _is_transport_failure(err: BaseException) -> bool: |
199 | 218 |
|
200 | 219 |
|
201 | 220 | def _import_mcp_sdk() -> None: |
202 | | - """Import the mcp client SDK modules (blocking; run on the executor). |
| 221 | + """Import lazy LLM dependencies (blocking; run on the executor). |
203 | 222 |
|
204 | | - Raises ImportError when the SDK is not importable — the caller decides |
205 | | - whether that skips registration (SDK missing entirely) or surfaces as a |
206 | | - conversation error. |
| 223 | + Raises ImportError when the MCP SDK or Core's schema converter is not |
| 224 | + importable — the caller decides whether that skips registration or |
| 225 | + surfaces as a conversation error. |
207 | 226 | """ |
208 | 227 | importlib.import_module("mcp.client.session") |
209 | 228 | importlib.import_module("mcp.client.streamable_http") |
| 229 | + _schema_converter() |
210 | 230 |
|
211 | 231 |
|
212 | 232 | async def async_probe_mcp_sdk(hass: HomeAssistant) -> bool: |
213 | | - """Return True when the mcp client SDK imports (first import off-loop).""" |
| 233 | + """Return True when lazy LLM dependencies import (first import off-loop).""" |
214 | 234 | try: |
215 | 235 | await hass.async_add_executor_job(_import_mcp_sdk) |
216 | 236 | except ImportError as err: |
217 | 237 | _LOGGER.warning( |
218 | | - "The installed server package provides no importable 'mcp' client " |
219 | | - "SDK (%s); the conversation-agent LLM API will not be available", |
| 238 | + "A required LLM dependency is not importable (%s); the " |
| 239 | + "conversation-agent LLM API will not be available", |
220 | 240 | err, |
221 | 241 | ) |
222 | 242 | return False |
@@ -538,9 +558,7 @@ async def async_get_api_instance( |
538 | 558 | def _convert_parameters(self, tool: Any) -> vol.Schema | None: |
539 | 559 | """Convert one tool's JSON schema, or None (logged) when it fails.""" |
540 | 560 | try: |
541 | | - # cast: voluptuous_openapi is an untyped (ignored) import, so the |
542 | | - # call returns Any; its documented return type is vol.Schema. |
543 | | - return cast(vol.Schema, convert_to_voluptuous(tool.inputSchema)) |
| 561 | + return convert_to_voluptuous(tool.inputSchema) |
544 | 562 | except Exception: |
545 | 563 | # One unconvertible schema must not take down the whole |
546 | 564 | # toolset for the conversation — skip that tool, loudly. |
|
0 commit comments