Skip to content

Commit a7ac05d

Browse files
authored
Merge pull request #265 from jekalmin/v1.0.4
1.0.4
2 parents 1b20b56 + 94d293f commit a7ac05d

File tree

10 files changed

+332
-27
lines changed

10 files changed

+332
-27
lines changed

README.md

+7-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Derived from [OpenAI Conversation](https://www.home-assistant.io/integrations/op
1313
## How it works
1414
Extended OpenAI Conversation uses OpenAI API's feature of [function calling](https://platform.openai.com/docs/guides/function-calling) to call service of Home Assistant.
1515

16-
Since "gpt-3.5-turbo" model already knows how to call service of Home Assistant in general, you just have to let model know what devices you have by [exposing entities](https://github.com/jekalmin/extended_openai_conversation#preparation)
16+
Since OpenAI models already know how to call service of Home Assistant in general, you just have to let model know what devices you have by [exposing entities](https://github.com/jekalmin/extended_openai_conversation#preparation)
1717

1818
## Installation
1919
1. Install via registering as a custom repository of HACS or by copying `extended_openai_conversation` folder into `<config directory>/custom_components`
@@ -22,7 +22,7 @@ Since "gpt-3.5-turbo" model already knows how to call service of Home Assistant
2222
4. In the bottom right corner, select the Add Integration button.
2323
5. Follow the instructions on screen to complete the setup (API Key is required).
2424
- [Generating an API Key](https://www.home-assistant.io/integrations/openai_conversation/#generate-an-api-key)
25-
- Specify "Base Url" if using OpenAI compatible servers like LocalAI, otherwise leave as it is.
25+
- Specify "Base Url" if using OpenAI compatible servers like Azure OpenAI (also with APIM), LocalAI, otherwise leave as it is.
2626
6. Go to Settings > [Voice Assistants](https://my.home-assistant.io/redirect/voice_assistants/).
2727
7. Click to edit Assistant (named "Home Assistant" by default).
2828
8. Select "Extended OpenAI Conversation" from "Conversation agent" tab.
@@ -245,12 +245,14 @@ In order to pass result of calling service to OpenAI, set response variable to `
245245
function:
246246
type: script
247247
sequence:
248-
- service: calendar.list_events
248+
- service: calendar.get_events
249249
data:
250250
start_date_time: "{{start_date_time}}"
251251
end_date_time: "{{end_date_time}}"
252252
target:
253-
entity_id: calendar.test
253+
entity_id:
254+
- calendar.[YourCalendarHere]
255+
- calendar.[MoreCalendarsArePossible]
254256
response_variable: _function_result
255257
```
256258

@@ -513,7 +515,7 @@ When using [ytube_music_player](https://github.com/KoljaWindeler/ytube_music_pla
513515
#### 7-1. Let model generate a query
514516
- Without examples, a query tries to fetch data only from "states" table like below
515517
> Question: When did bedroom light turn on? <br/>
516-
Query(generated by gpt-3.5): SELECT * FROM states WHERE entity_id = 'input_boolean.livingroom_light_2' AND state = 'on' ORDER BY last_changed DESC LIMIT 1
518+
Query(generated by gpt): SELECT * FROM states WHERE entity_id = 'input_boolean.livingroom_light_2' AND state = 'on' ORDER BY last_changed DESC LIMIT 1
517519
- Since "entity_id" is stored in "states_meta" table, we need to give examples of question and query.
518520
- Not secured, but flexible way
519521

custom_components/extended_openai_conversation/__init__.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
intent,
3131
template,
3232
)
33+
from homeassistant.helpers.httpx_client import get_async_client
3334
from homeassistant.helpers.typing import ConfigType
3435
from homeassistant.util import ulid
3536

@@ -145,12 +146,14 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
145146
azure_endpoint=base_url,
146147
api_version=entry.data.get(CONF_API_VERSION),
147148
organization=entry.data.get(CONF_ORGANIZATION),
149+
http_client=get_async_client(hass),
148150
)
149151
else:
150152
self.client = AsyncOpenAI(
151153
api_key=entry.data[CONF_API_KEY],
152154
base_url=base_url,
153155
organization=entry.data.get(CONF_ORGANIZATION),
156+
http_client=get_async_client(hass),
154157
)
155158

156159
@property
@@ -186,9 +189,9 @@ async def async_process(
186189
messages = [system_message]
187190
user_message = {"role": "user", "content": user_input.text}
188191
if self.entry.options.get(CONF_ATTACH_USERNAME, DEFAULT_ATTACH_USERNAME):
189-
user = await self.hass.auth.async_get_user(user_input.context.user_id)
190-
if user is not None and user.name is not None:
191-
user_message[ATTR_NAME] = user.name
192+
user = user_input.context.user_id
193+
if user is not None:
194+
user_message[ATTR_NAME] = user
192195

193196
messages.append(user_message)
194197

@@ -356,7 +359,7 @@ async def query(
356359
if len(functions) == 0:
357360
tool_kwargs = {}
358361

359-
_LOGGER.info("Prompt for %s: %s", model, messages)
362+
_LOGGER.info("Prompt for %s: %s", model, json.dumps(messages))
360363

361364
response: ChatCompletion = await self.client.chat.completions.create(
362365
model=model,
@@ -368,7 +371,7 @@ async def query(
368371
**tool_kwargs,
369372
)
370373

371-
_LOGGER.info("Response %s", response.model_dump(exclude_none=True))
374+
_LOGGER.info("Response %s", json.dumps(response.model_dump(exclude_none=True)))
372375

373376
if response.usage.total_tokens > context_threshold:
374377
await self.truncate_message_history(messages, exposed_entities, user_input)

custom_components/extended_openai_conversation/const.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
Do not restate or appreciate what user says, rather make a quick inquiry.
3333
"""
3434
CONF_CHAT_MODEL = "chat_model"
35-
DEFAULT_CHAT_MODEL = "gpt-3.5-turbo-1106"
35+
DEFAULT_CHAT_MODEL = "gpt-4o-mini"
3636
CONF_MAX_TOKENS = "max_tokens"
3737
DEFAULT_MAX_TOKENS = 150
3838
CONF_TOP_P = "top_p"

custom_components/extended_openai_conversation/helpers.py

+24-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from abc import ABC, abstractmethod
22
from datetime import timedelta
3+
from functools import partial
34
import logging
45
import os
56
import re
@@ -39,6 +40,7 @@
3940
from homeassistant.core import HomeAssistant, State
4041
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound
4142
from homeassistant.helpers import config_validation as cv
43+
from homeassistant.helpers.httpx_client import get_async_client
4244
from homeassistant.helpers.script import Script
4345
from homeassistant.helpers.template import Template
4446
import homeassistant.util.dt as dt_util
@@ -56,7 +58,7 @@
5658
_LOGGER = logging.getLogger(__name__)
5759

5860

59-
AZURE_DOMAIN_PATTERN = r"\.openai\.azure\.com"
61+
AZURE_DOMAIN_PATTERN = r"\.(openai\.azure\.com|azure-api\.net)"
6062

6163

6264
def get_function_executor(value: str):
@@ -141,13 +143,17 @@ async def validate_authentication(
141143
azure_endpoint=base_url,
142144
api_version=api_version,
143145
organization=organization,
146+
http_client=get_async_client(hass),
144147
)
145148
else:
146149
client = AsyncOpenAI(
147-
api_key=api_key, base_url=base_url, organization=organization
150+
api_key=api_key,
151+
base_url=base_url,
152+
organization=organization,
153+
http_client=get_async_client(hass),
148154
)
149155

150-
await client.models.list(timeout=10)
156+
await hass.async_add_executor_job(partial(client.models.list, timeout=10))
151157

152158

153159
class FunctionExecutor(ABC):
@@ -223,6 +229,10 @@ async def execute(
223229
return await self.get_statistics(
224230
hass, function, arguments, user_input, exposed_entities
225231
)
232+
if name == "get_user_from_user_id":
233+
return await self.get_user_from_user_id(
234+
hass, function, arguments, user_input, exposed_entities
235+
)
226236

227237
raise NativeNotFound(name)
228238

@@ -372,6 +382,17 @@ async def get_energy(
372382
energy_manager: energy.data.EnergyManager = await energy.async_get_manager(hass)
373383
return energy_manager.data
374384

385+
async def get_user_from_user_id(
386+
self,
387+
hass: HomeAssistant,
388+
function,
389+
arguments,
390+
user_input: conversation.ConversationInput,
391+
exposed_entities,
392+
):
393+
user = await hass.auth.async_get_user(user_input.context.user_id)
394+
return {'name': user.name if user and hasattr(user, 'name') else 'Unknown'}
395+
375396
async def get_statistics(
376397
self,
377398
hass: HomeAssistant,

custom_components/extended_openai_conversation/services.py

+40-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import base64
12
import logging
3+
import mimetypes
4+
from pathlib import Path
5+
from urllib.parse import urlparse
26

3-
import voluptuous as vol
47
from openai import AsyncOpenAI
58
from openai._exceptions import OpenAIError
9+
from openai.types.chat.chat_completion_content_part_image_param import (
10+
ChatCompletionContentPartImageParam,
11+
)
12+
import voluptuous as vol
613

714
from homeassistant.core import (
815
HomeAssistant,
@@ -11,8 +18,8 @@
1118
SupportsResponse,
1219
)
1320
from homeassistant.exceptions import HomeAssistantError
21+
from homeassistant.helpers import config_validation as cv, selector
1422
from homeassistant.helpers.typing import ConfigType
15-
from homeassistant.helpers import selector, config_validation as cv
1623

1724
from .const import DOMAIN, SERVICE_QUERY_IMAGE
1825

@@ -25,7 +32,7 @@
2532
),
2633
vol.Required("model", default="gpt-4-vision-preview"): cv.string,
2734
vol.Required("prompt"): cv.string,
28-
vol.Required("images"): vol.All(cv.ensure_list, [{"url": cv.url}]),
35+
vol.Required("images"): vol.All(cv.ensure_list, [{"url": cv.string}]),
2936
vol.Optional("max_tokens", default=300): cv.positive_int,
3037
}
3138
)
@@ -41,7 +48,7 @@ async def query_image(call: ServiceCall) -> ServiceResponse:
4148
try:
4249
model = call.data["model"]
4350
images = [
44-
{"type": "image_url", "image_url": image}
51+
{"type": "image_url", "image_url": to_image_param(hass, image)}
4552
for image in call.data["images"]
4653
]
4754

@@ -74,3 +81,32 @@ async def query_image(call: ServiceCall) -> ServiceResponse:
7481
schema=QUERY_IMAGE_SCHEMA,
7582
supports_response=SupportsResponse.ONLY,
7683
)
84+
85+
86+
def to_image_param(hass: HomeAssistant, image) -> ChatCompletionContentPartImageParam:
87+
"""Convert url to base64 encoded image if local."""
88+
url = image["url"]
89+
90+
if urlparse(url).scheme in cv.EXTERNAL_URL_PROTOCOL_SCHEMA_LIST:
91+
return image
92+
93+
if not hass.config.is_allowed_path(url):
94+
raise HomeAssistantError(
95+
f"Cannot read `{url}`, no access to path; "
96+
"`allowlist_external_dirs` may need to be adjusted in "
97+
"`configuration.yaml`"
98+
)
99+
if not Path(url).exists():
100+
raise HomeAssistantError(f"`{url}` does not exist")
101+
mime_type, _ = mimetypes.guess_type(url)
102+
if mime_type is None or not mime_type.startswith("image"):
103+
raise HomeAssistantError(f"`{url}` is not an image")
104+
105+
image["url"] = f"data:{mime_type};base64,{encode_image(url)}"
106+
return image
107+
108+
109+
def encode_image(image_path):
110+
"""Convert to base64 encoded image."""
111+
with open(image_path, "rb") as image_file:
112+
return base64.b64encode(image_file.read()).decode("utf-8")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"config": {
3+
"error": {
4+
"cannot_connect": "Não é possível conectar",
5+
"invalid_auth": "Autenticação inválida",
6+
"unknown": "Erro desconhecido"
7+
},
8+
"step": {
9+
"user": {
10+
"data": {
11+
"name": "Nome",
12+
"api_key": "Chave API",
13+
"base_url": "Base Url",
14+
"api_version": "Versão da API",
15+
"organization": "Organização",
16+
"skip_authentication": "Pular autenticação"
17+
}
18+
}
19+
}
20+
},
21+
"options": {
22+
"step": {
23+
"init": {
24+
"data": {
25+
"max_tokens": "Número máximo de tokens da resposta",
26+
"model": "Modelo da Conclusão",
27+
"prompt": "Template do Prompt",
28+
"temperature": "Temperatura",
29+
"top_p": "Top P",
30+
"max_function_calls_per_conversation": "Quantidade máxima de chamadas por conversação",
31+
"functions": "Funções",
32+
"attach_username": "Anexar nome do usuário na mensagem",
33+
"use_tools": "Use ferramentas",
34+
"context_threshold": "Limite do contexto",
35+
"context_truncate_strategy": "Estratégia de truncamento de contexto quando o limite é excedido"
36+
}
37+
}
38+
}
39+
},
40+
"services": {
41+
"query_image": {
42+
"name": "Consultar imagem",
43+
"description": "Receba imagens e responda perguntas sobre elas",
44+
"fields": {
45+
"config_entry": {
46+
"name": "Registro de configuração",
47+
"description": "O registro de configuração para utilizar neste serviço"
48+
},
49+
"model": {
50+
"name": "Modelo",
51+
"description": "Especificar modelo",
52+
"example": "gpt-4-vision-preview"
53+
},
54+
"prompt": {
55+
"name": "Prompt",
56+
"description": "O texto para fazer a pergunta sobre a imagem",
57+
"example": "O que tem nesta imagem?"
58+
},
59+
"images": {
60+
"name": "Imagens",
61+
"description": "Uma lista de imagens que serão analisadas",
62+
"example": "{\"url\": \"https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg\"}"
63+
},
64+
"max_tokens": {
65+
"name": "Max Tokens",
66+
"description": "Quantidade máxima de tokens",
67+
"example": "300"
68+
}
69+
}
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)