Skip to content

Commit 112b10b

Browse files
fix(embedded): support Probatio schema conversion (#2286)
* fix(embedded): support Probatio schema conversion * test(embedded): cover nested converter import failures
1 parent cd85644 commit 112b10b

3 files changed

Lines changed: 128 additions & 16 deletions

File tree

custom_components/ha_mcp_tools/llm_api.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@
4949
import asyncio
5050
import importlib
5151
import logging
52-
from collections.abc import AsyncIterator, Iterable
52+
from collections.abc import AsyncIterator, Callable, Iterable
5353
from contextlib import asynccontextmanager
5454
from dataclasses import dataclass
55+
from functools import cache
5556
from typing import TYPE_CHECKING, Any, cast
5657

5758
import voluptuous as vol
5859
from homeassistant.core import HomeAssistant
5960
from homeassistant.exceptions import HomeAssistantError
6061
from homeassistant.helpers import llm
6162
from homeassistant.helpers.httpx_client import get_async_client
62-
from voluptuous_openapi import convert_to_voluptuous
6363

6464
from .const import (
6565
DATA_LLM_API_UNSUB,
@@ -128,6 +128,25 @@
128128
_CALL_TOOL_NAME = "ha_call_tool"
129129
_SEARCH_RESULT_LIMIT = 8
130130

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+
131150
# Used when the server's initialize result carries no instructions (it always
132151
# should — ha-mcp ships server-level instructions — but never render an empty
133152
# prompt if a build does not).
@@ -199,24 +218,25 @@ def _is_transport_failure(err: BaseException) -> bool:
199218

200219

201220
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).
203222
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.
207226
"""
208227
importlib.import_module("mcp.client.session")
209228
importlib.import_module("mcp.client.streamable_http")
229+
_schema_converter()
210230

211231

212232
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)."""
214234
try:
215235
await hass.async_add_executor_job(_import_mcp_sdk)
216236
except ImportError as err:
217237
_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",
220240
err,
221241
)
222242
return False
@@ -538,9 +558,7 @@ async def async_get_api_instance(
538558
def _convert_parameters(self, tool: Any) -> vol.Schema | None:
539559
"""Convert one tool's JSON schema, or None (logged) when it fails."""
540560
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)
544562
except Exception:
545563
# One unconvertible schema must not take down the whole
546564
# toolset for the conversation — skip that tool, loudly.

tests/src/unit/_embedded_stubs.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,13 +613,18 @@ def setmod(name: str, **attrs: Any) -> ModuleType:
613613
async_register_api=_llm_async_register_api,
614614
)
615615
_pin_llm_on_helpers()
616-
# voluptuous_openapi (an HA-core runtime dependency, not installed in this
617-
# test environment). Pass-through conversion: the unit tests assert wiring,
618-
# not the real JSON-schema -> voluptuous translation.
616+
# HA Core's schema conversion dependency differs across supported releases:
617+
# stable uses voluptuous_openapi while 2026.9+ provides Probatio. Pass-through
618+
# conversion keeps these unit tests focused on wiring; compatibility selection
619+
# itself is covered in test_llm_api.
619620
setmod(
620621
"voluptuous_openapi",
621622
convert_to_voluptuous=lambda schema: {"_converted": schema},
622623
)
624+
setmod(
625+
"probatio",
626+
from_openapi=lambda schema: {"_converted": schema},
627+
)
623628
# aiohttp_client + event helpers for the periodic auto-update check
624629
# (embedded_setup fetches PyPI; embedded_entry registers the interval).
625630
setmod(

tests/src/unit/test_llm_api.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import asyncio
1616
import logging
17+
import threading
1718
from contextlib import asynccontextmanager
1819
from types import SimpleNamespace
1920
from typing import Any
@@ -260,7 +261,95 @@ def _boom() -> None:
260261

261262
assert fake_llm_apis(hass) == {}
262263
assert DATA_LLM_API_UNSUB not in hass.data.get(DOMAIN, {})
263-
assert "no importable 'mcp' client SDK" in caplog.text
264+
assert "required LLM dependency is not importable" in caplog.text
265+
266+
267+
class TestSchemaConversionCompatibility:
268+
@pytest.fixture(autouse=True)
269+
def _clear_schema_converter_cache(self):
270+
llm_api._schema_converter.cache_clear()
271+
yield
272+
llm_api._schema_converter.cache_clear()
273+
274+
def test_prefers_stable_core_converter_when_available(self, monkeypatch):
275+
schema = {"type": "object"}
276+
legacy = SimpleNamespace(
277+
convert_to_voluptuous=lambda value: {"voluptuous_openapi": value}
278+
)
279+
280+
def _import_module(name):
281+
assert name == "voluptuous_openapi"
282+
return legacy
283+
284+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
285+
286+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
287+
288+
def test_falls_back_to_probatio_on_newer_core(self, monkeypatch):
289+
schema = {"type": "object"}
290+
probatio = SimpleNamespace(from_openapi=lambda value: {"probatio": value})
291+
292+
def _import_module(name):
293+
if name == "voluptuous_openapi":
294+
raise ModuleNotFoundError(
295+
"No module named 'voluptuous_openapi'",
296+
name="voluptuous_openapi",
297+
)
298+
assert name == "probatio"
299+
return probatio
300+
301+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
302+
303+
assert llm_api.convert_to_voluptuous(schema) == {"probatio": schema}
304+
305+
def test_reraises_nested_module_not_found(self, monkeypatch):
306+
def _import_module(name):
307+
assert name == "voluptuous_openapi"
308+
raise ModuleNotFoundError(
309+
"No module named 'legacy_dependency'",
310+
name="legacy_dependency",
311+
)
312+
313+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
314+
315+
with pytest.raises(ModuleNotFoundError, match="legacy_dependency"):
316+
llm_api.convert_to_voluptuous({"type": "object"})
317+
318+
async def test_converter_import_is_warmed_once_off_event_loop(self, monkeypatch):
319+
main_thread = threading.get_ident()
320+
imports: list[tuple[str, int]] = []
321+
legacy = SimpleNamespace(
322+
convert_to_voluptuous=lambda value: {"voluptuous_openapi": value}
323+
)
324+
325+
def _import_module(name):
326+
imports.append((name, threading.get_ident()))
327+
if name.startswith("mcp."):
328+
return SimpleNamespace()
329+
if name == "voluptuous_openapi":
330+
return legacy
331+
if name == "probatio":
332+
raise ModuleNotFoundError("No module named 'probatio'", name="probatio")
333+
raise AssertionError(name)
334+
335+
async def _executor(func, *args):
336+
return await asyncio.to_thread(func, *args)
337+
338+
hass = _make_hass()
339+
hass.async_add_executor_job = AsyncMock(side_effect=_executor)
340+
monkeypatch.setattr(llm_api.importlib, "import_module", _import_module)
341+
342+
assert await llm_api.async_probe_mcp_sdk(hass)
343+
schema = {"type": "object"}
344+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
345+
assert llm_api.convert_to_voluptuous(schema) == {"voluptuous_openapi": schema}
346+
347+
assert [name for name, _ in imports] == [
348+
"mcp.client.session",
349+
"mcp.client.streamable_http",
350+
"voluptuous_openapi",
351+
]
352+
assert all(thread_id != main_thread for _, thread_id in imports)
264353

265354

266355
class TestFullModeInstance:

0 commit comments

Comments
 (0)