Release v2.0.0

This commit is contained in:
SMKRV
2024-11-22 01:34:47 +03:00
parent 9341b02f4b
commit 53b15fa74c
8 changed files with 235 additions and 649 deletions
+27 -31
View File
@@ -24,13 +24,15 @@ from .const import (
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_API_ENDPOINT, CONF_API_ENDPOINT,
CONF_REQUEST_INTERVAL, CONF_REQUEST_INTERVAL,
CONF_API_PROVIDER,
API_PROVIDER_OPENAI,
API_PROVIDER_ANTHROPIC,
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_TEMPERATURE, DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS, DEFAULT_MAX_TOKENS,
DEFAULT_API_ENDPOINT, DEFAULT_OPENAI_ENDPOINT,
DEFAULT_ANTHROPIC_ENDPOINT,
DEFAULT_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL,
API_VERSION,
API_MODELS_PATH,
API_TIMEOUT, API_TIMEOUT,
API_RETRY_COUNT, API_RETRY_COUNT,
API_BACKOFF_FACTOR, API_BACKOFF_FACTOR,
@@ -131,27 +133,17 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
return True return True
async def async_check_api(session, endpoint: str, headers: dict, is_anthropic: bool = False) -> bool: async def async_check_api(session, endpoint: str, headers: dict, provider: str) -> bool:
"""Check API availability for different providers.""" """Check API availability for different providers."""
try: try:
# Определяем, является ли это VSE GPT endpoint if provider == API_PROVIDER_ANTHROPIC:
is_vsegpt = "vsegpt" in endpoint.lower()
if is_vsegpt:
# Для VSE GPT используем специальный endpoint
check_url = f"{endpoint.rstrip('/')}/v1/models"
headers["Authorization"] = f"Bearer {headers.get('x-api-key', '')}"
elif is_anthropic:
check_url = f"{endpoint}/v1/models" check_url = f"{endpoint}/v1/models"
else: else: # OpenAI
check_url = f"{endpoint}/{API_VERSION}/{API_MODELS_PATH}" check_url = f"{endpoint}/models"
async with timeout(API_TIMEOUT): async with timeout(API_TIMEOUT):
async with session.get(check_url, headers=headers) as response: async with session.get(check_url, headers=headers) as response:
if response.status == 200: if response.status in [200, 404]:
return True
elif response.status == 404 and is_vsegpt:
# VSE GPT может возвращать 404 для /models endpoint
return True return True
elif response.status == 401: elif response.status == 401:
raise ConfigEntryNotReady("Invalid API key") raise ConfigEntryNotReady("Invalid API key")
@@ -168,35 +160,39 @@ async def async_check_api(session, endpoint: str, headers: dict, is_anthropic: b
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA Text AI from a config entry.""" """Set up HA Text AI from a config entry."""
try: try:
# Проверка наличия провайдера
if CONF_API_PROVIDER not in entry.data:
_LOGGER.error("API provider not specified")
raise ConfigEntryNotReady("API provider is required")
session = aiohttp_client.async_get_clientsession(hass) session = aiohttp_client.async_get_clientsession(hass)
# Determine API type based on model and endpoint # Получаем провайдера из конфигурации
api_provider = entry.data.get(CONF_API_PROVIDER)
model = entry.data.get(CONF_MODEL, DEFAULT_MODEL) model = entry.data.get(CONF_MODEL, DEFAULT_MODEL)
endpoint = entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT).rstrip('/') endpoint = entry.data.get(CONF_API_ENDPOINT,
DEFAULT_OPENAI_ENDPOINT if api_provider == API_PROVIDER_OPENAI
else DEFAULT_ANTHROPIC_ENDPOINT).rstrip('/')
api_key = entry.data[CONF_API_KEY] api_key = entry.data[CONF_API_KEY]
is_vsegpt = "vsegpt" in endpoint.lower() # Определяем параметры подключения в зависимости от провайдера
is_anthropic = any(m in model.lower() for m in ["claude", "anthropic"]) or is_vsegpt is_anthropic = api_provider == API_PROVIDER_ANTHROPIC
# Configure headers based on API type # Конфигурация headers
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json" "Accept": "application/json"
} }
if is_vsegpt: if is_anthropic:
headers["Authorization"] = f"Bearer {api_key}"
if is_anthropic:
headers["anthropic-version"] = "2023-06-01"
elif is_anthropic:
headers["x-api-key"] = api_key headers["x-api-key"] = api_key
headers["anthropic-version"] = "2023-06-01" headers["anthropic-version"] = "2023-06-01"
else: else: # OpenAI
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
# Check API with retries # Проверка API с повторами
for attempt in range(API_RETRY_COUNT): for attempt in range(API_RETRY_COUNT):
if await async_check_api(session, endpoint, headers, is_anthropic): if await async_check_api(session, endpoint, headers, api_provider):
break break
if attempt < API_RETRY_COUNT - 1: if attempt < API_RETRY_COUNT - 1:
delay = API_BACKOFF_FACTOR * (2 ** attempt) delay = API_BACKOFF_FACTOR * (2 ** attempt)
+150 -363
View File
@@ -1,14 +1,10 @@
"""Config flow for HA text AI integration.""" """Config flow for HA text AI integration."""
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional
import voluptuous as vol import voluptuous as vol
import asyncio
import aiohttp import aiohttp
from async_timeout import timeout
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
import homeassistant.helpers.config_validation as cv
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -20,403 +16,194 @@ from .const import (
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_API_ENDPOINT, CONF_API_ENDPOINT,
CONF_REQUEST_INTERVAL, CONF_REQUEST_INTERVAL,
CONF_API_PROVIDER,
API_PROVIDER_OPENAI,
API_PROVIDER_ANTHROPIC,
API_PROVIDERS,
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_TEMPERATURE, DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS, DEFAULT_MAX_TOKENS,
DEFAULT_API_ENDPOINT,
DEFAULT_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL,
DEFAULT_OPENAI_ENDPOINT,
DEFAULT_ANTHROPIC_ENDPOINT,
MIN_TEMPERATURE, MIN_TEMPERATURE,
MAX_TEMPERATURE, MAX_TEMPERATURE,
MIN_MAX_TOKENS, MIN_MAX_TOKENS,
MAX_MAX_TOKENS, MAX_MAX_TOKENS,
MIN_REQUEST_INTERVAL, 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({
vol.Required(CONF_API_KEY): str,
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str,
vol.Optional(CONF_TEMPERATURE, default=str(DEFAULT_TEMPERATURE)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(CONF_MAX_TOKENS, default=str(DEFAULT_MAX_TOKENS)): vol.All(
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(CONF_REQUEST_INTERVAL, default=str(DEFAULT_REQUEST_INTERVAL)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
})
async def async_step_user(
self,
user_input: Optional[Dict[str, Any]] = None
) -> FlowResult:
"""Handle the initial step."""
errors: Dict[str, str] = {}
if user_input is not None:
try:
_LOGGER.debug("Received user input: %s", user_input)
# Преобразование строковых значений в числовые
try:
temperature = float(user_input[CONF_TEMPERATURE])
max_tokens = int(user_input[CONF_MAX_TOKENS])
request_interval = float(user_input[CONF_REQUEST_INTERVAL])
# Обновляем значения в user_input
validated_input = {
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_MODEL: user_input[CONF_MODEL],
CONF_TEMPERATURE: temperature,
CONF_MAX_TOKENS: max_tokens,
CONF_API_ENDPOINT: user_input[CONF_API_ENDPOINT],
CONF_REQUEST_INTERVAL: request_interval
}
_LOGGER.debug("Converted values: %s", validated_input)
# Проверка диапазонов
if not MIN_TEMPERATURE <= temperature <= MAX_TEMPERATURE:
_LOGGER.error("Invalid temperature: %f", temperature)
errors["base"] = "invalid_temperature"
elif not MIN_MAX_TOKENS <= max_tokens <= MAX_MAX_TOKENS:
_LOGGER.error("Invalid max_tokens: %d", max_tokens)
errors["base"] = "invalid_max_tokens"
elif request_interval < MIN_REQUEST_INTERVAL:
_LOGGER.error("Invalid request_interval: %f", request_interval)
errors["base"] = "invalid_request_interval"
else:
# Проверка URL
endpoint = validated_input[CONF_API_ENDPOINT]
try:
result = urlparse(endpoint)
if not all([result.scheme, result.netloc]):
errors["base"] = "invalid_url_format"
else:
is_valid, error_code, available_models = await validate_api_connection(
self.hass,
validated_input[CONF_API_KEY],
endpoint,
validated_input[CONF_MODEL]
)
if is_valid:
await self.async_set_unique_id(validated_input[CONF_API_KEY])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="HA Text AI",
data=validated_input
)
errors["base"] = error_code
if error_code == ERROR_INVALID_MODEL:
_LOGGER.warning(
"Selected model %s not found in available models: %s",
validated_input[CONF_MODEL],
", ".join(available_models)
)
except Exception as e:
_LOGGER.error("URL parsing error: %s", str(e))
errors["base"] = "invalid_url_format"
except ValueError as ve:
_LOGGER.error("Value conversion error: %s", str(ve))
errors["base"] = "invalid_input_format"
except Exception as err:
_LOGGER.error("Unexpected error: %s", str(err))
errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"default_model": DEFAULT_MODEL,
"default_endpoint": DEFAULT_API_ENDPOINT,
}
)
async def validate_api_connection(
hass,
api_key: str,
endpoint: str,
model: str,
retry_count: int = 3,
retry_delay: float = 1.0
) -> Tuple[bool, str, list]:
"""Validate API connection with retry logic."""
session = async_get_clientsession(hass)
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
if "vsegpt" in endpoint.lower():
headers["Authorization"] = f"Bearer {api_key}"
base_url = endpoint.rstrip('/')
if not base_url.endswith("/v1"):
base_url = f"{base_url}/v1"
models_url = f"{base_url}/models"
_LOGGER.debug("Using VSE GPT endpoint: %s", models_url)
supported_models = [
"anthropic/claude-3-5-haiku",
"anthropic/claude-3.5-sonnet",
]
if model in supported_models:
return True, "", supported_models
elif any(m in model.lower() for m in ["claude", "anthropic"]):
headers["x-api-key"] = api_key
headers["anthropic-version"] = "2023-06-01"
base_url = endpoint.rstrip('/')
if not base_url.endswith("/v1"):
base_url = f"{base_url}/v1"
models_url = f"{base_url}/models"
_LOGGER.debug("Using Anthropic endpoint: %s", models_url)
else:
headers["Authorization"] = f"Bearer {api_key}"
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("Using OpenAI endpoint: %s", models_url)
for attempt in range(retry_count):
try:
async with timeout(10):
if "vsegpt" in endpoint.lower():
test_url = f"{base_url}/models"
async with session.get(test_url, headers=headers) as response:
if response.status == 404:
if model.startswith("anthropic/"):
return True, "", [model]
elif response.status == 401:
return False, ERROR_INVALID_API_KEY, []
elif response.status == 429:
return False, ERROR_RATE_LIMIT, []
return True, "", [model]
async with session.get(models_url, headers=headers) as response:
_LOGGER.debug("API response status: %s", response.status)
if response.status == 200:
data = await response.json()
if any(m in model.lower() for m in ["claude", "anthropic"]):
model_ids = [m["id"] for m in data.get("models", [])]
if model.startswith("anthropic/"):
model = model.split("/")[1]
if model in model_ids or any(m.endswith(model) for m in model_ids):
return True, "", model_ids
else: # OpenAI format
model_ids = [m["id"] for m in data.get("data", [])]
if model in model_ids:
return True, "", model_ids
_LOGGER.warning(
"Model %s not found in available models: %s",
model,
", ".join(model_ids)
)
return False, ERROR_INVALID_MODEL, model_ids
elif response.status == 401:
_LOGGER.error("Authentication failed")
return False, ERROR_INVALID_API_KEY, []
elif response.status == 429:
_LOGGER.error("Rate limit exceeded")
if attempt < retry_count - 1:
await asyncio.sleep(retry_delay * (2 ** attempt))
continue
return False, ERROR_RATE_LIMIT, []
else:
response_text = await response.text()
_LOGGER.error(
"API error: %s - %s",
response.status,
response_text
)
if attempt < retry_count - 1:
await asyncio.sleep(retry_delay)
continue
return False, ERROR_API_ERROR, []
except asyncio.TimeoutError:
_LOGGER.warning(
"Timeout during API validation (attempt %d/%d)",
attempt + 1,
retry_count
)
if attempt < retry_count - 1:
await asyncio.sleep(retry_delay)
continue
return False, ERROR_TIMEOUT, []
except aiohttp.ClientError as err:
_LOGGER.error("Connection error: %s", str(err))
return False, ERROR_CANNOT_CONNECT, []
except Exception as err:
_LOGGER.exception("Unexpected error during validation: %s", str(err))
return False, ERROR_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."""
VERSION = 1 VERSION = 1
async def async_step_user( async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult:
self,
user_input: Optional[Dict[str, Any]] = None
) -> FlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors: Dict[str, str] = {} if user_input is None:
return self._show_provider_selection()
if user_input is not None: if "provider" in user_input:
try: return self._show_provider_config(user_input["provider"])
# Валидация значений через схему
validated_input = STEP_USER_DATA_SCHEMA(user_input)
endpoint = validated_input[CONF_API_ENDPOINT] return await self._process_configuration(user_input)
try:
result = urlparse(endpoint)
if not all([result.scheme, result.netloc]):
errors["base"] = "invalid_url_format"
else:
is_valid, error_code, available_models = await validate_api_connection(
self.hass,
validated_input[CONF_API_KEY],
endpoint,
validated_input[CONF_MODEL]
)
if is_valid: def _show_provider_selection(self):
await self.async_set_unique_id(validated_input[CONF_API_KEY]) """Show provider selection screen."""
self._abort_if_unique_id_configured() return self.async_show_form(
return self.async_create_entry( step_id="user",
title="HA Text AI", data_schema=vol.Schema({
data=validated_input vol.Required("provider"): vol.In(API_PROVIDERS)
) }),
errors["base"] = error_code description_placeholders={"providers": ", ".join(API_PROVIDERS)}
if error_code == ERROR_INVALID_MODEL: )
_LOGGER.warning(
"Selected model %s not found in available models: %s",
validated_input[CONF_MODEL],
", ".join(available_models)
)
except Exception as e:
_LOGGER.error("URL parsing error: %s", str(e))
errors["base"] = "invalid_url_format"
except vol.Invalid as err: def _show_provider_config(self, provider):
_LOGGER.error("Validation error: %s", str(err)) """Show configuration screen for selected provider."""
errors["base"] = "invalid_input" default_endpoint = DEFAULT_OPENAI_ENDPOINT if provider == API_PROVIDER_OPENAI else DEFAULT_ANTHROPIC_ENDPOINT
except Exception as err:
_LOGGER.error("Unexpected error: %s", str(err))
errors["base"] = "unknown"
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=vol.Schema({
errors=errors, vol.Required(CONF_API_PROVIDER): vol.In([provider]),
description_placeholders={ vol.Required(CONF_API_KEY): str,
"default_model": DEFAULT_MODEL, vol.Required(CONF_MODEL, default=DEFAULT_MODEL): str,
"default_endpoint": DEFAULT_API_ENDPOINT, vol.Optional(CONF_API_ENDPOINT, default=default_endpoint): str,
} vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
}),
description_placeholders={"provider": provider}
) )
@staticmethod async def _process_configuration(self, user_input):
@callback """Validate and process user configuration."""
def async_get_options_flow( errors = {}
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
try:
session = async_get_clientsession(self.hass)
# Minimal API key validation
headers = {
"Authorization": f"Bearer {user_input[CONF_API_KEY]}",
"Content-Type": "application/json"
}
# Adjust headers and endpoint based on provider
if user_input[CONF_API_PROVIDER] == API_PROVIDER_ANTHROPIC:
headers = {
"x-api-key": user_input[CONF_API_KEY],
"anthropic-version": "2023-06-01"
}
# Basic connection test
try:
async with session.get(
f"{user_input[CONF_API_ENDPOINT]}/models",
headers=headers
) as response:
if response.status not in [200, 404]:
errors["base"] = "cannot_connect"
except Exception as e:
_LOGGER.error(f"Connection test failed: {e}")
errors["base"] = "cannot_connect"
if not errors:
# Use a unique ID based on the API key
await self.async_set_unique_id(user_input[CONF_API_KEY])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"HA Text AI ({user_input[CONF_API_PROVIDER]})",
data=user_input
)
except Exception as e:
_LOGGER.error(f"Unexpected error: {e}")
errors["base"] = "unknown"
# Return to provider config, preserving previous input
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required(CONF_API_PROVIDER): vol.In([user_input[CONF_API_PROVIDER]]),
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_MODEL, default=user_input.get(CONF_MODEL, DEFAULT_MODEL)): str,
vol.Optional(CONF_API_ENDPOINT, default=user_input.get(CONF_API_ENDPOINT,
DEFAULT_OPENAI_ENDPOINT if user_input[CONF_API_PROVIDER] == API_PROVIDER_OPENAI
else DEFAULT_ANTHROPIC_ENDPOINT)): str,
vol.Optional(CONF_TEMPERATURE, default=user_input.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(CONF_MAX_TOKENS, default=user_input.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(CONF_REQUEST_INTERVAL, default=user_input.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
}),
description_placeholders={"provider": user_input[CONF_API_PROVIDER]},
errors=errors
)
@staticmethod
@callback
def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow:
"""Create the options flow."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for HA text AI.""" """Options flow handler."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None: def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self.config_entry = config_entry self.config_entry = config_entry
async def async_step_init( async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult:
self, """Manage the options."""
user_input: Optional[Dict[str, Any]] = None
) -> FlowResult:
"""Handle options flow."""
if user_input is not None: if user_input is not None:
try: return self.async_create_entry(title="", data=user_input)
# Validate using schema
options_schema = self._get_options_schema() current_data = self.config_entry.data
validated_input = options_schema(user_input)
return self.async_create_entry(title="", data=validated_input)
except vol.Invalid as err:
_LOGGER.error("Options validation error: %s", str(err))
return self.async_show_form(
step_id="init",
data_schema=self._get_options_schema(),
errors={"base": "invalid_input"}
)
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=self._get_options_schema(), data_schema=vol.Schema({
vol.Optional(
CONF_TEMPERATURE,
default=current_data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(
CONF_MAX_TOKENS,
default=current_data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS)
): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(
CONF_REQUEST_INTERVAL,
default=current_data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
),
})
) )
def _get_options_schema(self) -> vol.Schema:
"""Get options schema."""
return vol.Schema({
vol.Optional(
CONF_TEMPERATURE,
default=self.config_entry.options.get(
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
)
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(
CONF_MAX_TOKENS,
default=self.config_entry.options.get(
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
)
): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional(
CONF_REQUEST_INTERVAL,
default=self.config_entry.options.get(
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
)
): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL)
)
})
+24 -40
View File
@@ -8,6 +8,20 @@ from homeassistant.helpers import config_validation as cv
DOMAIN: Final = "ha_text_ai" DOMAIN: Final = "ha_text_ai"
PLATFORMS: Final = [Platform.SENSOR] PLATFORMS: Final = [Platform.SENSOR]
# New constants for providers
CONF_API_PROVIDER: Final = "api_provider"
API_PROVIDER_OPENAI: Final = "openai"
API_PROVIDER_ANTHROPIC: Final = "anthropic"
API_PROVIDERS: Final = [
API_PROVIDER_OPENAI,
API_PROVIDER_ANTHROPIC
]
# Default endpoints
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
DEFAULT_ANTHROPIC_ENDPOINT: Final = "https://api.anthropic.com/v1"
# Configuration constants # Configuration constants
CONF_MODEL: Final = "model" CONF_MODEL: Final = "model"
CONF_TEMPERATURE: Final = "temperature" CONF_TEMPERATURE: Final = "temperature"
@@ -15,29 +29,13 @@ 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 = [
"o1-preview",
"o1-mini",
"gpt-4o-mini",
"gpt-4o",
"claude-3-5-haiku",
"claude-3.5-sonnet",
"claude-3-haiku",
"anthropic/claude-3-5-haiku",
"anthropic/claude-3.5-sonnet",
]
# Default values # Default values
DEFAULT_MODEL: Final = "gpt-4o-mini" DEFAULT_MODEL: Final = "gpt-4o-mini"
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" DEFAULT_API_ENDPOINT: Final = DEFAULT_OPENAI_ENDPOINT
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_HISTORY_LIMIT: Final = 50
DEFAULT_RETRY_COUNT: Final = 3
# Parameter constraints # Parameter constraints
MIN_TEMPERATURE: Final = 0.0 MIN_TEMPERATURE: Final = 0.0
@@ -45,19 +43,11 @@ MAX_TEMPERATURE: Final = 2.0
MIN_MAX_TOKENS: Final = 1 MIN_MAX_TOKENS: Final = 1
MAX_MAX_TOKENS: Final = 4096 MAX_MAX_TOKENS: Final = 4096
MIN_REQUEST_INTERVAL: Final = 0.1 MIN_REQUEST_INTERVAL: Final = 0.1
MIN_TIMEOUT: Final = 5
MAX_TIMEOUT: Final = 120
MAX_PROMPT_LENGTH: Final = 1000
MAX_HISTORY_LIMIT: Final = 100
# API constants # API constants
API_VERSION: Final = "v1"
API_MODELS_PATH: Final = "models"
API_CHAT_PATH: Final = "chat/completions" API_CHAT_PATH: Final = "chat/completions"
API_TIMEOUT: Final = 30 API_TIMEOUT: Final = 30
API_RETRY_COUNT: Final = 3 API_RETRY_COUNT: Final = 3
API_BACKOFF_FACTOR: Final = 1.5
API_MAX_RETRIES: Final = 3
# History constants # History constants
HISTORY_FILTER_MODEL: Final = "filter_model" HISTORY_FILTER_MODEL: Final = "filter_model"
@@ -195,7 +185,7 @@ EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed"
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Required("question"): cv.string, vol.Required("question"): cv.string,
vol.Optional("system_prompt"): cv.string, vol.Optional("system_prompt"): cv.string,
vol.Optional("model"): vol.In(SUPPORTED_MODELS), vol.Optional("model"): cv.string,
vol.Optional("temperature"): vol.All( vol.Optional("temperature"): vol.All(
vol.Coerce(float), vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
@@ -207,7 +197,13 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Optional("priority"): cv.boolean, vol.Optional("priority"): cv.boolean,
}) })
SERVICE_SCHEMA_GET_HISTORY = { # set_system_prompt
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
vol.Required("prompt"): cv.string,
})
# get_history
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
vol.Optional("limit", default=10): vol.All( vol.Optional("limit", default=10): vol.All(
vol.Coerce(int), vol.Coerce(int),
vol.Range(min=1, max=100) vol.Range(min=1, max=100)
@@ -215,16 +211,4 @@ SERVICE_SCHEMA_GET_HISTORY = {
vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS), vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS),
vol.Optional("start_date"): cv.datetime, vol.Optional("start_date"): cv.datetime,
vol.Optional("include_metadata"): cv.boolean, vol.Optional("include_metadata"): cv.boolean,
} })
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = {
vol.Required("prompt"): cv.string,
}
# VSE GPT specific constants
VSE_GPT_ENDPOINT = "https://api.vsegpt.ru"
VSE_GPT_API_VERSION = "2023-06-01"
VSE_GPT_MODELS = [
"anthropic/claude-3-5-haiku",
"anthropic/claude-3.5-sonnet",
]
+1 -9
View File
@@ -22,7 +22,6 @@ from .const import (
QUEUE_MAX_SIZE, QUEUE_MAX_SIZE,
MAX_RETRIES, MAX_RETRIES,
RETRY_DELAY, RETRY_DELAY,
SUPPORTED_MODELS,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -41,13 +40,11 @@ class HATextAICoordinator(DataUpdateCoordinator):
session: Optional[Any] = None, session: Optional[Any] = None,
is_anthropic: bool = False, is_anthropic: bool = False,
) -> None: ) -> None:
request_interval = float(request_interval)
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=request_interval), update_interval=timedelta(seconds=float(request_interval)),
) )
"""Initialize coordinator.""" """Initialize coordinator."""
super().__init__( super().__init__(
@@ -684,13 +681,8 @@ class HATextAICoordinator(DataUpdateCoordinator):
} }
} }
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: async def estimate_tokens(self, text: str) -> int:
"""Estimate token count for text.""" """Estimate token count for text."""
# Простая оценка: примерно 4 символа на токен
return len(text) // 4 return len(text) // 4
def get_rate_limit_info(self) -> Dict[str, Any]: def get_rate_limit_info(self) -> Dict[str, Any]:
+2 -1
View File
@@ -23,5 +23,6 @@
"ssdp": [], "ssdp": [],
"usb": [], "usb": [],
"version": "2.0.0", "version": "2.0.0",
"zeroconf": [] "zeroconf": [],
"supported_api_providers": ["openai", "anthropic"]
} }
+4 -2
View File
@@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util
from .const import ( from .const import (
DOMAIN, DOMAIN,
CONF_API_PROVIDER, # Новый импорт
ATTR_QUESTION, ATTR_QUESTION,
ATTR_RESPONSE, ATTR_RESPONSE,
ATTR_LAST_UPDATED, ATTR_LAST_UPDATED,
@@ -80,11 +81,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
# Добавляем провайдера в device_info
self._attr_device_info = { self._attr_device_info = {
"identifiers": {(DOMAIN, self._attr_unique_id)}, "identifiers": {(DOMAIN, self._attr_unique_id)},
"name": "HA Text AI", "name": "HA Text AI",
"manufacturer": "Community", "manufacturer": "Community",
"model": coordinator.model, "model": f"{coordinator.model} ({self._config_entry.data.get(CONF_API_PROVIDER, 'Unknown')} provider)",
"sw_version": coordinator.api_version, "sw_version": coordinator.api_version,
} }
@@ -216,7 +219,6 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
else: else:
self._state = STATE_DISCONNECTED self._state = STATE_DISCONNECTED
# Обновляем счетчик ошибок только если статус изменился на ошибку
if self._state == STATE_ERROR: if self._state == STATE_ERROR:
self._error_count += 1 self._error_count += 1
+13 -122
View File
@@ -3,24 +3,11 @@ ask_question:
description: >- description: >-
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.
Supports various AI models with different capabilities and pricing.
fields: fields:
question: question:
name: Question name: Question
description: >- description: Your question or prompt for the AI assistant.
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.
For complex queries, consider breaking them into smaller parts.
The system will maintain conversation context for follow-up questions.
required: true required: true
example: |
What automations would you recommend for a smart kitchen?
Consider energy efficiency, convenience, and integration with:
- Smart lighting
- Appliance control
- Temperature monitoring
- Voice commands
selector: selector:
text: text:
multiline: true multiline: true
@@ -28,43 +15,15 @@ ask_question:
model: model:
name: Model name: Model
description: "Select an AI model to use (optional, overrides default setting)." description: "Select AI model to use (optional, overrides default setting)."
required: false required: false
example: "gpt-4o-mini"
default: "gpt-4o-mini"
selector: selector:
select: text:
custom_value: true multiline: false
options:
- label: "o1-preview"
value: "o1-preview"
- label: "o1-mini"
value: "o1-mini"
- label: "gpt-4o-mini"
value: "gpt-4o-mini"
- label: "gpt-4o"
value: "gpt-4o"
- label: "claude-3-5-haiku"
value: "claude-3-5-haiku"
- label: "claude-3.5-sonnet"
value: "claude-3.5-sonnet"
- label: "claude-3-haiku"
value: "claude-3-haiku"
- label: "anthropic/claude-3-5-haiku"
value: "anthropic/claude-3-5-haiku"
- label: "anthropic/claude-3.5-sonnet"
value: "anthropic/claude-3.5-sonnet"
mode: dropdown
temperature: temperature:
name: Temperature name: Temperature
description: >- description: Controls response creativity (0.0-2.0).
Controls response creativity (0.0-2.0):
0.0-0.3: Focused, consistent responses (best for technical/factual queries)
0.4-0.7: Balanced responses (recommended for most uses)
0.8-2.0: More creative, varied responses (best for brainstorming)
Note: Higher values may produce less predictable results.
required: false required: false
default: 0.7 default: 0.7
selector: selector:
@@ -73,17 +32,10 @@ ask_question:
max: 2.0 max: 2.0
step: 0.1 step: 0.1
mode: slider mode: slider
unit_of_measurement: ""
max_tokens: max_tokens:
name: Max Tokens name: Max Tokens
description: >- description: Maximum length of the response (1-4096 tokens).
Maximum length of the response (1-4096 tokens).
Recommended ranges:
- Short (256-512): Quick answers, status updates
- Medium (512-1024): Detailed explanations
- Long (1024-4096): Complex analysis
Note: Token limits vary by model. Actual length may be shorter.
required: false required: false
default: 1000 default: 1000
selector: selector:
@@ -95,40 +47,24 @@ ask_question:
system_prompt: system_prompt:
name: System Prompt name: System Prompt
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.
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 and security"
selector: selector:
text: text:
multiline: true multiline: true
clear_history: clear_history:
name: Clear History name: 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.
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.
Retrieve recent conversation history, including:
- 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
description: >- description: Number of most recent conversations to return (1-100).
Number of most recent conversations to return (1-100).
Higher values return more history but may take longer to process.
Default: 10 conversations
required: false required: false
default: 10 default: 10
selector: selector:
@@ -138,66 +74,21 @@ get_history:
step: 1 step: 1
mode: box mode: box
filter_model:
name: Filter by Model
description: >-
Only return conversations using a specific AI model.
Leave empty to show all models.
required: false
selector:
select:
custom_value: true
options:
- label: "All Models"
value: ""
- label: "GPT-3.5 Turbo"
value: "gpt-3.5-turbo"
- label: "GPT-4"
value: "gpt-4"
- label: "GPT-4 Turbo"
value: "gpt-4-1106-preview"
- label: "Claude-3 Sonnet"
value: "claude-3-sonnet"
- label: "Claude-3 Opus"
value: "claude-3-opus"
mode: dropdown
start_date: start_date:
name: Start Date name: Start Date
description: >- description: Optional start date for filtering history.
Optional start date for filtering history.
Format: YYYY-MM-DD
required: false required: false
selector: selector:
datetime: datetime:
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.
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:
- Desired expertise and knowledge areas
- Response tone and style
- Output format preferences
- Special handling instructions
Maximum length: 1000 characters
required: true required: true
example: |
You are a home automation expert assistant. Focus on:
1. Practical and efficient solutions
2. Energy-saving recommendations
3. Integration with popular smart home platforms
4. Security and privacy considerations
Provide detailed but concise responses with clear steps when applicable.
Format complex responses with bullet points or numbered lists.
Include warnings about potential risks or limitations.
selector: selector:
text: text:
multiline: true multiline: true
@@ -3,15 +3,15 @@
"step": { "step": {
"user": { "user": {
"title": "Set up HA Text AI", "title": "Set up HA Text AI",
"description": "Configure your AI integration for various providers (OpenAI, Anthropic, etc.). Different models have different capabilities and pricing.", "description": "Configure your AI integration with OpenAI or Anthropic providers.",
"data": { "data": {
"api_provider": "Select API Provider",
"api_key": "API key for authentication (required)", "api_key": "API key for authentication (required)",
"model": "AI model to use (e.g., gpt-3.5-turbo, gpt-4, claude-3-sonnet, claude-3-opus)", "model": "AI model to use (provider-specific)",
"temperature": "Response creativity (0-2, lower = more focused and consistent)", "temperature": "Response creativity (0-2, lower = more focused and consistent)",
"max_tokens": "Maximum response length (1-4096 tokens)", "max_tokens": "Maximum response length (1-4096 tokens)",
"api_endpoint": "Custom API endpoint URL (default varies by provider)", "api_endpoint": "Custom API endpoint URL (optional)",
"request_interval": "Minimum time between requests in seconds (min: 0.1)", "request_interval": "Minimum time between requests in seconds (min: 0.1)"
"system_prompt": "Default instructions for AI behavior and expertise"
} }
} }
}, },
@@ -19,13 +19,11 @@
"invalid_auth": "Authentication failed - check your API key", "invalid_auth": "Authentication failed - check your API key",
"invalid_api_key": "Invalid API key - please verify your credentials", "invalid_api_key": "Invalid API key - please verify your credentials",
"cannot_connect": "Connection failed - check endpoint and network status", "cannot_connect": "Connection failed - check endpoint and network status",
"invalid_model": "Model unavailable or not supported by the API", "invalid_model": "Model unavailable or not supported by the selected provider",
"rate_limit": "Rate limit exceeded - please reduce request frequency", "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", "api_error": "API service error - check provider status",
"timeout": "Request timeout - server not responding", "timeout": "Request timeout - server not responding",
"queue_full": "Request queue full - try again later", "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_url_format": "Invalid API endpoint URL format",
"invalid_input": "Invalid configuration parameters", "invalid_input": "Invalid configuration parameters",
"unknown": "Unexpected error - check logs for details" "unknown": "Unexpected error - check logs for details"
@@ -35,16 +33,12 @@
"step": { "step": {
"init": { "init": {
"title": "HA Text AI Settings", "title": "HA Text AI Settings",
"description": "Adjust your AI integration parameters and behavior", "description": "Adjust your AI integration parameters",
"data": { "data": {
"model": "Select AI model for responses (capabilities vary)", "model": "Select AI model (provider-specific)",
"temperature": "Response creativity (0-2, affects variation)", "temperature": "Response creativity (0-2)",
"max_tokens": "Maximum response length in tokens", "max_tokens": "Maximum response length in tokens",
"request_interval": "Minimum seconds between requests (rate limiting)", "request_interval": "Minimum seconds between requests"
"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"
} }
} }
} }
@@ -52,86 +46,25 @@
"services": { "services": {
"ask_question": { "ask_question": {
"name": "Ask Question", "name": "Ask Question",
"description": "Send a question to the AI model and get a detailed response. Supports context awareness for follow-up questions.", "description": "Send a question to the AI model and get a detailed response.",
"fields": { "fields": {
"question": { "question": {
"name": "Question", "name": "Question",
"description": "Your question or prompt for the AI model. Be specific for better results." "description": "Your question or prompt for the AI model"
},
"system_prompt": {
"name": "System Prompt",
"description": "Optional behavior instructions for this specific question"
}, },
"model": { "model": {
"name": "Model", "name": "Model",
"description": "Optional specific AI model for this request (overrides default)" "description": "Optional specific AI model for this request"
}, },
"temperature": { "temperature": {
"name": "Temperature", "name": "Temperature",
"description": "Optional creativity setting (0-2, affects response variation)" "description": "Optional creativity setting (0-2)"
}, },
"max_tokens": { "max_tokens": {
"name": "Max Tokens", "name": "Max Tokens",
"description": "Optional maximum response length (1-4096 tokens)" "description": "Optional maximum response length (1-4096 tokens)"
} }
} }
},
"clear_history": {
"name": "Clear History",
"description": "Delete all stored conversation history, responses, and metadata"
},
"get_history": {
"name": "Get History",
"description": "Retrieve conversation history with detailed metadata including timestamps",
"fields": {
"limit": {
"name": "Limit",
"description": "Maximum number of conversations to return (1-100)"
},
"filter_model": {
"name": "Filter Model",
"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": {
"name": "Set System Prompt",
"description": "Update default AI behavior and expertise instructions",
"fields": {
"prompt": {
"name": "System Prompt",
"description": "Instructions for AI behavior, expertise, and response style"
}
}
}
},
"entity": {
"sensor": {
"status": {
"name": "AI Status",
"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"
}
},
"last_response": {
"name": "Last Response",
"state": {
"success": "Success",
"error": "Error",
"timeout": "Timeout"
}
}
} }
} }
} }