From 53b15fa74cff9264d0912119d9d6f89f037c581c Mon Sep 17 00:00:00 2001 From: SMKRV Date: Fri, 22 Nov 2024 01:34:47 +0300 Subject: [PATCH] Release v2.0.0 --- custom_components/ha_text_ai/__init__.py | 58 +- custom_components/ha_text_ai/config_flow.py | 513 +++++------------- custom_components/ha_text_ai/const.py | 64 +-- custom_components/ha_text_ai/coordinator.py | 10 +- custom_components/ha_text_ai/manifest.json | 3 +- custom_components/ha_text_ai/sensor.py | 6 +- custom_components/ha_text_ai/services.yaml | 135 +---- .../ha_text_ai/translations/en.json | 95 +--- 8 files changed, 235 insertions(+), 649 deletions(-) diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 56f751f..fb34163 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -24,13 +24,15 @@ from .const import ( CONF_MAX_TOKENS, CONF_API_ENDPOINT, CONF_REQUEST_INTERVAL, + CONF_API_PROVIDER, + API_PROVIDER_OPENAI, + API_PROVIDER_ANTHROPIC, DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS, - DEFAULT_API_ENDPOINT, + DEFAULT_OPENAI_ENDPOINT, + DEFAULT_ANTHROPIC_ENDPOINT, DEFAULT_REQUEST_INTERVAL, - API_VERSION, - API_MODELS_PATH, API_TIMEOUT, API_RETRY_COUNT, API_BACKOFF_FACTOR, @@ -131,27 +133,17 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: 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.""" try: - # Определяем, является ли это VSE GPT endpoint - 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: + if provider == API_PROVIDER_ANTHROPIC: check_url = f"{endpoint}/v1/models" - else: - check_url = f"{endpoint}/{API_VERSION}/{API_MODELS_PATH}" + else: # OpenAI + check_url = f"{endpoint}/models" async with timeout(API_TIMEOUT): async with session.get(check_url, headers=headers) as response: - if response.status == 200: - return True - elif response.status == 404 and is_vsegpt: - # VSE GPT может возвращать 404 для /models endpoint + if response.status in [200, 404]: return True elif response.status == 401: 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: """Set up HA Text AI from a config entry.""" 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) - # Determine API type based on model and endpoint + # Получаем провайдера из конфигурации + api_provider = entry.data.get(CONF_API_PROVIDER) 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] - 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 = { "Content-Type": "application/json", "Accept": "application/json" } - if is_vsegpt: - headers["Authorization"] = f"Bearer {api_key}" - if is_anthropic: - headers["anthropic-version"] = "2023-06-01" - elif is_anthropic: + if is_anthropic: headers["x-api-key"] = api_key headers["anthropic-version"] = "2023-06-01" - else: + else: # OpenAI headers["Authorization"] = f"Bearer {api_key}" - # Check API with retries + # Проверка API с повторами 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 if attempt < API_RETRY_COUNT - 1: delay = API_BACKOFF_FACTOR * (2 ** attempt) diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 78edbbb..e6014d9 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -1,14 +1,10 @@ """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 asyncio import aiohttp -from async_timeout import timeout -from urllib.parse import urlparse, urljoin from homeassistant import config_entries from homeassistant.const import CONF_API_KEY -import homeassistant.helpers.config_validation as cv from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -20,403 +16,194 @@ from .const import ( CONF_MAX_TOKENS, CONF_API_ENDPOINT, CONF_REQUEST_INTERVAL, + CONF_API_PROVIDER, + API_PROVIDER_OPENAI, + API_PROVIDER_ANTHROPIC, + API_PROVIDERS, DEFAULT_MODEL, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS, - DEFAULT_API_ENDPOINT, DEFAULT_REQUEST_INTERVAL, + DEFAULT_OPENAI_ENDPOINT, + DEFAULT_ANTHROPIC_ENDPOINT, 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 _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): """Handle a config flow for HA text AI.""" VERSION = 1 - async def async_step_user( - self, - user_input: Optional[Dict[str, Any]] = None - ) -> FlowResult: + 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 None: + return self._show_provider_selection() - if user_input is not None: - try: - # Валидация значений через схему - validated_input = STEP_USER_DATA_SCHEMA(user_input) + if "provider" in user_input: + return self._show_provider_config(user_input["provider"]) - 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] - ) + return await self._process_configuration(user_input) - 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" + def _show_provider_selection(self): + """Show provider selection screen.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({ + vol.Required("provider"): vol.In(API_PROVIDERS) + }), + description_placeholders={"providers": ", ".join(API_PROVIDERS)} + ) - except vol.Invalid as err: - _LOGGER.error("Validation error: %s", str(err)) - errors["base"] = "invalid_input" - except Exception as err: - _LOGGER.error("Unexpected error: %s", str(err)) - errors["base"] = "unknown" + def _show_provider_config(self, provider): + """Show configuration screen for selected provider.""" + default_endpoint = DEFAULT_OPENAI_ENDPOINT if provider == API_PROVIDER_OPENAI else DEFAULT_ANTHROPIC_ENDPOINT 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, - } + data_schema=vol.Schema({ + vol.Required(CONF_API_PROVIDER): vol.In([provider]), + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MODEL, default=DEFAULT_MODEL): str, + 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 - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + async def _process_configuration(self, user_input): + """Validate and process user configuration.""" + errors = {} + 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): - """Handle options flow for HA text AI.""" + """Options flow handler.""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init( - self, - user_input: Optional[Dict[str, Any]] = None - ) -> FlowResult: - """Handle options flow.""" + async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult: + """Manage the options.""" if user_input is not None: - try: - # Validate using schema - options_schema = self._get_options_schema() - 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_create_entry(title="", data=user_input) + + current_data = self.config_entry.data return self.async_show_form( 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) - ) - }) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 0f9ed0d..9a770ab 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -8,6 +8,20 @@ from homeassistant.helpers import config_validation as cv DOMAIN: Final = "ha_text_ai" 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 CONF_MODEL: Final = "model" CONF_TEMPERATURE: Final = "temperature" @@ -15,29 +29,13 @@ CONF_MAX_TOKENS: Final = "max_tokens" CONF_API_ENDPOINT: Final = "api_endpoint" 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_MODEL: Final = "gpt-4o-mini" DEFAULT_TEMPERATURE: Final = 0.1 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_TIMEOUT: Final = 30 -DEFAULT_QUEUE_SIZE: Final = 100 -DEFAULT_HISTORY_LIMIT: Final = 50 -DEFAULT_RETRY_COUNT: Final = 3 # Parameter constraints MIN_TEMPERATURE: Final = 0.0 @@ -45,19 +43,11 @@ MAX_TEMPERATURE: Final = 2.0 MIN_MAX_TOKENS: Final = 1 MAX_MAX_TOKENS: Final = 4096 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_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" @@ -195,7 +185,7 @@ EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed" 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("model"): cv.string, vol.Optional("temperature"): vol.All( vol.Coerce(float), vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) @@ -207,7 +197,13 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ 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.Coerce(int), vol.Range(min=1, max=100) @@ -215,16 +211,4 @@ SERVICE_SCHEMA_GET_HISTORY = { vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS), vol.Optional("start_date"): cv.datetime, 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", -] +}) diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index 3a7b695..ff8d0fd 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -22,7 +22,6 @@ from .const import ( QUEUE_MAX_SIZE, MAX_RETRIES, RETRY_DELAY, - SUPPORTED_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -41,13 +40,11 @@ class HATextAICoordinator(DataUpdateCoordinator): session: Optional[Any] = None, is_anthropic: bool = False, ) -> None: - request_interval = float(request_interval) - super().__init__( hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=request_interval), + update_interval=timedelta(seconds=float(request_interval)), ) """Initialize coordinator.""" 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: """Estimate token count for text.""" - # Простая оценка: примерно 4 символа на токен return len(text) // 4 def get_rate_limit_info(self) -> Dict[str, Any]: diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 589c02d..8bc44ed 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -23,5 +23,6 @@ "ssdp": [], "usb": [], "version": "2.0.0", - "zeroconf": [] + "zeroconf": [], + "supported_api_providers": ["openai", "anthropic"] } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index 849b9c5..09d4702 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util from .const import ( DOMAIN, + CONF_API_PROVIDER, # Новый импорт ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED, @@ -80,11 +81,13 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): self._error_count = 0 self._last_error = None self._state = STATE_INITIALIZING + + # Добавляем провайдера в device_info self._attr_device_info = { "identifiers": {(DOMAIN, self._attr_unique_id)}, "name": "HA Text AI", "manufacturer": "Community", - "model": coordinator.model, + "model": f"{coordinator.model} ({self._config_entry.data.get(CONF_API_PROVIDER, 'Unknown')} provider)", "sw_version": coordinator.api_version, } @@ -216,7 +219,6 @@ class HATextAISensor(CoordinatorEntity, SensorEntity): else: self._state = STATE_DISCONNECTED - # Обновляем счетчик ошибок только если статус изменился на ошибку if self._state == STATE_ERROR: self._error_count += 1 diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index 1953833..efac7ca 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -3,24 +3,11 @@ ask_question: description: >- 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. - Response time may vary based on model selection and server load. - Supports various AI models with different capabilities and pricing. fields: question: name: Question - description: >- - 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. + description: Your question or prompt for the AI assistant. 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: text: multiline: true @@ -28,43 +15,15 @@ ask_question: 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 - example: "gpt-4o-mini" - default: "gpt-4o-mini" selector: - select: - custom_value: true - 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 - + text: + multiline: false temperature: name: Temperature - description: >- - 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. + description: Controls response creativity (0.0-2.0). required: false default: 0.7 selector: @@ -73,17 +32,10 @@ ask_question: max: 2.0 step: 0.1 mode: slider - unit_of_measurement: "" max_tokens: name: Max Tokens - description: >- - 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. + description: Maximum length of the response (1-4096 tokens). required: false default: 1000 selector: @@ -95,40 +47,24 @@ ask_question: system_prompt: name: System Prompt - description: >- - 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. + description: Optional system prompt to set context for this specific question. required: false - example: "You are a home automation expert focused on energy efficiency and security" selector: text: multiline: true clear_history: name: Clear History - description: >- - 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. + description: Delete all stored questions and responses from the conversation history. fields: {} get_history: name: Get History - description: >- - 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. + description: Retrieve recent conversation history. fields: limit: name: Limit - description: >- - Number of most recent conversations to return (1-100). - Higher values return more history but may take longer to process. - Default: 10 conversations + description: Number of most recent conversations to return (1-100). required: false default: 10 selector: @@ -138,66 +74,21 @@ get_history: step: 1 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: name: Start Date - description: >- - Optional start date for filtering history. - Format: YYYY-MM-DD + description: Optional start date for filtering history. required: false selector: datetime: set_system_prompt: name: Set System Prompt - description: >- - 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. + description: Set default system behavior instructions for all future conversations. fields: prompt: name: System Prompt - description: >- - 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 + description: Instructions that define how the AI should behave and respond. 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: text: multiline: true diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index b2cad13..0c02b8f 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -3,15 +3,15 @@ "step": { "user": { "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": { + "api_provider": "Select API Provider", "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)", "max_tokens": "Maximum response length (1-4096 tokens)", - "api_endpoint": "Custom API endpoint URL (default varies by provider)", - "request_interval": "Minimum time between requests in seconds (min: 0.1)", - "system_prompt": "Default instructions for AI behavior and expertise" + "api_endpoint": "Custom API endpoint URL (optional)", + "request_interval": "Minimum time between requests in seconds (min: 0.1)" } } }, @@ -19,13 +19,11 @@ "invalid_auth": "Authentication failed - check your API key", "invalid_api_key": "Invalid API key - please verify your credentials", "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", - "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" @@ -35,16 +33,12 @@ "step": { "init": { "title": "HA Text AI Settings", - "description": "Adjust your AI integration parameters and behavior", + "description": "Adjust your AI integration parameters", "data": { - "model": "Select AI model for responses (capabilities vary)", - "temperature": "Response creativity (0-2, affects variation)", + "model": "Select AI model (provider-specific)", + "temperature": "Response creativity (0-2)", "max_tokens": "Maximum response length in tokens", - "request_interval": "Minimum seconds between requests (rate limiting)", - "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" + "request_interval": "Minimum seconds between requests" } } } @@ -52,86 +46,25 @@ "services": { "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": { "question": { "name": "Question", - "description": "Your question or prompt for the AI model. Be specific for better results." - }, - "system_prompt": { - "name": "System Prompt", - "description": "Optional behavior instructions for this specific question" + "description": "Your question or prompt for the AI model" }, "model": { "name": "Model", - "description": "Optional specific AI model for this request (overrides default)" + "description": "Optional specific AI model for this request" }, "temperature": { "name": "Temperature", - "description": "Optional creativity setting (0-2, affects response variation)" + "description": "Optional creativity setting (0-2)" }, "max_tokens": { "name": "Max 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" - } - } } } }