1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-14 10:03:52 +01:00
Files
2026-07-04 08:20:10 +02:00

458 lines
16 KiB
Python

"""Base entity for llama.cpp Conversation."""
import base64
from collections.abc import AsyncGenerator, Callable
import json
import logging
import mimetypes
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from openai import AsyncOpenAI
from openai._streaming import AsyncStream
from openai._types import Omit
from openai.types.chat import (
ChatCompletion,
ChatCompletionAssistantMessageParam,
ChatCompletionChunk,
ChatCompletionContentPartParam,
ChatCompletionContentPartTextParam,
ChatCompletionFunctionToolParam,
ChatCompletionMessage,
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam,
ChatCompletionMessageToolCallParam,
ChatCompletionSystemMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionUserMessageParam,
)
from openai.types.chat.chat_completion_message_function_tool_call_param import Function
from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from .api import api_error_handler
from .const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
CONF_STREAMING,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_MODEL,
DOMAIN,
LOGGER,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
)
if TYPE_CHECKING:
from . import LlamaCppConfigEntry
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
_LOGGER = logging.getLogger(__name__)
def _format_structured_output(
name: str, structure: vol.Schema, llm_api: llm.APIInstance | None
) -> ResponseFormatJSONSchema:
"""Format structured output specification."""
schema = convert(
structure, custom_serializer=llm_api.custom_serializer if llm_api else None
)
return ResponseFormatJSONSchema(
type="json_schema",
json_schema={
"name": name,
"strict": True,
"schema": cast(dict[str, object], schema),
},
)
def _format_tool(
tool: llm.Tool,
custom_serializer: Callable[[Any], Any] | None,
) -> ChatCompletionFunctionToolParam:
"""Format tool specification."""
tool_spec = FunctionDefinition(
name=tool.name,
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
)
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"] = [
ChatCompletionMessageToolCallParam(
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 OpenAI 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(
translation_domain=DOMAIN,
translation_key="json_parse_error",
translation_placeholders={"message": str(err)},
) from err
async def _transform_response(
message: ChatCompletionMessage,
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the OpenAI API 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 isinstance(tool_call, ChatCompletionMessageFunctionToolCall)
]
yield data
def _convert_content_to_param(
content: conversation.Content,
) -> ChatCompletionMessageParam:
"""Convert any native chat message for this agent to the native format."""
if isinstance(content, conversation.ToolResultContent):
return ChatCompletionToolMessageParam(
role="tool",
tool_call_id=content.tool_call_id,
content=json.dumps(content.tool_result),
)
if not isinstance(content, conversation.AssistantContent) or not content.tool_calls:
if isinstance(content, conversation.SystemContent):
return ChatCompletionSystemMessageParam(
role="system",
content=content.content or "",
)
return cast(
ChatCompletionMessageParam,
{"role": content.role, "content": content.content or ""},
)
return ChatCompletionAssistantMessageParam(
role="assistant",
content=content.content,
tool_calls=[
ChatCompletionMessageToolCallParam(
id=tool_call.id,
function=Function(
arguments=json.dumps(tool_call.tool_args),
name=tool_call.tool_name,
),
type="function",
)
for tool_call in content.tool_calls
],
)
async def _transform_stream(
result: AsyncStream[ChatCompletionChunk],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform an OpenAI delta stream into HA format."""
current_tool_call: dict[str, Any] | None = None
yielded_role = False
async for chunk in result:
LOGGER.debug("Received chunk: %s", chunk)
if not chunk.choices:
continue
choice = chunk.choices[0]
if choice.finish_reason:
if current_tool_call:
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_call["id"],
tool_name=current_tool_call["tool_name"],
tool_args=_decode_tool_arguments(
current_tool_call["tool_args"]
)
if current_tool_call["tool_args"]
else {},
)
]
}
break
delta = choice.delta
if current_tool_call is None and not delta.tool_calls:
yield_dict: conversation.AssistantContentDeltaDict = {}
if not yielded_role and delta.role == "assistant":
yield_dict["role"] = "assistant"
yielded_role = True
if delta.content is not None:
yield_dict["content"] = delta.content
if yield_dict:
yield yield_dict
continue
if (
not delta.tool_calls
or not (delta_tool_call := delta.tool_calls[0])
or not delta_tool_call.function
):
continue
if current_tool_call and delta_tool_call.index == current_tool_call["index"]:
current_tool_call["tool_args"] += delta_tool_call.function.arguments or ""
continue
if current_tool_call:
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_call["id"],
tool_name=current_tool_call["tool_name"],
tool_args=_decode_tool_arguments(
current_tool_call["tool_args"]
),
)
]
}
current_tool_call = {
"index": delta_tool_call.index,
"id": delta_tool_call.id,
"tool_name": delta_tool_call.function.name,
"tool_args": delta_tool_call.function.arguments or "",
}
class LlamaCppBaseLLMEntity(Entity):
"""llama.cpp base LLM entity."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, entry: LlamaCppConfigEntry, subentry: ConfigSubentry) -> None:
"""Initialize the entity."""
self.entry = entry
self.subentry = subentry
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="llama.cpp",
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT_MODEL),
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
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
]
model: str = options.get(CONF_CHAT_MODEL, DEFAULT_MODEL)
messages = [
m
for content in chat_log.content
if (m := _convert_content_to_chat_message(content))
]
response_format: ResponseFormatJSONSchema | Omit = Omit()
if structure and structure_name:
response_format = _format_structured_output(
structure_name, structure, chat_log.llm_api
)
last_content = chat_log.content[-1]
if (
isinstance(last_content, conversation.UserContent)
and last_content.attachments
):
files = await async_prepare_files_for_prompt(
self.hass,
[a.path for a in last_content.attachments],
)
for i in range(len(messages) - 1, -1, -1):
if messages[i]["role"] == "user":
user_msg = cast(ChatCompletionUserMessageParam, messages[i])
current_content = user_msg.get("content")
if isinstance(current_content, str):
user_msg["content"] = [
ChatCompletionContentPartTextParam(
type="text", text=current_content
),
*files,
]
break
client: AsyncOpenAI = self.entry.runtime_data
streaming = bool(
self.entry.data.get(CONF_STREAMING, options.get(CONF_STREAMING, False))
)
for _iteration in range(MAX_TOOL_ITERATIONS):
with api_error_handler():
result = await client.chat.completions.create(
messages=messages,
model=model,
tools=tools or Omit(),
response_format=response_format,
max_tokens=cast(
int, options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
),
top_p=cast(float, options.get(CONF_TOP_P, RECOMMENDED_TOP_P)),
temperature=cast(
float, options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE)
),
user=chat_log.conversation_id,
stream=cast(Any, streaming),
)
convert_message: Callable[[Any], Any]
async_generator: AsyncGenerator[conversation.AssistantContentDeltaDict]
if streaming:
convert_message = _convert_content_to_param
async_generator = _transform_stream(
cast(AsyncStream[ChatCompletionChunk], result)
)
else:
convert_message = _convert_content_to_chat_message
async_generator = _transform_response(
cast(ChatCompletion, result).choices[0].message
)
messages.extend(
[
msg
async for content in chat_log.async_add_delta_content_stream(
self.entity_id, async_generator
)
if (msg := convert_message(content))
]
)
if not chat_log.unresponded_tool_results:
break
async def async_prepare_files_for_prompt(
hass: HomeAssistant, files: list[Path]
) -> list[ChatCompletionContentPartParam]:
"""Prepare files for OpenAI-compatible API.
Caller needs to ensure that the files are allowed.
"""
def guess_file_type(file_path: Path) -> tuple[str | None, str | None]:
"""Guess the file type based on the file extension."""
return mimetypes.guess_type(str(file_path))
def append_files_to_content() -> list[ChatCompletionContentPartParam]:
content: list[ChatCompletionContentPartParam] = []
for file_path in files:
if not file_path.exists():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="file_not_found",
translation_placeholders={"file_path": str(file_path)},
)
mime_type, _ = guess_file_type(file_path)
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unsupported_file_type",
translation_placeholders={"file_path": str(file_path)},
)
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
if mime_type.startswith("image/"):
content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_file}",
"detail": "auto",
},
}
)
elif mime_type.startswith("application/pdf"):
content.append(
{
"type": "text",
"text": f"[File: {file_path.name}]\nContent: {base64_file}",
}
)
return content
return await hass.async_add_executor_job(append_files_to_content)