mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Add LiteLLM integration (#172960)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d1b8881603
commit
40e243a176
@@ -350,6 +350,7 @@ homeassistant.components.lifx.*
|
||||
homeassistant.components.light.*
|
||||
homeassistant.components.linkplay.*
|
||||
homeassistant.components.litejet.*
|
||||
homeassistant.components.litellm.*
|
||||
homeassistant.components.litterrobot.*
|
||||
homeassistant.components.llama_cpp.*
|
||||
homeassistant.components.local_ip.*
|
||||
|
||||
Generated
+2
@@ -1034,6 +1034,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/linux_battery/ @fabaff
|
||||
/homeassistant/components/litejet/ @joncar
|
||||
/tests/components/litejet/ @joncar
|
||||
/homeassistant/components/litellm/ @luismalves
|
||||
/tests/components/litellm/ @luismalves
|
||||
/homeassistant/components/litterrobot/ @natekspencer @tkdrob
|
||||
/tests/components/litterrobot/ @natekspencer @tkdrob
|
||||
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""The LiteLLM integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.CONVERSATION]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool:
|
||||
"""Set up LiteLLM from a config entry."""
|
||||
coordinator = LiteLLMDataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: LiteLLMConfigEntry
|
||||
) -> None:
|
||||
"""Handle update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool:
|
||||
"""Unload LiteLLM."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Config flow for LiteLLM integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_USER,
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TemplateSelector,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
DOMAIN,
|
||||
PLACEHOLDER_API_KEY,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect to the proxy."""
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate the API key is invalid."""
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
"""Normalize the proxy URL, ensuring it ends with the OpenAI `/v1` path."""
|
||||
parsed = URL(url.strip())
|
||||
path = parsed.path.rstrip("/")
|
||||
if not path.endswith("/v1"):
|
||||
path = f"{path}/v1"
|
||||
return str(parsed.with_path(path))
|
||||
|
||||
|
||||
async def _get_models(hass: HomeAssistant, url: str, api_key: str | None) -> list[str]:
|
||||
"""Fetch the available model names from the LiteLLM proxy.
|
||||
|
||||
Uses the OpenAI-compatible `/v1/models` endpoint, which a LiteLLM proxy
|
||||
serves with the configured model names.
|
||||
"""
|
||||
client = AsyncOpenAI(
|
||||
base_url=url,
|
||||
api_key=api_key or PLACEHOLDER_API_KEY,
|
||||
http_client=get_async_client(hass),
|
||||
)
|
||||
try:
|
||||
return [
|
||||
model.id async for model in client.with_options(timeout=10.0).models.list()
|
||||
]
|
||||
except (AuthenticationError, PermissionDeniedError) as err:
|
||||
raise InvalidAuth from err
|
||||
except OpenAIError as err:
|
||||
raise CannotConnect from err
|
||||
|
||||
|
||||
class LiteLLMConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for LiteLLM."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
@override
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return {"conversation": ConversationFlowHandler}
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
url = _normalize_url(user_input[CONF_URL])
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
self._async_abort_entries_match({CONF_URL: url})
|
||||
try:
|
||||
await _get_models(self.hass, url, api_key)
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
data = {CONF_URL: url}
|
||||
if api_key:
|
||||
data[CONF_API_KEY] = api_key
|
||||
return self.async_create_entry(
|
||||
title=URL(url).host or url,
|
||||
data=data,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Optional(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class LiteLLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for LiteLLM."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
self.models: list[str] = []
|
||||
|
||||
async def _fetch_models(self) -> None:
|
||||
"""Fetch models from the LiteLLM proxy."""
|
||||
entry = self._get_entry()
|
||||
self.models = await _get_models(
|
||||
self.hass, entry.data[CONF_URL], entry.data.get(CONF_API_KEY)
|
||||
)
|
||||
|
||||
|
||||
class ConversationFlowHandler(LiteLLMSubentryFlowHandler):
|
||||
"""Handle conversation subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
super().__init__()
|
||||
self.options: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def _is_new(self) -> bool:
|
||||
"""Return if this is a new subentry."""
|
||||
return self.source == SOURCE_USER
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to create a conversation agent."""
|
||||
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
return await self.async_step_init(user_input)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of a conversation agent."""
|
||||
self.options = self._get_reconfigure_subentry().data.copy()
|
||||
return await self.async_step_init(user_input)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Manage conversation agent configuration."""
|
||||
if self._get_entry().state is not ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
if user_input is not None:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
if self._is_new:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_MODEL], data=user_input
|
||||
)
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
title=user_input[CONF_MODEL],
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._fetch_models()
|
||||
except InvalidAuth:
|
||||
return self.async_abort(reason="invalid_auth")
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
options = [SelectOptionDict(value=model, label=model) for model in self.models]
|
||||
|
||||
hass_apis: list[SelectOptionDict] = [
|
||||
SelectOptionDict(
|
||||
label=api.name,
|
||||
value=api.id,
|
||||
)
|
||||
for api in llm.async_get_apis(self.hass)
|
||||
]
|
||||
|
||||
if suggested_llm_apis := self.options.get(CONF_LLM_HASS_API):
|
||||
valid_api_ids = {api["value"] for api in hass_apis}
|
||||
self.options[CONF_LLM_HASS_API] = [
|
||||
api for api in suggested_llm_apis if api in valid_api_ids
|
||||
]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_MODEL, default=self.options.get(CONF_MODEL)
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options, mode=SelectSelectorMode.DROPDOWN, sort=True
|
||||
),
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": self.options.get(
|
||||
CONF_PROMPT,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT],
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
default=self.options.get(
|
||||
CONF_LLM_HASS_API,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API],
|
||||
),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Constants for the LiteLLM integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
DOMAIN = "litellm"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
# LiteLLM proxies may run without authentication. The OpenAI client requires a
|
||||
# non-empty API key, so we send a placeholder when the user did not provide one.
|
||||
PLACEHOLDER_API_KEY = "sk-no-key-required"
|
||||
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Conversation support for LiteLLM."""
|
||||
|
||||
from typing import Literal, override
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LiteLLMConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import LiteLLMEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: LiteLLMConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up conversation entities."""
|
||||
for subentry in config_entry.get_subentries_of_type("conversation"):
|
||||
async_add_entities(
|
||||
[LiteLLMConversationEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class LiteLLMConversationEntity(LiteLLMEntity, conversation.ConversationEntity):
|
||||
"""LiteLLM conversation agent."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def supported_languages(self) -> list[str] | Literal["*"]:
|
||||
"""Return a list of supported languages."""
|
||||
return MATCH_ALL
|
||||
|
||||
@override
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> conversation.ConversationResult:
|
||||
"""Process the user input and call the API."""
|
||||
options = self.subentry.data
|
||||
|
||||
try:
|
||||
await chat_log.async_provide_llm_data(
|
||||
user_input.as_llm_context(DOMAIN),
|
||||
options.get(CONF_LLM_HASS_API),
|
||||
options.get(CONF_PROMPT),
|
||||
user_input.extra_system_prompt,
|
||||
)
|
||||
except conversation.ConverseError as err:
|
||||
return err.as_conversation_result()
|
||||
|
||||
await self._async_handle_chat_log(chat_log)
|
||||
|
||||
return conversation.async_get_result_from_chat_log(user_input, chat_log)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Coordinator for the LiteLLM integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import override
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER, PLACEHOLDER_API_KEY
|
||||
|
||||
# Ping the proxy hourly while it is reachable, and back off to once a minute
|
||||
# while it is down so entities recover quickly once it returns.
|
||||
UPDATE_INTERVAL_CONNECTED = timedelta(hours=1)
|
||||
UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
|
||||
|
||||
type LiteLLMConfigEntry = ConfigEntry[LiteLLMDataUpdateCoordinator]
|
||||
|
||||
|
||||
class LiteLLMDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Own the OpenAI client and track LiteLLM proxy availability."""
|
||||
|
||||
config_entry: LiteLLMConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: LiteLLMConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=config_entry.title,
|
||||
update_interval=UPDATE_INTERVAL_CONNECTED,
|
||||
always_update=False,
|
||||
)
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=config_entry.data[CONF_URL],
|
||||
api_key=config_entry.data.get(CONF_API_KEY) or PLACEHOLDER_API_KEY,
|
||||
http_client=get_async_client(hass),
|
||||
)
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Ping the proxy to confirm it is reachable and authenticated."""
|
||||
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
|
||||
try:
|
||||
async for _ in self.client.with_options(timeout=10.0).models.list():
|
||||
break
|
||||
except (AuthenticationError, PermissionDeniedError) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except OpenAIError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
self.update_interval = UPDATE_INTERVAL_CONNECTED
|
||||
|
||||
@callback
|
||||
@override
|
||||
def async_set_updated_data(self, data: None) -> None:
|
||||
"""Manually update data and reset to the connected interval."""
|
||||
self.update_interval = UPDATE_INTERVAL_CONNECTED
|
||||
super().async_set_updated_data(data)
|
||||
|
||||
@callback
|
||||
def mark_connection_error(self) -> None:
|
||||
"""Flag the proxy as unreachable and schedule a quick recheck."""
|
||||
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
|
||||
if self.last_update_success:
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
if self._listeners and not self.hass.is_stopping:
|
||||
self._schedule_refresh()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Base entity for LiteLLM."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
import openai
|
||||
from openai.types.chat import (
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionFunctionToolParam,
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionMessageFunctionToolCallParam,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
)
|
||||
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
|
||||
from openai.types.shared_params import FunctionDefinition
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator
|
||||
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool,
|
||||
custom_serializer: Callable[[Any], Any] | None,
|
||||
) -> ChatCompletionFunctionToolParam:
|
||||
"""Format tool specification."""
|
||||
unsupported_keys = {"oneOf", "anyOf", "allOf"}
|
||||
schema = convert(tool.parameters, custom_serializer=custom_serializer)
|
||||
schema = {k: v for k, v in schema.items() if k not in unsupported_keys}
|
||||
|
||||
tool_spec = FunctionDefinition(
|
||||
name=tool.name,
|
||||
parameters=schema,
|
||||
)
|
||||
if tool.description:
|
||||
tool_spec["description"] = tool.description
|
||||
return ChatCompletionFunctionToolParam(type="function", function=tool_spec)
|
||||
|
||||
|
||||
def _convert_content_to_chat_message(
|
||||
content: conversation.Content,
|
||||
) -> ChatCompletionMessageParam | None:
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
LOGGER.debug("_convert_content_to_chat_message=%s", content)
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
return ChatCompletionToolMessageParam(
|
||||
role="tool",
|
||||
tool_call_id=content.tool_call_id,
|
||||
content=json_dumps(content.tool_result),
|
||||
)
|
||||
|
||||
role: Literal["user", "assistant", "system"] = content.role
|
||||
if role == "system" and content.content:
|
||||
return ChatCompletionSystemMessageParam(role="system", content=content.content)
|
||||
|
||||
if role == "user" and content.content:
|
||||
return ChatCompletionUserMessageParam(role="user", content=content.content)
|
||||
|
||||
if role == "assistant":
|
||||
param = ChatCompletionAssistantMessageParam(
|
||||
role="assistant",
|
||||
content=content.content,
|
||||
)
|
||||
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
|
||||
param["tool_calls"] = [
|
||||
ChatCompletionMessageFunctionToolCallParam(
|
||||
type="function",
|
||||
id=tool_call.id,
|
||||
function=Function(
|
||||
arguments=json_dumps(tool_call.tool_args),
|
||||
name=tool_call.tool_name,
|
||||
),
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
return param
|
||||
LOGGER.warning("Could not convert message to Completions API: %s", content)
|
||||
return None
|
||||
|
||||
|
||||
def _decode_tool_arguments(arguments: str) -> Any:
|
||||
"""Decode tool call arguments."""
|
||||
try:
|
||||
return json.loads(arguments)
|
||||
except json.JSONDecodeError as err:
|
||||
raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err
|
||||
|
||||
|
||||
async def _transform_response(
|
||||
message: ChatCompletionMessage,
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the LiteLLM message to a ChatLog format."""
|
||||
data: conversation.AssistantContentDeltaDict = {
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
}
|
||||
if message.tool_calls:
|
||||
data["tool_calls"] = [
|
||||
llm.ToolInput(
|
||||
id=tool_call.id,
|
||||
tool_name=tool_call.function.name,
|
||||
tool_args=_decode_tool_arguments(tool_call.function.arguments),
|
||||
)
|
||||
for tool_call in message.tool_calls
|
||||
if tool_call.type == "function"
|
||||
]
|
||||
yield data
|
||||
|
||||
|
||||
class LiteLLMEntity(CoordinatorEntity[LiteLLMDataUpdateCoordinator]):
|
||||
"""Base entity for LiteLLM."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(entry.runtime_data)
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self.model = subentry.data[CONF_MODEL]
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
model_args = {
|
||||
"model": self.model,
|
||||
"user": chat_log.conversation_id,
|
||||
}
|
||||
|
||||
tools: list[ChatCompletionFunctionToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
model_args["messages"] = [
|
||||
m
|
||||
for content in chat_log.content
|
||||
if (m := _convert_content_to_chat_message(content))
|
||||
]
|
||||
|
||||
coordinator = self.entry.runtime_data
|
||||
client = coordinator.client
|
||||
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
try:
|
||||
result = await client.chat.completions.create(**model_args)
|
||||
except (openai.AuthenticationError, openai.PermissionDeniedError) as err:
|
||||
# Re-check so the proxy is marked unavailable for the auth failure.
|
||||
await coordinator.async_request_refresh()
|
||||
LOGGER.error("Error talking to API: %s", err)
|
||||
raise HomeAssistantError("Error talking to API") from err
|
||||
except openai.APIConnectionError as err:
|
||||
coordinator.mark_connection_error()
|
||||
LOGGER.error("Error talking to API: %s", err)
|
||||
raise HomeAssistantError("Error talking to API") from err
|
||||
except openai.OpenAIError as err:
|
||||
# Reachable but the request failed; keep the entity available.
|
||||
coordinator.async_set_updated_data(None)
|
||||
LOGGER.error("Error talking to API: %s", err)
|
||||
raise HomeAssistantError("Error talking to API") from err
|
||||
|
||||
if not result.choices:
|
||||
LOGGER.error("API returned empty choices")
|
||||
raise HomeAssistantError("API returned empty response")
|
||||
|
||||
result_message = result.choices[0].message
|
||||
|
||||
model_args["messages"].extend(
|
||||
[
|
||||
msg
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id, _transform_response(result_message)
|
||||
)
|
||||
if (msg := _convert_content_to_chat_message(content))
|
||||
]
|
||||
)
|
||||
if not chat_log.unresponded_tool_results:
|
||||
coordinator.async_set_updated_data(None)
|
||||
break
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Stopped after %s tool iterations with unresolved tool calls",
|
||||
MAX_TOOL_ITERATIONS,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "litellm",
|
||||
"name": "LiteLLM",
|
||||
"after_dependencies": ["assist_pipeline", "intent"],
|
||||
"codeowners": ["@luismalves"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["conversation"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/litellm",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["openai==2.45.0"]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No actions are implemented
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: >-
|
||||
the coordinator polls the proxy hourly for an availability check, backing
|
||||
off to once a minute while it is unreachable
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No actions are implemented
|
||||
docs-conditions:
|
||||
status: exempt
|
||||
comment: This integration does not have any conditions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
docs-triggers:
|
||||
status: exempt
|
||||
comment: This integration does not have any triggers.
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: the integration does not subscribe to events
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: the integration has no options
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: >-
|
||||
the conversation entity follows the coordinator and is marked unavailable
|
||||
when the proxy cannot be reached
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Service can't be discovered
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Service can't be discovered
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: devices are created via subentries, not discovered dynamically
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: the conversation entity does not use entity categories
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: no suitable device class for the conversation entity
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: only one conversation entity
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: the integration has no repairs
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: only one device per entry, is deleted with the entry.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"url": "[%key:common::config_flow::data::url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "An optional LiteLLM API key or virtual key. Leave empty if your proxy does not require authentication.",
|
||||
"url": "The base URL of your LiteLLM proxy, including the host and port"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"conversation": {
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure conversation agent",
|
||||
"user": "Add conversation agent"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"model": "[%key:common::generic::model%]",
|
||||
"prompt": "[%key:common::config_flow::data::prompt%]"
|
||||
},
|
||||
"data_description": {
|
||||
"llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
|
||||
"model": "The model to use for the conversation agent",
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
},
|
||||
"description": "Configure the conversation agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -429,6 +429,7 @@ FLOWS = {
|
||||
"lifx",
|
||||
"linkplay",
|
||||
"litejet",
|
||||
"litellm",
|
||||
"litterrobot",
|
||||
"livisi",
|
||||
"llama_cpp",
|
||||
|
||||
@@ -3911,6 +3911,12 @@
|
||||
"iot_class": "local_push",
|
||||
"single_config_entry": true
|
||||
},
|
||||
"litellm": {
|
||||
"name": "LiteLLM",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"litterrobot": {
|
||||
"name": "Whisker",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -3257,6 +3257,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.litellm.*]
|
||||
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.litterrobot.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Generated
+1
@@ -1770,6 +1770,7 @@ open-garage==0.2.0
|
||||
open-meteo==0.3.2
|
||||
|
||||
# homeassistant.components.cloud
|
||||
# homeassistant.components.litellm
|
||||
# homeassistant.components.llama_cpp
|
||||
# homeassistant.components.open_router
|
||||
# homeassistant.components.openai_conversation
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Tests for the LiteLLM integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Fixture for setting up the component."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def get_subentry_id(mock_config_entry: MockConfigEntry, subentry_type: str) -> str:
|
||||
"""Get the subentry ID for a given type."""
|
||||
ids = [
|
||||
subentry_id
|
||||
for subentry_id, subentry in mock_config_entry.subentries.items()
|
||||
if subentry.subentry_type == subentry_type
|
||||
]
|
||||
if not ids:
|
||||
raise ValueError(f"No subentry found for type {subentry_type}")
|
||||
return ids[0]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Fixtures for LiteLLM integration tests."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from openai.types import CompletionUsage, Model
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN
|
||||
from homeassistant.config_entries import ConfigSubentryData
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_URL = "http://localhost:4000/v1"
|
||||
|
||||
|
||||
async def models_response(*model_ids: str) -> AsyncGenerator[Model]:
|
||||
"""Yield models as the OpenAI client's `models.list()` would."""
|
||||
for model_id in model_ids:
|
||||
yield Model(id=model_id, created=0, object="model", owned_by="litellm")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.litellm.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_assist() -> bool:
|
||||
"""Return whether the Assist LLM API is enabled for the conversation agent."""
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]:
|
||||
"""Mock conversation subentry data."""
|
||||
res: dict[str, Any] = {
|
||||
CONF_MODEL: "gpt-3.5-turbo",
|
||||
CONF_PROMPT: "You are a helpful assistant.",
|
||||
}
|
||||
if enable_assist:
|
||||
res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST]
|
||||
return res
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(
|
||||
hass: HomeAssistant,
|
||||
conversation_subentry_data: dict[str, Any],
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry."""
|
||||
return MockConfigEntry(
|
||||
title="localhost:4000",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_API_KEY: "bla",
|
||||
},
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data=conversation_subentry_data,
|
||||
subentry_id="ABCDEF",
|
||||
subentry_type="conversation",
|
||||
title="gpt-3.5-turbo",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_openai_client() -> AsyncGenerator[AsyncMock]:
|
||||
"""Mock the OpenAI client used for chat completions."""
|
||||
with patch(
|
||||
"homeassistant.components.litellm.coordinator.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.chat.completions.create = AsyncMock(
|
||||
return_value=ChatCompletion(
|
||||
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content="Hello, how can I help you?",
|
||||
role="assistant",
|
||||
function_call=None,
|
||||
tool_calls=None,
|
||||
),
|
||||
)
|
||||
],
|
||||
created=1700000000,
|
||||
model="gpt-3.5-turbo",
|
||||
object="chat.completion",
|
||||
system_fingerprint=None,
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=9, prompt_tokens=8, total_tokens=17
|
||||
),
|
||||
)
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_models() -> Generator[AsyncMock]:
|
||||
"""Mock the OpenAI client the config flow uses to list proxy models."""
|
||||
with patch(
|
||||
"homeassistant.components.litellm.config_flow.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.with_options.return_value.models.list.side_effect = (
|
||||
lambda *args, **kwargs: models_response("gpt-3.5-turbo", "gpt-4")
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_ha(hass: HomeAssistant) -> None:
|
||||
"""Set up Home Assistant."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
@@ -0,0 +1,295 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'conversation',
|
||||
'entity_category': None,
|
||||
'entity_id': 'conversation.gpt_3_5_turbo',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
'conversation': dict({
|
||||
'should_expose': False,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'litellm',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <ConversationEntityFeature: 1>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ABCDEF',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'gpt-3.5-turbo',
|
||||
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <ConversationEntityFeature: 1>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'conversation.gpt_3_5_turbo',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'conversation',
|
||||
'entity_category': None,
|
||||
'entity_id': 'conversation.gpt_3_5_turbo',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
'conversation': dict({
|
||||
'should_expose': False,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'litellm',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ABCDEF',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'gpt-3.5-turbo',
|
||||
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <ConversationEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'conversation.gpt_3_5_turbo',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_default_prompt
|
||||
list([
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'hello',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.gpt_3_5_turbo',
|
||||
'content': 'Hello, how can I help you?',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_function_call[True]
|
||||
list([
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'What time is it?',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.gpt_3_5_turbo',
|
||||
'content': None,
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': list([
|
||||
dict({
|
||||
'external': True,
|
||||
'id': 'mock_tool_call_id',
|
||||
'tool_args': dict({
|
||||
}),
|
||||
'tool_name': 'HassGetCurrentTime',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.gpt_3_5_turbo',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'mock_tool_call_id',
|
||||
'tool_name': 'HassGetCurrentTime',
|
||||
'tool_result': dict({
|
||||
'data': dict({
|
||||
'failed': list([
|
||||
]),
|
||||
'success': list([
|
||||
]),
|
||||
}),
|
||||
'response_type': 'action_done',
|
||||
'speech': dict({
|
||||
'plain': dict({
|
||||
'extra_data': None,
|
||||
'speech': '12:00 PM',
|
||||
}),
|
||||
}),
|
||||
'speech_slots': dict({
|
||||
'time': datetime.time(12, 0),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.gpt_3_5_turbo',
|
||||
'content': '12:00 PM',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': None,
|
||||
}),
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'Please call the test function',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.gpt_3_5_turbo',
|
||||
'content': None,
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': list([
|
||||
dict({
|
||||
'external': False,
|
||||
'id': 'call_call_1',
|
||||
'tool_args': dict({
|
||||
'param1': 'call1',
|
||||
}),
|
||||
'tool_name': 'test_tool',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.gpt_3_5_turbo',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'call_call_1',
|
||||
'tool_name': 'test_tool',
|
||||
'tool_result': 'value1',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.gpt_3_5_turbo',
|
||||
'content': 'I have successfully called the function',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_function_call[True].1
|
||||
list([
|
||||
dict({
|
||||
'content': '''
|
||||
You are a helpful assistant.
|
||||
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
|
||||
''',
|
||||
'role': 'system',
|
||||
}),
|
||||
dict({
|
||||
'content': 'What time is it?',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': None,
|
||||
'role': 'assistant',
|
||||
'tool_calls': list([
|
||||
dict({
|
||||
'function': dict({
|
||||
'arguments': '{}',
|
||||
'name': 'HassGetCurrentTime',
|
||||
}),
|
||||
'id': 'mock_tool_call_id',
|
||||
'type': 'function',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
dict({
|
||||
'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}',
|
||||
'role': 'tool',
|
||||
'tool_call_id': 'mock_tool_call_id',
|
||||
}),
|
||||
dict({
|
||||
'content': '12:00 PM',
|
||||
'role': 'assistant',
|
||||
}),
|
||||
dict({
|
||||
'content': 'Please call the test function',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': None,
|
||||
'role': 'assistant',
|
||||
'tool_calls': list([
|
||||
dict({
|
||||
'function': dict({
|
||||
'arguments': '{"param1":"call1"}',
|
||||
'name': 'test_tool',
|
||||
}),
|
||||
'id': 'call_call_1',
|
||||
'type': 'function',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
dict({
|
||||
'content': '"value1"',
|
||||
'role': 'tool',
|
||||
'tool_call_id': 'call_call_1',
|
||||
}),
|
||||
dict({
|
||||
'content': 'I have successfully called the function',
|
||||
'role': 'assistant',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Test the LiteLLM config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from openai import (
|
||||
APIConnectionError,
|
||||
APITimeoutError,
|
||||
AuthenticationError,
|
||||
PermissionDeniedError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.litellm.config_flow import CannotConnect, InvalidAuth
|
||||
from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import get_subentry_id, setup_integration
|
||||
from .conftest import TEST_URL, models_response
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
CONVERSATION_MODEL_OPTIONS = [
|
||||
{"value": "gpt-3.5-turbo", "label": "gpt-3.5-turbo"},
|
||||
{"value": "gpt-4", "label": "gpt-4"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry", "mock_models")
|
||||
@pytest.mark.parametrize(
|
||||
"url_input",
|
||||
["http://localhost:4000", "http://localhost:4000/", TEST_URL, f"{TEST_URL}/"],
|
||||
)
|
||||
async def test_full_flow(hass: HomeAssistant, url_input: str) -> None:
|
||||
"""Test the full config flow normalizes the URL and stores the key."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert not result["errors"]
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_URL: url_input, CONF_API_KEY: "bla"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "localhost"
|
||||
assert result["data"] == {CONF_URL: TEST_URL, CONF_API_KEY: "bla"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry", "mock_models")
|
||||
async def test_full_flow_without_api_key(hass: HomeAssistant) -> None:
|
||||
"""Test the config flow works without an API key."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_URL: "http://localhost:4000"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {CONF_URL: TEST_URL}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(InvalidAuth, "invalid_auth"),
|
||||
(CannotConnect, "cannot_connect"),
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_form_errors(
|
||||
hass: HomeAssistant,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test we handle errors and can recover."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litellm.config_flow._get_models",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_models:
|
||||
mock_get_models.side_effect = exception
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
mock_get_models.side_effect = None
|
||||
mock_get_models.return_value = {"gpt-3.5-turbo": {}}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
def _status_error(
|
||||
error: type[AuthenticationError | PermissionDeniedError], status_code: int
|
||||
) -> AuthenticationError | PermissionDeniedError:
|
||||
"""Build an OpenAI status error backed by a real httpx response."""
|
||||
return error(
|
||||
response=httpx.Response(
|
||||
status_code=status_code, request=httpx.Request("GET", TEST_URL)
|
||||
),
|
||||
body=None,
|
||||
message="error",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error"),
|
||||
[
|
||||
(_status_error(AuthenticationError, 401), "invalid_auth"),
|
||||
(_status_error(PermissionDeniedError, 403), "invalid_auth"),
|
||||
(APIConnectionError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"),
|
||||
(APITimeoutError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_user_step_proxy_errors(
|
||||
hass: HomeAssistant,
|
||||
side_effect: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test the user step surfaces errors raised by the OpenAI client."""
|
||||
with patch(
|
||||
"homeassistant.components.litellm.config_flow.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
mock_client.return_value.with_options.return_value.models.list.side_effect = (
|
||||
side_effect
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_duplicate_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test aborting the flow if an entry with the same URL already exists."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "other"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_models")
|
||||
async def test_create_conversation_agent(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating a conversation agent."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
assert (
|
||||
result["data_schema"].schema["model"].config["options"]
|
||||
== CONVERSATION_MODEL_OPTIONS
|
||||
)
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_MODEL: "gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "gpt-3.5-turbo"
|
||||
assert result["data"] == {
|
||||
CONF_MODEL: "gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_models")
|
||||
async def test_create_conversation_agent_no_control(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating a conversation agent without control over the LLM API."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_MODEL: "gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_LLM_HASS_API: [],
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_MODEL: "gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
}
|
||||
|
||||
|
||||
async def test_conversation_agent_model_options(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the model dropdown is populated from the proxy's model list."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litellm.config_flow.AsyncOpenAI"
|
||||
) as mock_client:
|
||||
mock_client.return_value.with_options.return_value.models.list.side_effect = (
|
||||
lambda *args, **kwargs: models_response("gpt-4o", "gpt-5")
|
||||
)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["data_schema"].schema["model"].config["options"] == [
|
||||
{"value": "gpt-4o", "label": "gpt-4o"},
|
||||
{"value": "gpt-5", "label": "gpt-5"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "reason"),
|
||||
[
|
||||
(InvalidAuth, "invalid_auth"),
|
||||
(CannotConnect, "cannot_connect"),
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_subentry_exceptions(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
exception: Exception,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Test subentry flow aborts on errors fetching models."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litellm.config_flow._get_models",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=exception,
|
||||
):
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == reason
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_models")
|
||||
async def test_reconfigure_conversation_agent(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfiguring a conversation agent."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
subentry_id = get_subentry_id(mock_config_entry, "conversation")
|
||||
|
||||
result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_MODEL: "gpt-4",
|
||||
CONF_PROMPT: "updated prompt",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
subentry = mock_config_entry.subentries[subentry_id]
|
||||
assert subentry.title == "gpt-4"
|
||||
assert subentry.data[CONF_MODEL] == "gpt-4"
|
||||
assert subentry.data[CONF_PROMPT] == "updated prompt"
|
||||
assert subentry.data[CONF_LLM_HASS_API] == ["assist"]
|
||||
|
||||
|
||||
async def test_reconfigure_entry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfiguring aborts when the main entry is not loaded."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "entry_not_loaded"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_llm_apis", "suggested_llm_apis", "expected_options"),
|
||||
[
|
||||
(["assist"], ["assist"], ["assist"]),
|
||||
(["non-existent"], [], ["assist"]),
|
||||
(["assist", "non-existent"], ["assist"], ["assist"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_models")
|
||||
async def test_reconfigure_conversation_subentry_llm_api_schema(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
current_llm_apis: list[str],
|
||||
suggested_llm_apis: list[str],
|
||||
expected_options: list[str],
|
||||
) -> None:
|
||||
"""Test llm_hass_api field values when reconfiguring a conversation subentry."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
subentry_id = get_subentry_id(mock_config_entry, "conversation")
|
||||
subentry = mock_config_entry.subentries[subentry_id]
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
subentry,
|
||||
data={**subentry.data, CONF_LLM_HASS_API: current_llm_apis},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
schema = result["data_schema"].schema
|
||||
key = next(k for k in schema if k == CONF_LLM_HASS_API)
|
||||
assert key.default() == suggested_llm_apis
|
||||
|
||||
field_schema = schema[key]
|
||||
assert field_schema.config
|
||||
assert [
|
||||
opt["value"] for opt in field_schema.config.get("options")
|
||||
] == expected_options
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Tests for the LiteLLM conversation entity."""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
import httpx
|
||||
import openai
|
||||
from openai.types import CompletionUsage
|
||||
from openai.types.chat import (
|
||||
ChatCompletion,
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionMessageFunctionToolCall,
|
||||
)
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.const import STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er, intent
|
||||
from homeassistant.helpers.llm import ToolInput
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401
|
||||
|
||||
AGENT_ID = "conversation.gpt_3_5_turbo"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def freeze_the_time():
|
||||
"""Freeze the time."""
|
||||
with freeze_time("2024-05-24 12:00:00", tz_offset=0):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"])
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
with patch(
|
||||
"homeassistant.components.litellm.PLATFORMS",
|
||||
[Platform.CONVERSATION],
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_default_prompt(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
) -> None:
|
||||
"""Test that the default prompt works."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"hello",
|
||||
mock_chat_log.conversation_id,
|
||||
Context(),
|
||||
agent_id=AGENT_ID,
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert mock_chat_log.content[1:] == snapshot
|
||||
call = mock_openai_client.chat.completions.create.call_args_list[0][1]
|
||||
assert call["model"] == "gpt-3.5-turbo"
|
||||
assert "extra_headers" not in call
|
||||
|
||||
|
||||
async def test_empty_api_response(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
) -> None:
|
||||
"""Test that an empty choices response raises an error."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_openai_client.chat.completions.create = AsyncMock(
|
||||
return_value=ChatCompletion(
|
||||
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
|
||||
choices=[],
|
||||
created=1700000000,
|
||||
model="gpt-3.5-turbo",
|
||||
object="chat.completion",
|
||||
system_fingerprint=None,
|
||||
usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8),
|
||||
)
|
||||
)
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"hello",
|
||||
mock_chat_log.conversation_id,
|
||||
Context(),
|
||||
agent_id=AGENT_ID,
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
|
||||
|
||||
async def test_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
) -> None:
|
||||
"""Test that an error talking to the API is handled gracefully."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_openai_client.chat.completions.create = AsyncMock(
|
||||
side_effect=openai.OpenAIError("boom")
|
||||
)
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"hello",
|
||||
mock_chat_log.conversation_id,
|
||||
Context(),
|
||||
agent_id=AGENT_ID,
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
|
||||
|
||||
async def test_connection_error_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
) -> None:
|
||||
"""Test a connection error marks the entity unavailable until it recovers."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
mock_openai_client.chat.completions.create = AsyncMock(
|
||||
side_effect=openai.APIConnectionError(
|
||||
request=httpx.Request("POST", "http://localhost")
|
||||
)
|
||||
)
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"hello",
|
||||
mock_chat_log.conversation_id,
|
||||
Context(),
|
||||
agent_id=AGENT_ID,
|
||||
)
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(AGENT_ID).state == STATE_UNAVAILABLE
|
||||
|
||||
# A successful availability ping restores the entity.
|
||||
await mock_config_entry.runtime_data.async_request_refresh()
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_assist", [True])
|
||||
async def test_function_call(
|
||||
hass: HomeAssistant,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_openai_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test function call from the assistant."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_chat_log.async_add_user_content(
|
||||
conversation.UserContent(content="What time is it?")
|
||||
)
|
||||
mock_chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id=AGENT_ID,
|
||||
tool_calls=[
|
||||
ToolInput(
|
||||
tool_name="HassGetCurrentTime",
|
||||
tool_args={},
|
||||
id="mock_tool_call_id",
|
||||
external=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
mock_chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.ToolResultContent(
|
||||
agent_id=AGENT_ID,
|
||||
tool_call_id="mock_tool_call_id",
|
||||
tool_name="HassGetCurrentTime",
|
||||
tool_result={
|
||||
"speech": {"plain": {"speech": "12:00 PM", "extra_data": None}},
|
||||
"response_type": "action_done",
|
||||
"speech_slots": {"time": datetime.time(12, 0)},
|
||||
"data": {"success": [], "failed": []},
|
||||
},
|
||||
)
|
||||
)
|
||||
mock_chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id=AGENT_ID,
|
||||
content="12:00 PM",
|
||||
)
|
||||
)
|
||||
|
||||
mock_chat_log.mock_tool_results(
|
||||
{
|
||||
"call_call_1": "value1",
|
||||
"call_call_2": "value2",
|
||||
}
|
||||
)
|
||||
|
||||
mock_openai_client.chat.completions.create.side_effect = (
|
||||
ChatCompletion(
|
||||
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="tool_calls",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content=None,
|
||||
role="assistant",
|
||||
function_call=None,
|
||||
tool_calls=[
|
||||
ChatCompletionMessageFunctionToolCall(
|
||||
id="call_call_1",
|
||||
function=Function(
|
||||
arguments='{"param1":"call1"}',
|
||||
name="test_tool",
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
created=1700000000,
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
system_fingerprint=None,
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=9, prompt_tokens=8, total_tokens=17
|
||||
),
|
||||
),
|
||||
ChatCompletion(
|
||||
id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH",
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content="I have successfully called the function",
|
||||
role="assistant",
|
||||
function_call=None,
|
||||
tool_calls=None,
|
||||
),
|
||||
)
|
||||
],
|
||||
created=1700000000,
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
system_fingerprint=None,
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=9, prompt_tokens=8, total_tokens=17
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"Please call the test function",
|
||||
mock_chat_log.conversation_id,
|
||||
Context(),
|
||||
agent_id=AGENT_ID,
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
# Don't test the prompt, as it's not deterministic
|
||||
assert mock_chat_log.content[1:] == snapshot
|
||||
assert mock_openai_client.chat.completions.create.call_count == 2
|
||||
assert (
|
||||
mock_openai_client.chat.completions.create.call_args.kwargs["messages"]
|
||||
== snapshot
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the LiteLLM integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
from openai import APIConnectionError, AuthenticationError
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_load_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test loading and unloading the integration."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_state"),
|
||||
[
|
||||
(
|
||||
AuthenticationError(
|
||||
response=httpx.Response(
|
||||
status_code=401, request=httpx.Request("GET", "http://localhost")
|
||||
),
|
||||
body=None,
|
||||
message="invalid api key",
|
||||
),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
),
|
||||
(APIConnectionError(request=None), ConfigEntryState.SETUP_RETRY),
|
||||
],
|
||||
)
|
||||
async def test_setup_error(
|
||||
hass: HomeAssistant,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
expected_state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test that setup handles errors validating the connection."""
|
||||
mock_openai_client.with_options.return_value.models.list.side_effect = side_effect
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is expected_state
|
||||
Reference in New Issue
Block a user