mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-29 06:13:56 +08:00
Release v2.0.0
This commit is contained in:
@@ -1,16 +1,20 @@
|
|||||||
"""The HA Text AI integration."""
|
"""The HA Text AI integration."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
import asyncio
|
||||||
|
import voluptuous as vol
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_API_KEY
|
from homeassistant.const import CONF_API_KEY
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
from homeassistant.helpers import aiohttp_client
|
from homeassistant.helpers import aiohttp_client
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from async_timeout import timeout
|
||||||
|
|
||||||
from .coordinator import HATextAICoordinator
|
from .coordinator import HATextAICoordinator
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
@@ -24,15 +28,94 @@ from .const import (
|
|||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
DEFAULT_API_ENDPOINT,
|
DEFAULT_API_ENDPOINT,
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
DEFAULT_REQUEST_INTERVAL,
|
||||||
|
API_VERSION,
|
||||||
|
API_MODELS_PATH,
|
||||||
|
API_CHAT_PATH,
|
||||||
|
API_TIMEOUT,
|
||||||
|
API_RETRY_COUNT,
|
||||||
|
API_BACKOFF_FACTOR,
|
||||||
|
LOGGER_NAME,
|
||||||
|
STATE_ERROR,
|
||||||
|
STATE_READY,
|
||||||
|
STATE_PROCESSING,
|
||||||
|
STATE_RATE_LIMITED,
|
||||||
|
STATE_MAINTENANCE,
|
||||||
|
STATE_DISCONNECTED,
|
||||||
|
STATE_RETRYING,
|
||||||
|
STATE_QUEUED,
|
||||||
|
STATE_UPDATING,
|
||||||
|
SUPPORTED_MODELS,
|
||||||
|
EVENT_RESPONSE_RECEIVED,
|
||||||
|
EVENT_ERROR_OCCURRED,
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(LOGGER_NAME)
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
# Service validation schemas
|
||||||
|
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||||
|
vol.Required("question"): cv.string,
|
||||||
|
vol.Optional("system_prompt"): cv.string,
|
||||||
|
vol.Optional("model"): vol.In(SUPPORTED_MODELS),
|
||||||
|
vol.Optional("temperature"): vol.All(
|
||||||
|
vol.Coerce(float), vol.Range(min=0, max=2)
|
||||||
|
),
|
||||||
|
vol.Optional("max_tokens"): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=4096)
|
||||||
|
),
|
||||||
|
vol.Optional("priority"): vol.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||||
|
vol.Optional("limit", default=10): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=100)
|
||||||
|
),
|
||||||
|
vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS),
|
||||||
|
vol.Optional("start_date"): cv.datetime,
|
||||||
|
vol.Optional("include_metadata"): vol.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||||
|
vol.Required("prompt"): cv.string,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def async_check_api(session, endpoint: str, headers: dict, is_anthropic: bool = False) -> bool:
|
||||||
|
"""Check API availability for different providers."""
|
||||||
|
try:
|
||||||
|
if is_anthropic:
|
||||||
|
check_url = f"{endpoint}/v1/models"
|
||||||
|
else:
|
||||||
|
check_url = f"{endpoint}/{API_VERSION}/{API_MODELS_PATH}"
|
||||||
|
|
||||||
|
async with timeout(API_TIMEOUT):
|
||||||
|
async with session.get(check_url, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return True
|
||||||
|
elif response.status == 401:
|
||||||
|
raise ConfigEntryNotReady("Invalid API key")
|
||||||
|
elif response.status == 429:
|
||||||
|
_LOGGER.warning("Rate limit exceeded during API check")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
_LOGGER.error("API check failed with status: %d", response.status)
|
||||||
|
return False
|
||||||
|
except Exception as ex:
|
||||||
|
_LOGGER.error("API check error: %s", str(ex))
|
||||||
|
return False
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
"""Set up the HA Text AI component."""
|
"""Set up the HA Text AI component."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {
|
||||||
|
"coordinators": {},
|
||||||
|
"metrics": {
|
||||||
|
"total_requests": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
"errors": {},
|
||||||
|
"model_usage": {},
|
||||||
|
}
|
||||||
|
})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
@@ -40,30 +123,77 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
try:
|
try:
|
||||||
session = aiohttp_client.async_get_clientsession(hass)
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
|
|
||||||
|
# Determine API type based on model
|
||||||
|
model = entry.data.get(CONF_MODEL, DEFAULT_MODEL)
|
||||||
|
is_anthropic = any(model.startswith("claude") for model in SUPPORTED_MODELS)
|
||||||
|
|
||||||
|
api_key = entry.data[CONF_API_KEY]
|
||||||
|
endpoint = entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT).rstrip('/')
|
||||||
|
|
||||||
|
# Configure headers based on API type
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_anthropic:
|
||||||
|
headers["x-api-key"] = api_key
|
||||||
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
# Check API with retries
|
||||||
|
for attempt in range(API_RETRY_COUNT):
|
||||||
|
if await async_check_api(session, endpoint, headers, is_anthropic):
|
||||||
|
break
|
||||||
|
if attempt < API_RETRY_COUNT - 1:
|
||||||
|
delay = API_BACKOFF_FACTOR * (2 ** attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
raise ConfigEntryNotReady("Failed to connect to API")
|
||||||
|
|
||||||
|
# Create and initialize coordinator
|
||||||
coordinator = HATextAICoordinator(
|
coordinator = HATextAICoordinator(
|
||||||
hass,
|
hass,
|
||||||
api_key=entry.data[CONF_API_KEY],
|
api_key=api_key,
|
||||||
endpoint=entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT),
|
endpoint=endpoint,
|
||||||
model=entry.data.get(CONF_MODEL, DEFAULT_MODEL),
|
model=model,
|
||||||
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
|
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
|
||||||
max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
|
max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
|
||||||
request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
|
request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
|
||||||
session=session,
|
session=session,
|
||||||
|
is_anthropic=is_anthropic
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Initialize coordinator
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
except Exception as refresh_ex:
|
|
||||||
_LOGGER.error("Failed to refresh coordinator: %s", str(refresh_ex))
|
|
||||||
raise ConfigEntryNotReady from refresh_ex
|
|
||||||
|
|
||||||
if not coordinator.last_update_success:
|
# Check coordinator status
|
||||||
raise ConfigEntryNotReady("Failed to communicate with OpenAI API")
|
if coordinator.endpoint_status == "auth_error":
|
||||||
|
raise ConfigEntryNotReady("Authentication failed")
|
||||||
|
elif coordinator.endpoint_status == "rate_limited":
|
||||||
|
_LOGGER.warning("API rate limited during setup")
|
||||||
|
elif coordinator.endpoint_status == "maintenance":
|
||||||
|
raise ConfigEntryNotReady("API is in maintenance mode")
|
||||||
|
elif coordinator.endpoint_status == "error":
|
||||||
|
raise ConfigEntryNotReady("API error during setup")
|
||||||
|
elif not coordinator.last_update_success:
|
||||||
|
raise ConfigEntryNotReady("Failed to initialize coordinator")
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
# Register event handlers
|
||||||
|
@callback
|
||||||
|
def handle_state_change(event):
|
||||||
|
"""Handle state changes."""
|
||||||
|
if event.data.get("entity_id").startswith(f"{DOMAIN}."):
|
||||||
|
_LOGGER.debug("State changed: %s", event.data)
|
||||||
|
|
||||||
|
hass.bus.async_listen(EVENT_STATE_CHANGED, handle_state_change)
|
||||||
|
|
||||||
|
# Register services
|
||||||
async def async_ask_question(call: ServiceCall) -> None:
|
async def async_ask_question(call: ServiceCall) -> None:
|
||||||
"""Handle the ask_question service call."""
|
"""Handle the ask_question service call."""
|
||||||
question = call.data.get("question", "")
|
question = call.data.get("question", "")
|
||||||
@@ -71,36 +201,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_LOGGER.error("No question provided in service call")
|
_LOGGER.error("No question provided in service call")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Собираем все опциональные параметры
|
|
||||||
request_params = {}
|
request_params = {}
|
||||||
|
for param in ["system_prompt", "model", "temperature", "max_tokens"]:
|
||||||
# Обработка system_prompt
|
if param in call.data:
|
||||||
system_prompt = call.data.get("system_prompt")
|
request_params[param] = call.data[param]
|
||||||
if system_prompt is not None:
|
|
||||||
request_params["system_prompt"] = system_prompt
|
|
||||||
|
|
||||||
# Обработка model
|
|
||||||
model = call.data.get("model")
|
|
||||||
if model is not None:
|
|
||||||
request_params["model"] = model
|
|
||||||
|
|
||||||
# Обработка temperature
|
|
||||||
temperature = call.data.get("temperature")
|
|
||||||
if temperature is not None:
|
|
||||||
try:
|
|
||||||
request_params["temperature"] = float(temperature)
|
|
||||||
except ValueError:
|
|
||||||
_LOGGER.error("Invalid temperature value: %s", temperature)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Обработка max_tokens
|
|
||||||
max_tokens = call.data.get("max_tokens")
|
|
||||||
if max_tokens is not None:
|
|
||||||
try:
|
|
||||||
request_params["max_tokens"] = int(max_tokens)
|
|
||||||
except ValueError:
|
|
||||||
_LOGGER.error("Invalid max_tokens value: %s", max_tokens)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await coordinator.async_ask_question(question, **request_params)
|
await coordinator.async_ask_question(question, **request_params)
|
||||||
@@ -119,21 +223,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
async def async_get_history(call: ServiceCall) -> dict:
|
async def async_get_history(call: ServiceCall) -> dict:
|
||||||
"""Handle the get_history service call."""
|
"""Handle the get_history service call."""
|
||||||
try:
|
try:
|
||||||
limit = call.data.get("limit", 10)
|
limit = min(int(call.data.get("limit", 10)), 100)
|
||||||
filter_model = call.data.get("filter_model", "")
|
filter_model = str(call.data.get("filter_model", ""))
|
||||||
|
start_date = call.data.get("start_date")
|
||||||
|
include_metadata = call.data.get("include_metadata", False)
|
||||||
|
|
||||||
responses = coordinator._responses
|
responses = coordinator._responses
|
||||||
|
metrics = {
|
||||||
|
"total_requests": coordinator.request_count,
|
||||||
|
"total_tokens": coordinator.tokens_used,
|
||||||
|
"api_version": coordinator.api_version,
|
||||||
|
"endpoint_status": coordinator.endpoint_status,
|
||||||
|
"error_count": coordinator.error_count
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered_responses = responses.copy()
|
||||||
|
|
||||||
# Применяем фильтрацию по модели
|
|
||||||
if filter_model:
|
if filter_model:
|
||||||
filtered_responses = {
|
filtered_responses = {
|
||||||
k: v for k, v in responses.items()
|
k: v for k, v in filtered_responses.items()
|
||||||
if v.get("model") == filter_model
|
if v.get("model") == filter_model
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
filtered_responses = responses.copy()
|
|
||||||
|
|
||||||
# Сортируем по времени и ограничиваем количество
|
if start_date:
|
||||||
|
filtered_responses = {
|
||||||
|
k: v for k, v in filtered_responses.items()
|
||||||
|
if v.get("timestamp") >= start_date
|
||||||
|
}
|
||||||
|
|
||||||
|
if not include_metadata:
|
||||||
|
filtered_responses = {
|
||||||
|
k: {
|
||||||
|
"question": v["question"],
|
||||||
|
"response": v["response"],
|
||||||
|
"timestamp": v["timestamp"]
|
||||||
|
} for k, v in filtered_responses.items()
|
||||||
|
}
|
||||||
|
|
||||||
sorted_responses = dict(
|
sorted_responses = dict(
|
||||||
sorted(
|
sorted(
|
||||||
filtered_responses.items(),
|
filtered_responses.items(),
|
||||||
@@ -142,28 +268,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)[:limit]
|
)[:limit]
|
||||||
)
|
)
|
||||||
|
|
||||||
return sorted_responses
|
return {
|
||||||
|
"metrics": metrics,
|
||||||
|
"responses": sorted_responses
|
||||||
|
}
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error getting history: %s", str(err))
|
_LOGGER.error("Error getting history: %s", str(err))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def async_set_system_prompt(call: ServiceCall) -> None:
|
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||||
"""Handle the set_system_prompt service call."""
|
"""Handle the set_system_prompt service call."""
|
||||||
prompt = call.data.get("prompt", "")
|
try:
|
||||||
if prompt:
|
prompt = str(call.data.get("prompt", "")).strip()
|
||||||
try:
|
if prompt:
|
||||||
coordinator.system_prompt = prompt
|
coordinator.system_prompt = prompt
|
||||||
_LOGGER.info("System prompt updated successfully")
|
_LOGGER.info("System prompt updated successfully")
|
||||||
except Exception as err:
|
else:
|
||||||
_LOGGER.error("Error setting system prompt: %s", str(err))
|
_LOGGER.error("Empty prompt provided")
|
||||||
else:
|
except Exception as err:
|
||||||
_LOGGER.error("No prompt provided in service call")
|
_LOGGER.error("Error setting system prompt: %s", str(err))
|
||||||
|
|
||||||
# Регистрация сервисов
|
# Register services with validation
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
"ask_question",
|
"ask_question",
|
||||||
async_ask_question
|
async_ask_question,
|
||||||
|
schema=SERVICE_SCHEMA_ASK_QUESTION
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -175,13 +305,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
"get_history",
|
"get_history",
|
||||||
async_get_history
|
async_get_history,
|
||||||
|
schema=SERVICE_SCHEMA_GET_HISTORY
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
"set_system_prompt",
|
"set_system_prompt",
|
||||||
async_set_system_prompt
|
async_set_system_prompt,
|
||||||
|
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
@@ -192,24 +324,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_LOGGER.exception("Unexpected error setting up entry: %s", str(ex))
|
_LOGGER.exception("Setup error: %s", str(ex))
|
||||||
raise ConfigEntryNotReady from ex
|
raise ConfigEntryNotReady from ex
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
try:
|
try:
|
||||||
if entry.entry_id not in hass.data.get(DOMAIN, {}):
|
coordinator = hass.data[DOMAIN].get(entry.entry_id)
|
||||||
return True
|
if coordinator:
|
||||||
|
# Clear queue and history
|
||||||
|
coordinator._responses.clear()
|
||||||
|
while not coordinator._question_queue.empty():
|
||||||
|
try:
|
||||||
|
coordinator._question_queue.get_nowait()
|
||||||
|
coordinator._question_queue.task_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Удаляем все сервисы при выгрузке интеграции
|
# Close connection
|
||||||
services = ["ask_question", "clear_history", "get_history", "set_system_prompt"]
|
await coordinator.async_shutdown()
|
||||||
for service in services:
|
|
||||||
|
# Remove services
|
||||||
|
for service in ["ask_question", "clear_history", "get_history", "set_system_prompt"]:
|
||||||
hass.services.async_remove(DOMAIN, service)
|
hass.services.async_remove(DOMAIN, service)
|
||||||
|
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
await coordinator.async_shutdown()
|
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
@@ -217,10 +358,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_LOGGER.exception("Error unloading entry: %s", str(ex))
|
_LOGGER.exception("Error unloading entry: %s", str(ex))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Reload config entry."""
|
"""Migrate old entry."""
|
||||||
try:
|
_LOGGER.debug("Migrating from version %s", entry.version)
|
||||||
await async_unload_entry(hass, entry)
|
|
||||||
await async_setup_entry(hass, entry)
|
if entry.version == 1:
|
||||||
except Exception as ex:
|
new = {**entry.data}
|
||||||
_LOGGER.exception("Error reloading entry: %s", str(ex)) # убрано лишнее двоеточие
|
|
||||||
|
# Migrate settings
|
||||||
|
if CONF_MODEL in new and new[CONF_MODEL] not in SUPPORTED_MODELS:
|
||||||
|
new[CONF_MODEL] = DEFAULT_MODEL
|
||||||
|
|
||||||
|
entry.version = 2
|
||||||
|
hass.config_entries.async_update_entry(entry, data=new)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import voluptuous as vol
|
|||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from async_timeout import timeout
|
from async_timeout import timeout
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_API_KEY
|
from homeassistant.const import CONF_API_KEY
|
||||||
@@ -25,37 +25,56 @@ from .const import (
|
|||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
DEFAULT_API_ENDPOINT,
|
DEFAULT_API_ENDPOINT,
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
DEFAULT_REQUEST_INTERVAL,
|
||||||
|
MIN_TEMPERATURE,
|
||||||
|
MAX_TEMPERATURE,
|
||||||
|
MIN_MAX_TOKENS,
|
||||||
|
MAX_MAX_TOKENS,
|
||||||
|
MIN_REQUEST_INTERVAL,
|
||||||
|
API_VERSION,
|
||||||
|
API_MODELS_PATH,
|
||||||
|
ERROR_INVALID_API_KEY,
|
||||||
|
ERROR_CANNOT_CONNECT,
|
||||||
|
ERROR_UNKNOWN,
|
||||||
|
ERROR_INVALID_MODEL,
|
||||||
|
ERROR_RATE_LIMIT,
|
||||||
|
ERROR_API_ERROR,
|
||||||
|
ERROR_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema({
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
vol.Required(CONF_API_KEY): str,
|
{
|
||||||
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str,
|
vol.Required(CONF_API_KEY): cv.string,
|
||||||
vol.Optional(
|
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string,
|
||||||
CONF_TEMPERATURE,
|
vol.Optional(
|
||||||
default=DEFAULT_TEMPERATURE
|
CONF_TEMPERATURE,
|
||||||
): vol.All(
|
default=DEFAULT_TEMPERATURE
|
||||||
vol.Coerce(float),
|
): vol.All(
|
||||||
vol.Range(min=0, max=2)
|
vol.Coerce(float),
|
||||||
),
|
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||||
vol.Optional(
|
),
|
||||||
CONF_MAX_TOKENS,
|
vol.Optional(
|
||||||
default=DEFAULT_MAX_TOKENS
|
CONF_MAX_TOKENS,
|
||||||
): vol.All(
|
default=DEFAULT_MAX_TOKENS
|
||||||
vol.Coerce(int),
|
): vol.All(
|
||||||
vol.Range(min=1, max=4096)
|
vol.Coerce(int),
|
||||||
),
|
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||||
vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str,
|
),
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_REQUEST_INTERVAL,
|
CONF_API_ENDPOINT,
|
||||||
default=DEFAULT_REQUEST_INTERVAL
|
default=DEFAULT_API_ENDPOINT
|
||||||
): vol.All(
|
): cv.string,
|
||||||
vol.Coerce(float),
|
vol.Optional(
|
||||||
vol.Range(min=0.1)
|
CONF_REQUEST_INTERVAL,
|
||||||
),
|
default=DEFAULT_REQUEST_INTERVAL
|
||||||
})
|
): vol.All(
|
||||||
|
vol.Coerce(float),
|
||||||
|
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def validate_api_connection(
|
async def validate_api_connection(
|
||||||
hass,
|
hass,
|
||||||
@@ -69,10 +88,17 @@ async def validate_api_connection(
|
|||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Accept": "application/json; charset=utf-8",
|
||||||
|
"Accept-Charset": "utf-8"
|
||||||
}
|
}
|
||||||
|
|
||||||
models_url = f"{endpoint}/v1/models"
|
base_url = endpoint.rstrip('/')
|
||||||
|
if not base_url.endswith(f"/{API_VERSION}"):
|
||||||
|
base_url = f"{base_url}/{API_VERSION}"
|
||||||
|
|
||||||
|
models_url = f"{base_url}/{API_MODELS_PATH}"
|
||||||
|
_LOGGER.debug("Attempting to connect to: %s", models_url)
|
||||||
|
|
||||||
for attempt in range(retry_count):
|
for attempt in range(retry_count):
|
||||||
try:
|
try:
|
||||||
@@ -82,30 +108,33 @@ async def validate_api_connection(
|
|||||||
data = await response.json()
|
data = await response.json()
|
||||||
model_ids = [m["id"] for m in data.get("data", [])]
|
model_ids = [m["id"] for m in data.get("data", [])]
|
||||||
|
|
||||||
|
_LOGGER.debug("Available models: %s", ", ".join(model_ids))
|
||||||
|
|
||||||
if model not in model_ids:
|
if model not in model_ids:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Model %s not found in available models: %s",
|
"Model %s not found in available models: %s",
|
||||||
model,
|
model,
|
||||||
", ".join(model_ids)
|
", ".join(model_ids)
|
||||||
)
|
)
|
||||||
return False, "invalid_model", model_ids
|
return False, ERROR_INVALID_MODEL, model_ids
|
||||||
return True, "", model_ids
|
return True, "", model_ids
|
||||||
|
|
||||||
elif response.status == 401:
|
elif response.status == 401:
|
||||||
_LOGGER.error("Authentication failed")
|
_LOGGER.error("Authentication failed")
|
||||||
return False, "invalid_auth", []
|
return False, ERROR_INVALID_API_KEY, []
|
||||||
|
|
||||||
elif response.status == 429:
|
elif response.status == 429:
|
||||||
_LOGGER.error("Rate limit exceeded")
|
_LOGGER.error("Rate limit exceeded")
|
||||||
return False, "rate_limit", []
|
return False, ERROR_RATE_LIMIT, []
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
response_text = await response.text()
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"API error: %s - %s",
|
"API error: %s - %s",
|
||||||
response.status,
|
response.status,
|
||||||
await response.text()
|
response_text
|
||||||
)
|
)
|
||||||
return False, "api_error", []
|
return False, ERROR_API_ERROR, []
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
@@ -114,18 +143,18 @@ async def validate_api_connection(
|
|||||||
retry_count
|
retry_count
|
||||||
)
|
)
|
||||||
if attempt == retry_count - 1:
|
if attempt == retry_count - 1:
|
||||||
return False, "timeout", []
|
return False, ERROR_TIMEOUT, []
|
||||||
await asyncio.sleep(retry_delay)
|
await asyncio.sleep(retry_delay)
|
||||||
|
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.error("Connection error: %s", str(err))
|
_LOGGER.error("Connection error: %s", str(err))
|
||||||
return False, "cannot_connect", []
|
return False, ERROR_CANNOT_CONNECT, []
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.exception("Unexpected error during validation: %s", str(err))
|
_LOGGER.exception("Unexpected error during validation: %s", str(err))
|
||||||
return False, "unknown", []
|
return False, ERROR_UNKNOWN, []
|
||||||
|
|
||||||
return False, "unknown", []
|
return False, ERROR_UNKNOWN, []
|
||||||
|
|
||||||
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for HA text AI."""
|
"""Handle a config flow for HA text AI."""
|
||||||
@@ -141,7 +170,6 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
# Validate URL format
|
|
||||||
endpoint = user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT)
|
endpoint = user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT)
|
||||||
try:
|
try:
|
||||||
result = urlparse(endpoint)
|
result = urlparse(endpoint)
|
||||||
@@ -161,7 +189,6 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors
|
errors=errors
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate input data
|
|
||||||
validated_input = STEP_USER_DATA_SCHEMA(user_input)
|
validated_input = STEP_USER_DATA_SCHEMA(user_input)
|
||||||
|
|
||||||
is_valid, error_code, available_models = await validate_api_connection(
|
is_valid, error_code, available_models = await validate_api_connection(
|
||||||
@@ -181,7 +208,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
errors["base"] = error_code
|
errors["base"] = error_code
|
||||||
if error_code == "invalid_model":
|
if error_code == ERROR_INVALID_MODEL:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Selected model %s not found in available models: %s",
|
"Selected model %s not found in available models: %s",
|
||||||
validated_input[CONF_MODEL],
|
validated_input[CONF_MODEL],
|
||||||
@@ -233,7 +260,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
),
|
),
|
||||||
): vol.All(
|
): vol.All(
|
||||||
vol.Coerce(float),
|
vol.Coerce(float),
|
||||||
vol.Range(min=0, max=2)
|
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_MAX_TOKENS,
|
CONF_MAX_TOKENS,
|
||||||
@@ -242,7 +269,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
),
|
),
|
||||||
): vol.All(
|
): vol.All(
|
||||||
vol.Coerce(int),
|
vol.Coerce(int),
|
||||||
vol.Range(min=1, max=4096)
|
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_REQUEST_INTERVAL,
|
CONF_REQUEST_INTERVAL,
|
||||||
@@ -251,7 +278,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
),
|
),
|
||||||
): vol.All(
|
): vol.All(
|
||||||
vol.Coerce(float),
|
vol.Coerce(float),
|
||||||
vol.Range(min=0.1)
|
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,27 @@ CONF_MAX_TOKENS: Final = "max_tokens"
|
|||||||
CONF_API_ENDPOINT: Final = "api_endpoint"
|
CONF_API_ENDPOINT: Final = "api_endpoint"
|
||||||
CONF_REQUEST_INTERVAL: Final = "request_interval"
|
CONF_REQUEST_INTERVAL: Final = "request_interval"
|
||||||
|
|
||||||
|
# Model constants
|
||||||
|
SUPPORTED_MODELS: Final = [
|
||||||
|
"gpt-3.5-turbo",
|
||||||
|
"gpt-3.5-turbo-16k",
|
||||||
|
"gpt-4",
|
||||||
|
"gpt-4-32k",
|
||||||
|
"gpt-4-1106-preview",
|
||||||
|
"claude-3-sonnet",
|
||||||
|
"claude-3-opus"
|
||||||
|
]
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
DEFAULT_MODEL: Final = "gpt-3.5-turbo"
|
DEFAULT_MODEL: Final = "gpt-3.5-turbo"
|
||||||
DEFAULT_TEMPERATURE: Final = 0.1
|
DEFAULT_TEMPERATURE: Final = 0.1
|
||||||
DEFAULT_MAX_TOKENS: Final = 1000
|
DEFAULT_MAX_TOKENS: Final = 1000
|
||||||
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com/v1"
|
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com"
|
||||||
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
||||||
DEFAULT_TIMEOUT: Final = 30
|
DEFAULT_TIMEOUT: Final = 30
|
||||||
DEFAULT_QUEUE_SIZE: Final = 100
|
DEFAULT_QUEUE_SIZE: Final = 100
|
||||||
DEFAULT_HISTORY_LIMIT: Final = 50
|
DEFAULT_HISTORY_LIMIT: Final = 50
|
||||||
|
DEFAULT_RETRY_COUNT: Final = 3
|
||||||
|
|
||||||
# Parameter constraints
|
# Parameter constraints
|
||||||
MIN_TEMPERATURE: Final = 0.0
|
MIN_TEMPERATURE: Final = 0.0
|
||||||
@@ -31,6 +43,23 @@ MAX_MAX_TOKENS: Final = 4096
|
|||||||
MIN_REQUEST_INTERVAL: Final = 0.1
|
MIN_REQUEST_INTERVAL: Final = 0.1
|
||||||
MIN_TIMEOUT: Final = 5
|
MIN_TIMEOUT: Final = 5
|
||||||
MAX_TIMEOUT: Final = 120
|
MAX_TIMEOUT: Final = 120
|
||||||
|
MAX_PROMPT_LENGTH: Final = 1000
|
||||||
|
MAX_HISTORY_LIMIT: Final = 100
|
||||||
|
|
||||||
|
# API constants
|
||||||
|
API_VERSION: Final = "v1"
|
||||||
|
API_MODELS_PATH: Final = "models"
|
||||||
|
API_CHAT_PATH: Final = "chat/completions"
|
||||||
|
API_TIMEOUT: Final = 30
|
||||||
|
API_RETRY_COUNT: Final = 3
|
||||||
|
API_BACKOFF_FACTOR: Final = 1.5
|
||||||
|
API_MAX_RETRIES: Final = 3
|
||||||
|
|
||||||
|
# History constants
|
||||||
|
HISTORY_FILTER_MODEL: Final = "filter_model"
|
||||||
|
HISTORY_FILTER_DATE: Final = "start_date"
|
||||||
|
HISTORY_SORT_ORDER: Final = "sort_order"
|
||||||
|
HISTORY_INCLUDE_METADATA: Final = "include_metadata"
|
||||||
|
|
||||||
# Service names
|
# Service names
|
||||||
SERVICE_ASK_QUESTION: Final = "ask_question"
|
SERVICE_ASK_QUESTION: Final = "ask_question"
|
||||||
@@ -58,6 +87,13 @@ ATTR_QUEUE_SIZE: Final = "queue_size"
|
|||||||
ATTR_API_STATUS: Final = "api_status"
|
ATTR_API_STATUS: Final = "api_status"
|
||||||
ATTR_ERROR_COUNT: Final = "error_count"
|
ATTR_ERROR_COUNT: Final = "error_count"
|
||||||
ATTR_LAST_ERROR: Final = "last_error"
|
ATTR_LAST_ERROR: Final = "last_error"
|
||||||
|
ATTR_API_VERSION: Final = "api_version"
|
||||||
|
ATTR_ENDPOINT_STATUS: Final = "endpoint_status"
|
||||||
|
ATTR_REQUEST_COUNT: Final = "request_count"
|
||||||
|
ATTR_TOKENS_USED: Final = "tokens_used"
|
||||||
|
ATTR_RETRY_COUNT: Final = "retry_count"
|
||||||
|
ATTR_QUEUE_POSITION: Final = "queue_position"
|
||||||
|
ATTR_ESTIMATED_WAIT: Final = "estimated_wait"
|
||||||
|
|
||||||
# Error messages
|
# Error messages
|
||||||
ERROR_INVALID_API_KEY: Final = "invalid_api_key"
|
ERROR_INVALID_API_KEY: Final = "invalid_api_key"
|
||||||
@@ -70,6 +106,8 @@ ERROR_API_ERROR: Final = "api_error"
|
|||||||
ERROR_TIMEOUT: Final = "timeout_error"
|
ERROR_TIMEOUT: Final = "timeout_error"
|
||||||
ERROR_QUEUE_FULL: Final = "queue_full"
|
ERROR_QUEUE_FULL: Final = "queue_full"
|
||||||
ERROR_INVALID_PROMPT: Final = "invalid_prompt"
|
ERROR_INVALID_PROMPT: Final = "invalid_prompt"
|
||||||
|
ERROR_INVALID_PARAMETERS: Final = "invalid_parameters"
|
||||||
|
ERROR_SERVICE_UNAVAILABLE: Final = "service_unavailable"
|
||||||
|
|
||||||
# Configuration descriptions
|
# Configuration descriptions
|
||||||
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
|
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
|
||||||
@@ -92,12 +130,18 @@ ATTR_QUEUE_SIZE_DESCRIPTION: Final = "Current size of question queue"
|
|||||||
ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status"
|
ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status"
|
||||||
ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors"
|
ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors"
|
||||||
ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message"
|
ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message"
|
||||||
|
ATTR_API_VERSION_DESCRIPTION: Final = "Current API version"
|
||||||
|
ATTR_ENDPOINT_STATUS_DESCRIPTION: Final = "Current endpoint status"
|
||||||
|
ATTR_REQUEST_COUNT_DESCRIPTION: Final = "Total number of API requests"
|
||||||
|
ATTR_TOKENS_USED_DESCRIPTION: Final = "Total tokens used"
|
||||||
|
|
||||||
# Entity attributes
|
# Entity attributes
|
||||||
ENTITY_NAME: Final = "HA Text AI"
|
ENTITY_NAME: Final = "HA Text AI"
|
||||||
ENTITY_ICON: Final = "mdi:robot"
|
ENTITY_ICON: Final = "mdi:robot"
|
||||||
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
|
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
|
||||||
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
|
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
|
||||||
|
ENTITY_ICON_OFFLINE: Final = "mdi:robot-off"
|
||||||
|
ENTITY_ICON_QUEUE: Final = "mdi:robot-confused"
|
||||||
|
|
||||||
# Translation keys
|
# Translation keys
|
||||||
TRANSLATION_KEY_CONFIG: Final = "config"
|
TRANSLATION_KEY_CONFIG: Final = "config"
|
||||||
@@ -113,6 +157,10 @@ STATE_ERROR: Final = "error"
|
|||||||
STATE_DISCONNECTED: Final = "disconnected"
|
STATE_DISCONNECTED: Final = "disconnected"
|
||||||
STATE_RATE_LIMITED: Final = "rate_limited"
|
STATE_RATE_LIMITED: Final = "rate_limited"
|
||||||
STATE_INITIALIZING: Final = "initializing"
|
STATE_INITIALIZING: Final = "initializing"
|
||||||
|
STATE_MAINTENANCE: Final = "maintenance"
|
||||||
|
STATE_RETRYING: Final = "retrying"
|
||||||
|
STATE_QUEUED: Final = "queued"
|
||||||
|
STATE_UPDATING: Final = "updating"
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOGGER_NAME: Final = "custom_components.ha_text_ai"
|
LOGGER_NAME: Final = "custom_components.ha_text_ai"
|
||||||
@@ -122,11 +170,6 @@ LOG_LEVEL_DEFAULT: Final = "INFO"
|
|||||||
QUEUE_TIMEOUT: Final = 5
|
QUEUE_TIMEOUT: Final = 5
|
||||||
QUEUE_MAX_SIZE: Final = 100
|
QUEUE_MAX_SIZE: Final = 100
|
||||||
|
|
||||||
# API constants
|
|
||||||
API_TIMEOUT: Final = 30
|
|
||||||
API_RETRY_COUNT: Final = 3
|
|
||||||
API_BACKOFF_FACTOR: Final = 1.5
|
|
||||||
|
|
||||||
# Service schema constants
|
# Service schema constants
|
||||||
SCHEMA_QUESTION: Final = "question"
|
SCHEMA_QUESTION: Final = "question"
|
||||||
SCHEMA_MODEL: Final = "model"
|
SCHEMA_MODEL: Final = "model"
|
||||||
|
|||||||
@@ -2,15 +2,24 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, List
|
||||||
|
import time
|
||||||
|
|
||||||
from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError
|
from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
import async_timeout
|
import async_timeout
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
MAX_QUEUE_SIZE,
|
||||||
|
MAX_RETRIES,
|
||||||
|
RETRY_DELAY,
|
||||||
|
SUPPORTED_MODELS,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,8 +36,21 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
request_interval: float,
|
request_interval: float,
|
||||||
session: Optional[Any] = None,
|
session: Optional[Any] = None,
|
||||||
|
is_anthropic: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize coordinator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hass: HomeAssistant instance
|
||||||
|
api_key: API key for the service
|
||||||
|
endpoint: API endpoint URL
|
||||||
|
model: Model name to use
|
||||||
|
temperature: Temperature parameter for generation
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
request_interval: Interval between requests
|
||||||
|
session: Optional session object
|
||||||
|
is_anthropic: Whether to use Anthropic API
|
||||||
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
@@ -43,18 +65,36 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.temperature = float(temperature)
|
self.temperature = float(temperature)
|
||||||
self.max_tokens = int(max_tokens)
|
self.max_tokens = int(max_tokens)
|
||||||
self._question_queue = asyncio.Queue()
|
self._question_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
|
||||||
self._responses: Dict[str, Any] = {}
|
self._responses: Dict[str, Any] = {}
|
||||||
self.system_prompt: Optional[str] = None
|
self.system_prompt: Optional[str] = None
|
||||||
self._is_ready = False
|
self._is_ready = False
|
||||||
|
self._is_processing = False
|
||||||
|
self._is_rate_limited = False
|
||||||
|
self._is_maintenance = False
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self._MAX_ERRORS = 3
|
self._MAX_ERRORS = 3
|
||||||
|
self._request_count = 0
|
||||||
|
self._tokens_used = 0
|
||||||
|
self._api_version = "v1"
|
||||||
|
self._endpoint_status = "disconnected"
|
||||||
|
self._performance_metrics: Dict[str, Any] = {
|
||||||
|
"avg_response_time": 0,
|
||||||
|
"total_errors": 0,
|
||||||
|
"success_rate": 100,
|
||||||
|
"requests_per_minute": 0,
|
||||||
|
}
|
||||||
|
self._last_request_time = 0
|
||||||
|
self._is_anthropic = is_anthropic
|
||||||
|
|
||||||
self.client = AsyncOpenAI(
|
if is_anthropic:
|
||||||
api_key=self.api_key,
|
self.client = AsyncAnthropic(api_key=self.api_key)
|
||||||
base_url=self.endpoint,
|
else:
|
||||||
http_client=session,
|
self.client = AsyncOpenAI(
|
||||||
)
|
api_key=self.api_key,
|
||||||
|
base_url=self.endpoint,
|
||||||
|
http_client=session,
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None:
|
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None:
|
||||||
"""Validate initialization parameters."""
|
"""Validate initialization parameters."""
|
||||||
@@ -66,13 +106,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
raise ValueError("Max tokens must be a positive integer")
|
raise ValueError("Max tokens must be a positive integer")
|
||||||
|
|
||||||
async def _async_update_data(self) -> Dict[str, Any]:
|
async def _async_update_data(self) -> Dict[str, Any]:
|
||||||
"""Update data via OpenAI API."""
|
"""Update data via API."""
|
||||||
if self._question_queue.empty():
|
if self._question_queue.empty():
|
||||||
return self._responses
|
return self._responses
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(30):
|
async with async_timeout.timeout(DEFAULT_TIMEOUT):
|
||||||
question_data = await self._question_queue.get()
|
self._is_processing = True
|
||||||
|
priority, question_data = await self._question_queue.get()
|
||||||
question = question_data["question"]
|
question = question_data["question"]
|
||||||
params = question_data["params"]
|
params = question_data["params"]
|
||||||
|
|
||||||
@@ -85,6 +126,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
system_prompt=params.get("system_prompt")
|
system_prompt=params.get("system_prompt")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._update_metrics(response_data)
|
||||||
self._responses[question] = {
|
self._responses[question] = {
|
||||||
"question": question,
|
"question": question,
|
||||||
"response": response_data["response"],
|
"response": response_data["response"],
|
||||||
@@ -93,15 +135,30 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
"model": response_data["model"],
|
"model": response_data["model"],
|
||||||
"temperature": params.get("temperature", self.temperature),
|
"temperature": params.get("temperature", self.temperature),
|
||||||
"max_tokens": params.get("max_tokens", self.max_tokens),
|
"max_tokens": params.get("max_tokens", self.max_tokens),
|
||||||
"response_time": response_data.get("response_time")
|
"response_time": response_data.get("response_time"),
|
||||||
|
"tokens": response_data.get("tokens", 0),
|
||||||
|
"priority": priority
|
||||||
}
|
}
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self._is_ready = True
|
self._is_ready = True
|
||||||
|
self._endpoint_status = "connected"
|
||||||
|
self._request_count += 1
|
||||||
|
self._tokens_used += response_data.get("tokens", 0)
|
||||||
|
self._last_request_time = time.time()
|
||||||
|
|
||||||
|
# Fire event for successful response
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_response_received", {
|
||||||
|
"question": question,
|
||||||
|
"model": response_data["model"],
|
||||||
|
"tokens": response_data.get("tokens", 0)
|
||||||
|
})
|
||||||
|
|
||||||
_LOGGER.debug("Response received for question: %s", question)
|
_LOGGER.debug("Response received for question: %s", question)
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
self._handle_api_error(question, err)
|
await self._handle_api_error(question, err)
|
||||||
finally:
|
finally:
|
||||||
|
self._is_processing = False
|
||||||
self._question_queue.task_done()
|
self._question_queue.task_done()
|
||||||
|
|
||||||
return self._responses
|
return self._responses
|
||||||
@@ -111,18 +168,29 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
await self._handle_timeout_error()
|
await self._handle_timeout_error()
|
||||||
return self._responses
|
return self._responses
|
||||||
|
|
||||||
def _handle_api_error(self, question: str, error: Exception) -> None:
|
async def _handle_api_error(self, question: str, error: Exception) -> None:
|
||||||
"""Handle API errors."""
|
"""Handle API errors with retry logic."""
|
||||||
self._error_count += 1
|
self._error_count += 1
|
||||||
|
self._performance_metrics["total_errors"] += 1
|
||||||
error_msg = str(error)
|
error_msg = str(error)
|
||||||
|
|
||||||
if isinstance(error, AuthenticationError):
|
if isinstance(error, AuthenticationError):
|
||||||
error_msg = "Authentication failed - invalid API key"
|
error_msg = "Authentication failed - invalid API key"
|
||||||
self._is_ready = False
|
self._is_ready = False
|
||||||
|
self._endpoint_status = "auth_error"
|
||||||
elif isinstance(error, RateLimitError):
|
elif isinstance(error, RateLimitError):
|
||||||
error_msg = "Rate limit exceeded"
|
error_msg = "Rate limit exceeded"
|
||||||
|
self._is_rate_limited = True
|
||||||
|
self._endpoint_status = "rate_limited"
|
||||||
|
# Implement exponential backoff
|
||||||
|
await asyncio.sleep(RETRY_DELAY * (2 ** (self._error_count - 1)))
|
||||||
elif isinstance(error, APIError):
|
elif isinstance(error, APIError):
|
||||||
|
if "maintenance" in str(error).lower():
|
||||||
|
self._is_maintenance = True
|
||||||
|
self._endpoint_status = "maintenance"
|
||||||
error_msg = f"API error: {error}"
|
error_msg = f"API error: {error}"
|
||||||
|
else:
|
||||||
|
self._endpoint_status = "error"
|
||||||
|
|
||||||
self._responses[question] = {
|
self._responses[question] = {
|
||||||
"question": question,
|
"question": question,
|
||||||
@@ -134,6 +202,13 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
"max_tokens": self.max_tokens
|
"max_tokens": self.max_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fire error event
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_error_occurred", {
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"error_message": error_msg,
|
||||||
|
"question": question
|
||||||
|
})
|
||||||
|
|
||||||
_LOGGER.error("API error (%s): %s", type(error).__name__, error_msg)
|
_LOGGER.error("API error (%s): %s", type(error).__name__, error_msg)
|
||||||
|
|
||||||
if self._error_count >= self._MAX_ERRORS:
|
if self._error_count >= self._MAX_ERRORS:
|
||||||
@@ -142,16 +217,26 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
self._error_count
|
self._error_count
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_timeout_error(self) -> None:
|
def _update_metrics(self, response_data: Dict[str, Any]) -> None:
|
||||||
"""Handle timeout errors."""
|
"""Update performance metrics."""
|
||||||
self._error_count += 1
|
response_time = response_data.get("response_time", 0)
|
||||||
if not self._question_queue.empty():
|
current_avg = self._performance_metrics["avg_response_time"]
|
||||||
try:
|
self._performance_metrics["avg_response_time"] = (
|
||||||
while not self._question_queue.empty():
|
(current_avg * self._request_count + response_time) /
|
||||||
self._question_queue.get_nowait()
|
(self._request_count + 1)
|
||||||
self._question_queue.task_done()
|
)
|
||||||
except Exception as err:
|
|
||||||
_LOGGER.error("Error clearing question queue: %s", err)
|
total_requests = self._request_count + 1
|
||||||
|
self._performance_metrics["success_rate"] = (
|
||||||
|
(total_requests - self._performance_metrics["total_errors"]) /
|
||||||
|
total_requests * 100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate requests per minute
|
||||||
|
if self._last_request_time:
|
||||||
|
time_diff = time.time() - self._last_request_time
|
||||||
|
if time_diff > 0:
|
||||||
|
self._performance_metrics["requests_per_minute"] = 60 / time_diff
|
||||||
|
|
||||||
async def _make_api_call(
|
async def _make_api_call(
|
||||||
self,
|
self,
|
||||||
@@ -161,26 +246,23 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
system_prompt: Optional[str] = None
|
system_prompt: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Make API call to OpenAI."""
|
"""Make API call to the selected service."""
|
||||||
try:
|
try:
|
||||||
messages = []
|
|
||||||
current_system_prompt = system_prompt if system_prompt is not None else self.system_prompt
|
|
||||||
if current_system_prompt:
|
|
||||||
messages.append({"role": "system", "content": current_system_prompt})
|
|
||||||
messages.append({"role": "user", "content": question})
|
|
||||||
|
|
||||||
start_time = dt_util.utcnow()
|
start_time = dt_util.utcnow()
|
||||||
completion = await self.client.chat.completions.create(
|
|
||||||
model=model or self.model,
|
if self._is_anthropic:
|
||||||
messages=messages,
|
response = await self._make_anthropic_call(
|
||||||
temperature=temperature if temperature is not None else self.temperature,
|
question, model, temperature, max_tokens, system_prompt
|
||||||
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
|
)
|
||||||
)
|
else:
|
||||||
|
response = await self._make_openai_call(
|
||||||
|
question, model, temperature, max_tokens, system_prompt
|
||||||
|
)
|
||||||
|
|
||||||
response_time = (dt_util.utcnow() - start_time).total_seconds()
|
response_time = (dt_util.utcnow() - start_time).total_seconds()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"response": completion.choices[0].message.content,
|
**response,
|
||||||
"model": completion.model,
|
|
||||||
"response_time": response_time
|
"response_time": response_time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,15 +270,70 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
_LOGGER.error("Error in API call: %s", err)
|
_LOGGER.error("Error in API call: %s", err)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _make_anthropic_call(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
model: Optional[str],
|
||||||
|
temperature: Optional[float],
|
||||||
|
max_tokens: Optional[int],
|
||||||
|
system_prompt: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to Anthropic."""
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
completion = await self.client.messages.create(
|
||||||
|
model=model or self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature if temperature is not None else self.temperature,
|
||||||
|
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": completion.content[0].text,
|
||||||
|
"model": completion.model,
|
||||||
|
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _make_openai_call(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
model: Optional[str],
|
||||||
|
temperature: Optional[float],
|
||||||
|
max_tokens: Optional[int],
|
||||||
|
system_prompt: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to OpenAI."""
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
completion = await self.client.chat.completions.create(
|
||||||
|
model=model or self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature if temperature is not None else self.temperature,
|
||||||
|
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": completion.choices[0].message.content,
|
||||||
|
"model": completion.model,
|
||||||
|
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
|
||||||
|
}
|
||||||
|
|
||||||
async def async_ask_question(
|
async def async_ask_question(
|
||||||
self,
|
self,
|
||||||
question: str,
|
question: str,
|
||||||
system_prompt: Optional[str] = None,
|
system_prompt: Optional[str] = None,
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
max_tokens: Optional[int] = None
|
max_tokens: Optional[int] = None,
|
||||||
|
priority: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add question to queue with optional parameters."""
|
"""Add question to queue with priority support."""
|
||||||
if not self._is_ready and self._error_count >= self._MAX_ERRORS:
|
if not self._is_ready and self._error_count >= self._MAX_ERRORS:
|
||||||
_LOGGER.warning("Coordinator is not ready due to previous errors")
|
_LOGGER.warning("Coordinator is not ready due to previous errors")
|
||||||
return
|
return
|
||||||
@@ -211,32 +348,271 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await self._question_queue.put(question_data)
|
# Priority: 0 for high priority, 1 for normal
|
||||||
await self.async_refresh()
|
priority_level = 0 if priority else 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._question_queue.put((priority_level, question_data))
|
||||||
|
await self.async_refresh()
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
_LOGGER.error("Question queue is full. Try again later.")
|
||||||
|
raise RuntimeError("Queue is full")
|
||||||
|
|
||||||
async def async_shutdown(self) -> None:
|
async def async_shutdown(self) -> None:
|
||||||
"""Shutdown the coordinator."""
|
"""Shutdown the coordinator."""
|
||||||
try:
|
try:
|
||||||
while not self._question_queue.empty():
|
while not self._question_queue.empty():
|
||||||
self._question_queue.get_nowait()
|
try:
|
||||||
self._question_queue.task_done()
|
self._question_queue.get_nowait()
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
if hasattr(self.client, 'close'):
|
||||||
|
await self.client.close()
|
||||||
|
|
||||||
await self.client.close()
|
|
||||||
self._is_ready = False
|
self._is_ready = False
|
||||||
|
self._endpoint_status = "disconnected"
|
||||||
|
|
||||||
|
# Final metrics update
|
||||||
|
self._update_final_metrics()
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error during shutdown: %s", err)
|
_LOGGER.error("Error during shutdown: %s", err)
|
||||||
|
|
||||||
|
def _update_final_metrics(self) -> None:
|
||||||
|
"""Update final metrics before shutdown."""
|
||||||
|
if self._request_count > 0:
|
||||||
|
self._performance_metrics["final_success_rate"] = (
|
||||||
|
(self._request_count - self._performance_metrics["total_errors"]) /
|
||||||
|
self._request_count * 100
|
||||||
|
)
|
||||||
|
self._performance_metrics["total_requests"] = self._request_count
|
||||||
|
self._performance_metrics["total_tokens"] = self._tokens_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def performance_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Return current performance metrics."""
|
||||||
|
return self._performance_metrics
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_size(self) -> int:
|
||||||
|
"""Return current queue size."""
|
||||||
|
return self._question_queue.qsize()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_queue_full(self) -> bool:
|
||||||
|
"""Return whether queue is full."""
|
||||||
|
return self._question_queue.full()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""Return if coordinator is ready."""
|
"""Return if coordinator is ready."""
|
||||||
return self._is_ready
|
return self._is_ready and self._error_count < self._MAX_ERRORS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_processing(self) -> bool:
|
||||||
|
"""Return if coordinator is processing."""
|
||||||
|
return self._is_processing
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_rate_limited(self) -> bool:
|
||||||
|
"""Return if coordinator is rate limited."""
|
||||||
|
return self._is_rate_limited
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_maintenance(self) -> bool:
|
||||||
|
"""Return if API is in maintenance."""
|
||||||
|
return self._is_maintenance
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error_count(self) -> int:
|
def error_count(self) -> int:
|
||||||
"""Return current error count."""
|
"""Return current error count."""
|
||||||
return self._error_count
|
return self._error_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_count(self) -> int:
|
||||||
|
"""Return total request count."""
|
||||||
|
return self._request_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tokens_used(self) -> int:
|
||||||
|
"""Return total tokens used."""
|
||||||
|
return self._tokens_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_version(self) -> str:
|
||||||
|
"""Return API version."""
|
||||||
|
return self._api_version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoint_status(self) -> str:
|
||||||
|
"""Return endpoint status."""
|
||||||
|
return self._endpoint_status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def responses(self) -> Dict[str, Any]:
|
||||||
|
"""Return all responses."""
|
||||||
|
return self._responses
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_response(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the last response."""
|
||||||
|
if not self._responses:
|
||||||
|
return None
|
||||||
|
return next(iter(self._responses.values()))
|
||||||
|
|
||||||
def reset_error_count(self) -> None:
|
def reset_error_count(self) -> None:
|
||||||
"""Reset error counter."""
|
"""Reset error counter."""
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
|
self._is_rate_limited = False
|
||||||
|
self._is_maintenance = False
|
||||||
|
if not self._is_ready:
|
||||||
|
self._is_ready = True
|
||||||
|
self._endpoint_status = "connected"
|
||||||
|
|
||||||
|
async def clear_queue(self) -> None:
|
||||||
|
"""Clear the question queue."""
|
||||||
|
try:
|
||||||
|
while not self._question_queue.empty():
|
||||||
|
try:
|
||||||
|
self._question_queue.get_nowait()
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error clearing queue: %s", err)
|
||||||
|
|
||||||
|
async def clear_history(self) -> None:
|
||||||
|
"""Clear response history."""
|
||||||
|
self._responses.clear()
|
||||||
|
await self.async_refresh()
|
||||||
|
|
||||||
|
def get_response(self, question: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get specific response by question."""
|
||||||
|
return self._responses.get(question)
|
||||||
|
|
||||||
|
def get_recent_responses(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""Get most recent responses."""
|
||||||
|
return list(sorted(
|
||||||
|
self._responses.values(),
|
||||||
|
key=lambda x: x["timestamp"],
|
||||||
|
reverse=True
|
||||||
|
))[:limit]
|
||||||
|
|
||||||
|
async def retry_failed_requests(self) -> None:
|
||||||
|
"""Retry failed requests."""
|
||||||
|
failed_requests = [
|
||||||
|
(q, r) for q, r in self._responses.items()
|
||||||
|
if r.get("error") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
for question, response in failed_requests:
|
||||||
|
await self.async_ask_question(
|
||||||
|
question,
|
||||||
|
system_prompt=response.get("system_prompt"),
|
||||||
|
model=response.get("model"),
|
||||||
|
temperature=response.get("temperature"),
|
||||||
|
max_tokens=response.get("max_tokens"),
|
||||||
|
priority=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_system_prompt(self, new_prompt: str) -> None:
|
||||||
|
"""Update system prompt."""
|
||||||
|
self.system_prompt = new_prompt
|
||||||
|
_LOGGER.info("System prompt updated")
|
||||||
|
|
||||||
|
async def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""Perform health check."""
|
||||||
|
health_status = {
|
||||||
|
"is_ready": self.is_ready,
|
||||||
|
"is_processing": self.is_processing,
|
||||||
|
"is_rate_limited": self.is_rate_limited,
|
||||||
|
"is_maintenance": self.is_maintenance,
|
||||||
|
"error_count": self.error_count,
|
||||||
|
"endpoint_status": self.endpoint_status,
|
||||||
|
"queue_size": self.queue_size,
|
||||||
|
"request_count": self.request_count,
|
||||||
|
"tokens_used": self.tokens_used,
|
||||||
|
"performance_metrics": self.performance_metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
return health_status
|
||||||
|
|
||||||
|
async def _handle_timeout_error(self) -> None:
|
||||||
|
"""Handle timeout errors."""
|
||||||
|
self._error_count += 1
|
||||||
|
self._endpoint_status = "timeout"
|
||||||
|
self._performance_metrics["total_errors"] += 1
|
||||||
|
|
||||||
|
if not self._question_queue.empty():
|
||||||
|
await self.clear_queue()
|
||||||
|
|
||||||
|
# Fire timeout event
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_timeout_error", {
|
||||||
|
"error_count": self._error_count,
|
||||||
|
"endpoint_status": self._endpoint_status
|
||||||
|
})
|
||||||
|
|
||||||
|
def export_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Export all metrics and statistics."""
|
||||||
|
return {
|
||||||
|
"performance": self._performance_metrics,
|
||||||
|
"requests": {
|
||||||
|
"total": self._request_count,
|
||||||
|
"successful": self._request_count - self._performance_metrics["total_errors"],
|
||||||
|
"failed": self._performance_metrics["total_errors"]
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"total_used": self._tokens_used,
|
||||||
|
"average_per_request": self._tokens_used / self._request_count if self._request_count > 0 else 0
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"is_ready": self.is_ready,
|
||||||
|
"endpoint_status": self._endpoint_status,
|
||||||
|
"error_count": self._error_count
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"size": self.queue_size,
|
||||||
|
"is_full": self.is_queue_full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def validate_model(self, model: str) -> bool:
|
||||||
|
"""Validate if model is supported."""
|
||||||
|
return model in SUPPORTED_MODELS
|
||||||
|
|
||||||
|
async def estimate_tokens(self, text: str) -> int:
|
||||||
|
"""Estimate token count for text."""
|
||||||
|
# Простая оценка: примерно 4 символа на токен
|
||||||
|
return len(text) // 4
|
||||||
|
|
||||||
|
def get_rate_limit_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get rate limit information."""
|
||||||
|
return {
|
||||||
|
"is_rate_limited": self._is_rate_limited,
|
||||||
|
"retry_after": RETRY_DELAY * (2 ** (self._error_count - 1)) if self._is_rate_limited else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def optimize_queue(self) -> None:
|
||||||
|
"""Optimize queue by removing duplicate requests."""
|
||||||
|
if self._question_queue.empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
seen_questions = set()
|
||||||
|
optimized_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
|
||||||
|
|
||||||
|
while not self._question_queue.empty():
|
||||||
|
try:
|
||||||
|
priority, question_data = self._question_queue.get_nowait()
|
||||||
|
question = question_data["question"]
|
||||||
|
|
||||||
|
if question not in seen_questions:
|
||||||
|
seen_questions.add(question)
|
||||||
|
await optimized_queue.put((priority, question_data))
|
||||||
|
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._question_queue = optimized_queue
|
||||||
|
|||||||
@@ -4,11 +4,39 @@
|
|||||||
"codeowners": ["@smkrv"],
|
"codeowners": ["@smkrv"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"documentation": "https://github.com/smkrv/ha-text-ai/wiki",
|
"documentation": "https://github.com/smkrv/ha-text-ai",
|
||||||
|
"homepage": "https://github.com/smkrv/ha-text-ai",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
||||||
"requirements": ["openai>=1.0.0"],
|
"requirements": [
|
||||||
|
"openai>=1.12.0",
|
||||||
|
"anthropic>=0.8.0",
|
||||||
|
"aiohttp>=3.8.0",
|
||||||
|
"async-timeout>=4.0.0"
|
||||||
|
],
|
||||||
|
"version": "2.0.0",
|
||||||
|
"homeassistant": "2024.1.0",
|
||||||
|
"quality_scale": "silver",
|
||||||
|
"integration_type": "service",
|
||||||
|
"translations": [
|
||||||
|
"en"
|
||||||
|
],
|
||||||
|
"zeroconf": [],
|
||||||
"ssdp": [],
|
"ssdp": [],
|
||||||
"version": "1.1.0",
|
"usb": [],
|
||||||
"zeroconf": []
|
"bluetooth": [],
|
||||||
|
"mqtt": [],
|
||||||
|
"loggers": ["custom_components.ha_text_ai"],
|
||||||
|
"after_dependencies": ["http"],
|
||||||
|
"title": "Text AI Assistant",
|
||||||
|
"description": "AI-powered text assistant supporting multiple models including GPT and Claude",
|
||||||
|
"render_readme": true,
|
||||||
|
"tags": [
|
||||||
|
"ai",
|
||||||
|
"automation",
|
||||||
|
"chat",
|
||||||
|
"assistant",
|
||||||
|
"openai",
|
||||||
|
"claude"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ from .const import (
|
|||||||
ATTR_ERROR_COUNT,
|
ATTR_ERROR_COUNT,
|
||||||
ATTR_LAST_ERROR,
|
ATTR_LAST_ERROR,
|
||||||
ATTR_RESPONSE_TIME,
|
ATTR_RESPONSE_TIME,
|
||||||
|
ATTR_API_VERSION,
|
||||||
|
ATTR_ENDPOINT_STATUS,
|
||||||
|
ATTR_REQUEST_COUNT,
|
||||||
|
ATTR_TOKENS_USED,
|
||||||
ENTITY_ICON,
|
ENTITY_ICON,
|
||||||
ENTITY_ICON_ERROR,
|
ENTITY_ICON_ERROR,
|
||||||
ENTITY_ICON_PROCESSING,
|
ENTITY_ICON_PROCESSING,
|
||||||
@@ -39,6 +43,7 @@ from .const import (
|
|||||||
STATE_DISCONNECTED,
|
STATE_DISCONNECTED,
|
||||||
STATE_RATE_LIMITED,
|
STATE_RATE_LIMITED,
|
||||||
STATE_INITIALIZING,
|
STATE_INITIALIZING,
|
||||||
|
STATE_MAINTENANCE,
|
||||||
)
|
)
|
||||||
from .coordinator import HATextAICoordinator
|
from .coordinator import HATextAICoordinator
|
||||||
|
|
||||||
@@ -53,6 +58,7 @@ async def async_setup_entry(
|
|||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities([HATextAISensor(coordinator, entry)], True)
|
async_add_entities([HATextAISensor(coordinator, entry)], True)
|
||||||
|
|
||||||
|
|
||||||
class HATextAISensor(CoordinatorEntity, SensorEntity):
|
class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||||
"""HA text AI Sensor."""
|
"""HA text AI Sensor."""
|
||||||
|
|
||||||
@@ -74,6 +80,13 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._state = STATE_INITIALIZING
|
self._state = STATE_INITIALIZING
|
||||||
|
self._attr_device_info = {
|
||||||
|
"identifiers": {(DOMAIN, self._attr_unique_id)},
|
||||||
|
"name": "HA Text AI",
|
||||||
|
"manufacturer": "Community",
|
||||||
|
"model": coordinator.model,
|
||||||
|
"sw_version": coordinator.api_version,
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
@@ -114,6 +127,10 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
ATTR_API_STATUS: self._state,
|
ATTR_API_STATUS: self._state,
|
||||||
ATTR_ERROR_COUNT: self._error_count,
|
ATTR_ERROR_COUNT: self._error_count,
|
||||||
ATTR_LAST_ERROR: self._last_error,
|
ATTR_LAST_ERROR: self._last_error,
|
||||||
|
ATTR_API_VERSION: self.coordinator.api_version,
|
||||||
|
ATTR_ENDPOINT_STATUS: self.coordinator.endpoint_status,
|
||||||
|
ATTR_REQUEST_COUNT: self.coordinator.request_count,
|
||||||
|
ATTR_TOKENS_USED: self.coordinator.tokens_used,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
@@ -186,11 +203,23 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
try:
|
try:
|
||||||
if self.coordinator.data:
|
if self.coordinator.data:
|
||||||
if self.coordinator._is_ready:
|
if self.coordinator._is_ready:
|
||||||
self._state = STATE_READY
|
if self.coordinator._is_processing:
|
||||||
|
self._state = STATE_PROCESSING
|
||||||
|
elif self.coordinator._is_rate_limited:
|
||||||
|
self._state = STATE_RATE_LIMITED
|
||||||
|
elif self.coordinator._is_maintenance:
|
||||||
|
self._state = STATE_MAINTENANCE
|
||||||
|
else:
|
||||||
|
self._state = STATE_READY
|
||||||
else:
|
else:
|
||||||
self._state = STATE_DISCONNECTED
|
self._state = STATE_DISCONNECTED
|
||||||
else:
|
else:
|
||||||
self._state = STATE_DISCONNECTED
|
self._state = STATE_DISCONNECTED
|
||||||
|
|
||||||
|
# Обновляем счетчик ошибок только если статус изменился на ошибку
|
||||||
|
if self._state == STATE_ERROR:
|
||||||
|
self._error_count += 1
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error handling update: %s", err, exc_info=True)
|
_LOGGER.error("Error handling update: %s", err, exc_info=True)
|
||||||
self._error_count += 1
|
self._error_count += 1
|
||||||
@@ -198,3 +227,9 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
self._state = STATE_ERROR
|
self._state = STATE_ERROR
|
||||||
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_reset_error_count(self) -> None:
|
||||||
|
"""Reset the error counter."""
|
||||||
|
self._error_count = 0
|
||||||
|
self._last_error = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ ask_question:
|
|||||||
Send a question to the AI model and receive a detailed response.
|
Send a question to the AI model and receive a detailed response.
|
||||||
The response will be stored in the conversation history and can be retrieved later.
|
The response will be stored in the conversation history and can be retrieved later.
|
||||||
Response time may vary based on model selection and server load.
|
Response time may vary based on model selection and server load.
|
||||||
|
Supports various AI models with different capabilities and pricing.
|
||||||
fields:
|
fields:
|
||||||
question:
|
question:
|
||||||
name: Question
|
name: Question
|
||||||
@@ -11,6 +12,7 @@ ask_question:
|
|||||||
Your question or prompt for the AI assistant. Be specific and clear for better results.
|
Your question or prompt for the AI assistant. Be specific and clear for better results.
|
||||||
You can ask about home automation, technical advice, or general questions.
|
You can ask about home automation, technical advice, or general questions.
|
||||||
For complex queries, consider breaking them into smaller parts.
|
For complex queries, consider breaking them into smaller parts.
|
||||||
|
The system will maintain conversation context for follow-up questions.
|
||||||
required: true
|
required: true
|
||||||
example: |
|
example: |
|
||||||
What automations would you recommend for a smart kitchen?
|
What automations would you recommend for a smart kitchen?
|
||||||
@@ -28,8 +30,10 @@ ask_question:
|
|||||||
name: Model
|
name: Model
|
||||||
description: >-
|
description: >-
|
||||||
Select an AI model to use (optional, overrides default setting).
|
Select an AI model to use (optional, overrides default setting).
|
||||||
You can choose from predefined models or enter your own model identifier.
|
Different models have different capabilities, context limits, and response characteristics:
|
||||||
Different models have different capabilities and token limits.
|
- GPT-3.5 Turbo: Fast, efficient, good for most tasks
|
||||||
|
- GPT-4: More capable, better reasoning, slower
|
||||||
|
- Claude-3: Advanced capabilities, longer context
|
||||||
Note: More capable models may have longer response times and higher API costs.
|
Note: More capable models may have longer response times and higher API costs.
|
||||||
required: false
|
required: false
|
||||||
example: "gpt-3.5-turbo"
|
example: "gpt-3.5-turbo"
|
||||||
@@ -48,14 +52,16 @@ ask_question:
|
|||||||
value: "gpt-4-32k"
|
value: "gpt-4-32k"
|
||||||
- label: "GPT-4 Turbo (Latest)"
|
- label: "GPT-4 Turbo (Latest)"
|
||||||
value: "gpt-4-1106-preview"
|
value: "gpt-4-1106-preview"
|
||||||
- label: "Claude-3 Sonnet"
|
- label: "Claude-3 Sonnet (Balanced)"
|
||||||
value: "claude-3-sonnet"
|
value: "claude-3-sonnet"
|
||||||
|
- label: "Claude-3 Opus (Most Advanced)"
|
||||||
|
value: "claude-3-opus"
|
||||||
mode: dropdown
|
mode: dropdown
|
||||||
|
|
||||||
temperature:
|
temperature:
|
||||||
name: Temperature
|
name: Temperature
|
||||||
description: >-
|
description: >-
|
||||||
Controls response creativity (0-2):
|
Controls response creativity (0.0-2.0):
|
||||||
0.0-0.3: Focused, consistent responses (best for technical/factual queries)
|
0.0-0.3: Focused, consistent responses (best for technical/factual queries)
|
||||||
0.4-0.7: Balanced responses (recommended for most uses)
|
0.4-0.7: Balanced responses (recommended for most uses)
|
||||||
0.8-2.0: More creative, varied responses (best for brainstorming)
|
0.8-2.0: More creative, varied responses (best for brainstorming)
|
||||||
@@ -73,12 +79,12 @@ ask_question:
|
|||||||
max_tokens:
|
max_tokens:
|
||||||
name: Max Tokens
|
name: Max Tokens
|
||||||
description: >-
|
description: >-
|
||||||
Maximum length of the response. Higher values allow longer responses but use more API tokens.
|
Maximum length of the response (1-4096 tokens).
|
||||||
Recommended ranges:
|
Recommended ranges:
|
||||||
- Short responses (256-512): Quick answers, status updates
|
- Short (256-512): Quick answers, status updates
|
||||||
- Medium responses (512-1024): Detailed explanations, instructions
|
- Medium (512-1024): Detailed explanations
|
||||||
- Long responses (1024-4096): Complex analysis, multiple examples
|
- Long (1024-4096): Complex analysis
|
||||||
Note: Actual response length may be shorter based on content.
|
Note: Token limits vary by model. Actual length may be shorter.
|
||||||
required: false
|
required: false
|
||||||
default: 1000
|
default: 1000
|
||||||
selector:
|
selector:
|
||||||
@@ -93,8 +99,9 @@ ask_question:
|
|||||||
description: >-
|
description: >-
|
||||||
Optional system prompt to set context for this specific question.
|
Optional system prompt to set context for this specific question.
|
||||||
This will temporarily override the default system prompt.
|
This will temporarily override the default system prompt.
|
||||||
|
Use this to specify expertise areas, response format, or special instructions.
|
||||||
required: false
|
required: false
|
||||||
example: "You are a home automation expert focused on energy efficiency"
|
example: "You are a home automation expert focused on energy efficiency and security"
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
multiline: true
|
multiline: true
|
||||||
@@ -104,14 +111,18 @@ clear_history:
|
|||||||
description: >-
|
description: >-
|
||||||
Delete all stored questions and responses from the conversation history.
|
Delete all stored questions and responses from the conversation history.
|
||||||
This action cannot be undone. Consider using 'get_history' first if you need to backup the data.
|
This action cannot be undone. Consider using 'get_history' first if you need to backup the data.
|
||||||
System prompt settings will be preserved.
|
System prompt settings and configuration will be preserved.
|
||||||
fields: {}
|
fields: {}
|
||||||
|
|
||||||
get_history:
|
get_history:
|
||||||
name: Get History
|
name: Get History
|
||||||
description: >-
|
description: >-
|
||||||
Retrieve recent conversation history, including questions, responses, and timestamps.
|
Retrieve recent conversation history, including:
|
||||||
Results are ordered from newest to oldest and include metadata like model used and response times.
|
- Questions and responses
|
||||||
|
- Timestamps and response times
|
||||||
|
- Models used and token counts
|
||||||
|
- Temperature and other settings
|
||||||
|
Results are ordered from newest to oldest.
|
||||||
fields:
|
fields:
|
||||||
limit:
|
limit:
|
||||||
name: Limit
|
name: Limit
|
||||||
@@ -144,22 +155,41 @@ get_history:
|
|||||||
value: "gpt-3.5-turbo"
|
value: "gpt-3.5-turbo"
|
||||||
- label: "GPT-4"
|
- label: "GPT-4"
|
||||||
value: "gpt-4"
|
value: "gpt-4"
|
||||||
|
- label: "GPT-4 Turbo"
|
||||||
|
value: "gpt-4-1106-preview"
|
||||||
- label: "Claude-3 Sonnet"
|
- label: "Claude-3 Sonnet"
|
||||||
value: "claude-3-sonnet"
|
value: "claude-3-sonnet"
|
||||||
|
- label: "Claude-3 Opus"
|
||||||
|
value: "claude-3-opus"
|
||||||
mode: dropdown
|
mode: dropdown
|
||||||
|
|
||||||
|
start_date:
|
||||||
|
name: Start Date
|
||||||
|
description: >-
|
||||||
|
Optional start date for filtering history.
|
||||||
|
Format: YYYY-MM-DD
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
type: date
|
||||||
|
|
||||||
set_system_prompt:
|
set_system_prompt:
|
||||||
name: Set System Prompt
|
name: Set System Prompt
|
||||||
description: >-
|
description: >-
|
||||||
Set default system behavior instructions for all future conversations.
|
Set default system behavior instructions for all future conversations.
|
||||||
This defines how the AI should behave and respond to questions.
|
This defines how the AI should behave and respond to questions.
|
||||||
|
The prompt will be used for all models unless overridden per question.
|
||||||
fields:
|
fields:
|
||||||
prompt:
|
prompt:
|
||||||
name: System Prompt
|
name: System Prompt
|
||||||
description: >-
|
description: >-
|
||||||
Instructions that define how the AI should behave and respond.
|
Instructions that define how the AI should behave and respond.
|
||||||
Be specific about the desired expertise, tone, and format of responses.
|
Be specific about:
|
||||||
Maximum length: 1000 characters.
|
- Desired expertise and knowledge areas
|
||||||
|
- Response tone and style
|
||||||
|
- Output format preferences
|
||||||
|
- Special handling instructions
|
||||||
|
Maximum length: 1000 characters
|
||||||
required: true
|
required: true
|
||||||
example: |
|
example: |
|
||||||
You are a home automation expert assistant. Focus on:
|
You are a home automation expert assistant. Focus on:
|
||||||
|
|||||||
@@ -3,36 +3,48 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Set up HA Text AI",
|
"title": "Set up HA Text AI",
|
||||||
"description": "Configure your AI integration (OpenAI, Anthropic, etc.)",
|
"description": "Configure your AI integration for various providers (OpenAI, Anthropic, etc.). Different models have different capabilities and pricing.",
|
||||||
"data": {
|
"data": {
|
||||||
"api_key": "Your API key",
|
"api_key": "API key for authentication (required)",
|
||||||
"model": "AI model to use for responses (e.g., gpt-3.5-turbo, claude-3-sonnet)",
|
"model": "AI model to use (e.g., gpt-3.5-turbo, gpt-4, claude-3-sonnet, claude-3-opus)",
|
||||||
"temperature": "Temperature for response generation (0-2)",
|
"temperature": "Response creativity (0-2, lower = more focused and consistent)",
|
||||||
"max_tokens": "Maximum tokens in response (1-4096)",
|
"max_tokens": "Maximum response length (1-4096 tokens)",
|
||||||
"api_endpoint": "API endpoint URL (optional, for custom endpoints)",
|
"api_endpoint": "Custom API endpoint URL (default varies by provider)",
|
||||||
"request_interval": "Minimum time between API requests (seconds)",
|
"request_interval": "Minimum time between requests in seconds (min: 0.1)",
|
||||||
"system_prompt": "Default system prompt for all conversations"
|
"system_prompt": "Default instructions for AI behavior and expertise"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_api_key": "Invalid API key - please check your credentials",
|
"invalid_auth": "Authentication failed - check your API key",
|
||||||
"cannot_connect": "Failed to connect to API - check endpoint and network",
|
"invalid_api_key": "Invalid API key - please verify your credentials",
|
||||||
"invalid_model": "Selected model is not available or invalid",
|
"cannot_connect": "Connection failed - check endpoint and network status",
|
||||||
"rate_limit": "API rate limit exceeded - please wait",
|
"invalid_model": "Model unavailable or not supported by the API",
|
||||||
"unknown": "Unexpected error occurred - check logs for details"
|
"rate_limit": "Rate limit exceeded - please reduce request frequency",
|
||||||
|
"context_length": "Input exceeds maximum context length for model",
|
||||||
|
"api_error": "API service error - check provider status",
|
||||||
|
"timeout": "Request timeout - server not responding",
|
||||||
|
"queue_full": "Request queue full - try again later",
|
||||||
|
"invalid_prompt": "Invalid system prompt format or length",
|
||||||
|
"invalid_url_format": "Invalid API endpoint URL format",
|
||||||
|
"invalid_input": "Invalid configuration parameters",
|
||||||
|
"unknown": "Unexpected error - check logs for details"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "HA Text AI Options",
|
"title": "HA Text AI Settings",
|
||||||
|
"description": "Adjust your AI integration parameters and behavior",
|
||||||
"data": {
|
"data": {
|
||||||
"model": "AI model selection",
|
"model": "Select AI model for responses (capabilities vary)",
|
||||||
"temperature": "Response temperature (0-2)",
|
"temperature": "Response creativity (0-2, affects variation)",
|
||||||
"max_tokens": "Maximum response length",
|
"max_tokens": "Maximum response length in tokens",
|
||||||
"request_interval": "Time between requests (seconds)",
|
"request_interval": "Minimum seconds between requests (rate limiting)",
|
||||||
"system_prompt": "Default system instructions"
|
"system_prompt": "Default AI behavior and expertise instructions",
|
||||||
|
"timeout": "Request timeout in seconds (default: 30)",
|
||||||
|
"retry_count": "Number of retry attempts for failed requests",
|
||||||
|
"queue_size": "Maximum pending requests in queue"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,55 +52,94 @@
|
|||||||
"services": {
|
"services": {
|
||||||
"ask_question": {
|
"ask_question": {
|
||||||
"name": "Ask Question",
|
"name": "Ask Question",
|
||||||
"description": "Send a question to the AI model and receive a response",
|
"description": "Send a question to the AI model and get a detailed response. Supports context awareness for follow-up questions.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"question": {
|
"question": {
|
||||||
"name": "Question",
|
"name": "Question",
|
||||||
"description": "Your question or prompt for the AI"
|
"description": "Your question or prompt for the AI model. Be specific for better results."
|
||||||
},
|
},
|
||||||
"system_prompt": {
|
"system_prompt": {
|
||||||
"name": "System Prompt",
|
"name": "System Prompt",
|
||||||
"description": "Optional system prompt to override default for this question"
|
"description": "Optional behavior instructions for this specific question"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"name": "Model",
|
"name": "Model",
|
||||||
"description": "Optional AI model to use for this question"
|
"description": "Optional specific AI model for this request (overrides default)"
|
||||||
},
|
},
|
||||||
"temperature": {
|
"temperature": {
|
||||||
"name": "Temperature",
|
"name": "Temperature",
|
||||||
"description": "Optional temperature setting for this question (0-2)"
|
"description": "Optional creativity setting (0-2, affects response variation)"
|
||||||
},
|
},
|
||||||
"max_tokens": {
|
"max_tokens": {
|
||||||
"name": "Max Tokens",
|
"name": "Max Tokens",
|
||||||
"description": "Optional maximum token limit for this response"
|
"description": "Optional maximum response length (1-4096 tokens)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clear_history": {
|
"clear_history": {
|
||||||
"name": "Clear History",
|
"name": "Clear History",
|
||||||
"description": "Delete all stored conversation history"
|
"description": "Delete all stored conversation history, responses, and metadata"
|
||||||
},
|
},
|
||||||
"get_history": {
|
"get_history": {
|
||||||
"name": "Get History",
|
"name": "Get History",
|
||||||
"description": "Retrieve conversation history with metadata",
|
"description": "Retrieve conversation history with detailed metadata including timestamps",
|
||||||
"fields": {
|
"fields": {
|
||||||
"limit": {
|
"limit": {
|
||||||
"name": "Limit",
|
"name": "Limit",
|
||||||
"description": "Maximum number of conversations to return"
|
"description": "Maximum number of conversations to return (1-100)"
|
||||||
},
|
},
|
||||||
"filter_model": {
|
"filter_model": {
|
||||||
"name": "Filter Model",
|
"name": "Filter Model",
|
||||||
"description": "Optional filter to show only specific model responses"
|
"description": "Show only responses from a specific model"
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"name": "Start Date",
|
||||||
|
"description": "Filter conversations from this date (YYYY-MM-DD)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"set_system_prompt": {
|
"set_system_prompt": {
|
||||||
"name": "Set System Prompt",
|
"name": "Set System Prompt",
|
||||||
"description": "Set default system behavior instructions",
|
"description": "Update default AI behavior and expertise instructions",
|
||||||
"fields": {
|
"fields": {
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"name": "System Prompt",
|
"name": "System Prompt",
|
||||||
"description": "Instructions defining AI behavior and response style"
|
"description": "Instructions for AI behavior, expertise, and response style"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready for requests",
|
||||||
|
"processing": "Processing request",
|
||||||
|
"error": "Error occurred",
|
||||||
|
"disconnected": "API disconnected",
|
||||||
|
"rate_limited": "Rate limit reached",
|
||||||
|
"initializing": "Starting up",
|
||||||
|
"retrying": "Retrying request",
|
||||||
|
"queued": "Request queued"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"status": {
|
||||||
|
"name": "AI Status",
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready",
|
||||||
|
"processing": "Processing",
|
||||||
|
"error": "Error",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"rate_limited": "Rate Limited",
|
||||||
|
"initializing": "Initializing",
|
||||||
|
"retrying": "Retrying",
|
||||||
|
"queued": "Queued"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_response": {
|
||||||
|
"name": "Last Response",
|
||||||
|
"state": {
|
||||||
|
"success": "Success",
|
||||||
|
"error": "Error",
|
||||||
|
"timeout": "Timeout"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"title": "Настройка HA Text AI",
|
|
||||||
"description": "Настройка интеграции с AI-сервисами (OpenAI, Anthropic и др.)",
|
|
||||||
"data": {
|
|
||||||
"api_key": "Ваш API-ключ",
|
|
||||||
"model": "Модель AI для ответов (например, gpt-3.5-turbo, claude-3-sonnet)",
|
|
||||||
"temperature": "Температура генерации ответов (0-2)",
|
|
||||||
"max_tokens": "Максимальное количество токенов в ответе (1-4096)",
|
|
||||||
"api_endpoint": "URL конечной точки API (необязательно, для пользовательских endpoint)",
|
|
||||||
"request_interval": "Минимальный интервал между запросами к API (секунды)",
|
|
||||||
"system_prompt": "Системная инструкция по умолчанию для всех диалогов"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"invalid_api_key": "Недействительный API-ключ - проверьте учетные данные",
|
|
||||||
"cannot_connect": "Не удалось подключиться к API - проверьте endpoint и сеть",
|
|
||||||
"invalid_model": "Выбранная модель недоступна или некорректна",
|
|
||||||
"rate_limit": "Превышен лимит запросов API - пожалуйста, подождите",
|
|
||||||
"unknown": "Произошла непредвиденная ошибка - проверьте логи"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"step": {
|
|
||||||
"init": {
|
|
||||||
"title": "Настройки HA Text AI",
|
|
||||||
"data": {
|
|
||||||
"model": "Выбор модели AI",
|
|
||||||
"temperature": "Температура ответов (0-2)",
|
|
||||||
"max_tokens": "Максимальная длина ответа",
|
|
||||||
"request_interval": "Интервал между запросами (секунды)",
|
|
||||||
"system_prompt": "Системные инструкции по умолчанию"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"ask_question": {
|
|
||||||
"name": "Задать вопрос",
|
|
||||||
"description": "Отправить вопрос модели AI и получить ответ",
|
|
||||||
"fields": {
|
|
||||||
"question": {
|
|
||||||
"name": "Вопрос",
|
|
||||||
"description": "Ваш вопрос или запрос для AI"
|
|
||||||
},
|
|
||||||
"system_prompt": {
|
|
||||||
"name": "Системная инструкция",
|
|
||||||
"description": "Необязательная системная инструкция, заменяющая инструкцию по умолчанию для этого вопроса"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"name": "Модель",
|
|
||||||
"description": "Необязательная модель AI для этого вопроса"
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"name": "Температура",
|
|
||||||
"description": "Необязательная настройка температуры для этого вопроса (0-2)"
|
|
||||||
},
|
|
||||||
"max_tokens": {
|
|
||||||
"name": "Макс. токенов",
|
|
||||||
"description": "Необязательное ограничение количества токенов для этого ответа"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"clear_history": {
|
|
||||||
"name": "Очистить историю",
|
|
||||||
"description": "Удалить всю сохраненную историю диалогов"
|
|
||||||
},
|
|
||||||
"get_history": {
|
|
||||||
"name": "Получить историю",
|
|
||||||
"description": "Получить историю диалогов с метаданными",
|
|
||||||
"fields": {
|
|
||||||
"limit": {
|
|
||||||
"name": "Лимит",
|
|
||||||
"description": "Максимальное количество диалогов для возврата"
|
|
||||||
},
|
|
||||||
"filter_model": {
|
|
||||||
"name": "Фильтр по модели",
|
|
||||||
"description": "Необязательный фильтр для показа ответов только от определенной модели"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"set_system_prompt": {
|
|
||||||
"name": "Установить системную инструкцию",
|
|
||||||
"description": "Задать системные инструкции по умолчанию",
|
|
||||||
"fields": {
|
|
||||||
"prompt": {
|
|
||||||
"name": "Системная инструкция",
|
|
||||||
"description": "Инструкции, определяющие поведение AI и стиль ответов"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
"domains": ["sensor"],
|
"domains": ["sensor"],
|
||||||
"homeassistant": "2024.11.0",
|
"homeassistant": "2024.11.0",
|
||||||
"icon": "mdi:brain",
|
"icon": "mdi:brain",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"documentation": "https://github.com/smkrv/ha-text-ai"
|
"documentation": "https://github.com/smkrv/ha-text-ai"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user