mirror of
https://github.com/home-assistant/core.git
synced 2026-07-14 10:03:52 +01:00
193 lines
6.5 KiB
Python
193 lines
6.5 KiB
Python
"""API client helper for llama.cpp integration.
|
|
|
|
This module contains thin wrappers around the OpenAI completions APIs used
|
|
to simplify Home Assistant integration and configuration. It handles client
|
|
setup, model validation, and API error handling.
|
|
"""
|
|
|
|
from collections.abc import Generator, Mapping
|
|
from contextlib import contextmanager
|
|
import logging
|
|
from typing import Any, cast
|
|
|
|
import openai
|
|
from openai._streaming import AsyncStream
|
|
from openai.types.chat import (
|
|
ChatCompletionChunk,
|
|
ChatCompletionMessageParam,
|
|
ChatCompletionToolParam,
|
|
)
|
|
|
|
from homeassistant.const import CONF_API_KEY
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers.httpx_client import get_async_client
|
|
|
|
from .const import (
|
|
CONF_BASE_URL,
|
|
DEFAULT_API_KEY,
|
|
DEFAULT_MODEL,
|
|
DOMAIN,
|
|
RECOMMENDED_CHAT_MODELS,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
# Simple prompt to test model basic chat completion capability. We send tools
|
|
# to ensure the model and server correctly supports tool calling. We set a
|
|
# minimal max_tokens to consume few resources.
|
|
_TEST_MESSAGES: list[ChatCompletionMessageParam] = [
|
|
{"role": "system", "content": "You are a helpful assistant."},
|
|
{"role": "user", "content": "What is the capital of France?"},
|
|
]
|
|
_TEST_TOOLS: list[ChatCompletionToolParam] = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "test_function",
|
|
"description": "Test function.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
]
|
|
_TEST_MAX_TOKENS = 3
|
|
|
|
|
|
async def async_create_client(
|
|
hass: HomeAssistant, config_entry_data: Mapping[str, Any]
|
|
) -> openai.AsyncOpenAI:
|
|
"""Create a new OpenAI client."""
|
|
api_key = config_entry_data.get(CONF_API_KEY) or DEFAULT_API_KEY
|
|
client = openai.AsyncOpenAI(
|
|
api_key=api_key,
|
|
base_url=config_entry_data[CONF_BASE_URL],
|
|
http_client=get_async_client(hass),
|
|
)
|
|
# Cache current platform data which gets added to each request
|
|
# (caching done by library)
|
|
_ = await hass.async_add_executor_job(client.platform_headers)
|
|
return client
|
|
|
|
|
|
async def async_list_models(client: openai.AsyncOpenAI) -> list[str]:
|
|
"""Return a list of models supported by the client."""
|
|
with api_error_handler():
|
|
page = await client.with_options(timeout=10.0).models.list()
|
|
return [model.id async for model in page]
|
|
|
|
|
|
async def async_validate_completions(
|
|
client: openai.AsyncOpenAI,
|
|
model: str,
|
|
stream: bool = False,
|
|
) -> None:
|
|
"""Validate that we can speak to the model over the completions API."""
|
|
with api_error_handler():
|
|
result = await client.chat.completions.create(
|
|
model=model,
|
|
messages=_TEST_MESSAGES,
|
|
tools=_TEST_TOOLS,
|
|
max_tokens=_TEST_MAX_TOKENS,
|
|
stream=stream,
|
|
)
|
|
|
|
if stream:
|
|
stream_result = cast(AsyncStream[ChatCompletionChunk], result)
|
|
async for event in stream_result:
|
|
if not event.choices:
|
|
continue
|
|
if event.choices[0].finish_reason is not None:
|
|
continue
|
|
|
|
|
|
def recommended_model(models: list[str] | None) -> str:
|
|
"""Return the selected model from user input."""
|
|
if not models:
|
|
return DEFAULT_MODEL
|
|
for model in RECOMMENDED_CHAT_MODELS:
|
|
if model in models:
|
|
return model
|
|
return models[0]
|
|
|
|
|
|
def model_name_to_title(model_id: str) -> str:
|
|
"""Convert a model ID into a human-readable title (inverse slugification).
|
|
|
|
Examples:
|
|
- "deepseek-v4-flash" -> "Deepseek V4 Flash"
|
|
- "gpt-4" -> "Gpt 4"
|
|
- "llama-3.2-3b-instruct" -> "Llama 3.2 3b Instruct"
|
|
- "anthropic/claude-fable-5" -> "Anthropic Claude Fable 5"
|
|
"""
|
|
words = model_id.replace("-", " ").replace("_", " ").replace("/", " ").split()
|
|
return " ".join(word.capitalize() for word in words)
|
|
|
|
|
|
def _extract_error_message(err: openai.APIStatusError) -> str:
|
|
"""Extract a clean error message from an APIStatusError response or message."""
|
|
error_message = ""
|
|
if err.response is not None:
|
|
try:
|
|
json_data = err.response.json()
|
|
if isinstance(json_data, dict) and "error" in json_data:
|
|
error_message = json_data["error"].get("message") or ""
|
|
except ValueError:
|
|
pass
|
|
return error_message or err.message or str(err)
|
|
|
|
|
|
@contextmanager
|
|
def api_error_handler() -> Generator[None]:
|
|
"""Context manager to handle API errors and translate them to HomeAssistantErrors."""
|
|
try:
|
|
yield
|
|
except openai.APITimeoutError as err:
|
|
_LOGGER.error("Timeout talking to API: %s", err)
|
|
error_message = err.message or str(err)
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="timeout",
|
|
translation_placeholders={"message": error_message},
|
|
) from err
|
|
except openai.APIConnectionError as err:
|
|
_LOGGER.error("Connection error talking to API: %s", err)
|
|
error_message = err.message or str(err)
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="cannot_connect",
|
|
translation_placeholders={"message": error_message},
|
|
) from err
|
|
except openai.AuthenticationError as err:
|
|
_LOGGER.error("Authentication error talking to API: %s", err)
|
|
error_message = _extract_error_message(err)
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="invalid_auth",
|
|
translation_placeholders={"message": error_message},
|
|
) from err
|
|
except openai.APIStatusError as err:
|
|
_LOGGER.error("Status error talking to API: %s", err)
|
|
error_message = _extract_error_message(err)
|
|
|
|
if err.status_code == 402:
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="quota_exceeded",
|
|
translation_placeholders={"message": error_message},
|
|
) from err
|
|
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="api_error",
|
|
translation_placeholders={"message": error_message},
|
|
) from err
|
|
except openai.OpenAIError as err:
|
|
_LOGGER.error("Generic error talking to API: %s", err)
|
|
error_message = getattr(err, "message", None) or str(err)
|
|
raise HomeAssistantError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="api_error",
|
|
translation_placeholders={"message": error_message},
|
|
) from err
|