"""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 probatio import to_openapi 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 = to_openapi(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, )