diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index c0944bd..a6f1db8 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -239,10 +239,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_check_api(session, endpoint: str, headers: dict, provider: str) -> bool: """Check API availability for different providers.""" try: - if provider == API_PROVIDER_ANTHROPIC: + if provider == API_PROVIDER_GEMINI: + # Gemini API does not support GET /models for validation, just check key presence + if headers.get("Authorization", "").replace("Bearer ", ""): + return True + else: + _LOGGER.error("Gemini API key is missing or empty") + return False + elif provider == API_PROVIDER_ANTHROPIC: check_url = f"{endpoint}/v1/models" elif provider == API_PROVIDER_DEEPSEEK: - check_url = f"{endpoint}/models" # DeepSeek + check_url = f"{endpoint}/models" else: # OpenAI check_url = f"{endpoint}/models" @@ -251,7 +258,8 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str) if response.status in [200, 404]: return True elif response.status == 401: - raise ConfigEntryNotReady("Invalid API key") + _LOGGER.error("Invalid API key") + return False elif response.status == 429: _LOGGER.warning("Rate limit exceeded during API check") return False diff --git a/custom_components/ha_text_ai/api_client.py b/custom_components/ha_text_ai/api_client.py index cac113a..b45f906 100644 --- a/custom_components/ha_text_ai/api_client.py +++ b/custom_components/ha_text_ai/api_client.py @@ -11,6 +11,7 @@ import asyncio from typing import Any, Dict, List, Optional from aiohttp import ClientSession, ClientTimeout from async_timeout import timeout +from datetime import datetime, timedelta from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -250,89 +251,135 @@ class APIClient: temperature: float, max_tokens: int, ) -> Dict[str, Any]: - """Create completion using Gemini API.""" - # Extract API key from headers (Bearer token) - api_key = self.headers.get("Authorization", "").replace("Bearer ", "") - url = f"{self.endpoint}/models/{model}:generateContent?key={api_key}" + """Create completion using Gemini API with google-genai library. - # Convert messages to Gemini format - contents = [] - system_instruction = "" - - # Process messages - for msg in messages: - if msg['role'] == 'system': - system_instruction += msg['content'] + "\n" - else: - # Convert role: 'user' stays 'user', anything else becomes 'model' - role = "user" if msg['role'] == 'user' else "model" - contents.append({ - "role": role, - "parts": [{"text": msg['content']}] - }) - - # Ensure contents starts with a user message if not empty - if contents and contents[0]["role"] != "user": - # Add a placeholder user message - contents.insert(0, { - "role": "user", - "parts": [{"text": "I need your assistance."}] - }) - - # Ensure contents is not empty - if not contents: - contents.append({ - "role": "user", - "parts": [{"text": "I need your assistance."}] - }) - - # Create payload with snake_case keys as required by Gemini API - payload = { - "contents": contents, - "generation_config": { # Changed from camelCase to snake_case - "temperature": temperature, - "max_output_tokens": max_tokens # Changed from camelCase to snake_case - } - } - - if system_instruction: - payload["system_instruction"] = { # Changed from camelCase to snake_case - "parts": [{"text": system_instruction.strip()}] - } + Args: + model: The model name to use + messages: List of message dictionaries with role and content + temperature: Sampling temperature between 0.0 and 2.0 + max_tokens: Maximum number of tokens to generate + Returns: + Dictionary with response content and token usage + """ try: - data = await self._make_request(url, payload) + # Импортируем библиотеку в отдельном потоке, чтобы избежать блокировки event loop + def import_genai(): + from google import genai + return genai - # Safely extract response data - candidates = data.get("candidates", []) - if not candidates: - raise HomeAssistantError("Gemini API returned no candidates") + genai = await asyncio.to_thread(import_genai) - content = candidates[0].get("content", {}) - parts = content.get("parts", []) - if not parts: - raise HomeAssistantError("Gemini API response contains no content parts") + # Extract API key from headers (Bearer token) + api_key = self.headers.get("Authorization", "").replace("Bearer ", "") - answer_text = parts[0].get("text", "") + # Создаем клиент в отдельном потоке + def create_client(): + if self.endpoint and self.endpoint != "https://generativelanguage.googleapis.com/v1beta": + return genai.Client(api_key=api_key, transport="rest", + client_options={"api_endpoint": self.endpoint}) + else: + return genai.Client(api_key=api_key) - # Safely extract usage data - usage = data.get("usageMetadata", {}) - prompt_tokens = usage.get("promptTokenCount", 0) - completion_tokens = usage.get("candidatesTokenCount", 0) - total_tokens = usage.get("totalTokenCount", prompt_tokens + completion_tokens) + client = await asyncio.to_thread(create_client) + + # Process messages to extract system instruction and chat history + system_instruction = "" + contents = [] + + for msg in messages: + if msg['role'] == 'system': + system_instruction += msg['content'] + "\n" + else: + # For chat history, we need to convert to the format Gemini expects + role = "user" if msg['role'] == 'user' else "model" + contents.append({ + "role": role, + "parts": [{"text": msg['content']}] + }) + + # Create configuration + def create_config(): + from google.genai import types + config = types.GenerateContentConfig( + temperature=temperature, + max_output_tokens=max_tokens, + ) + + # Add system instruction if present + if system_instruction: + config.system_instruction = system_instruction.strip() + + return config + + config = await asyncio.to_thread(create_config) + + # Выполняем запрос в отдельном потоке + def generate_content(): + # For single message without history, use generate_content + if len(contents) <= 1: + # If we have no content yet, create a simple prompt + if not contents: + prompt = "I need your assistance." + else: + prompt = contents[0]["parts"][0]["text"] + + return client.models.generate_content( + model=model, + contents=prompt, + config=config + ) + else: + # For multi-turn conversations, use chat + chat = client.chats.create(model=model, config=config) + + # Send all messages in sequence + for content in contents: + if content["role"] == "user": + response = chat.send_message(content["parts"][0]["text"]) + # We don't send assistant messages as they're already part of the history + + return response + + response = await asyncio.to_thread(generate_content) + + # Extract response text + def extract_response(): + response_text = response.text if hasattr(response, 'text') else "" + + # Try to get token usage if available + usage = {} + if hasattr(response, 'usage_metadata'): + usage = { + "prompt_tokens": getattr(response.usage_metadata, 'prompt_token_count', 0), + "completion_tokens": getattr(response.usage_metadata, 'candidates_token_count', 0), + "total_tokens": getattr(response.usage_metadata, 'total_token_count', 0) + } + else: + # Estimate token count as fallback + usage = { + "prompt_tokens": len(" ".join([m["content"] for m in messages]).split()) // 3, + "completion_tokens": len(response_text.split()) // 3, + "total_tokens": 0 # Will be calculated below + } + usage["total_tokens"] = usage["prompt_tokens"] + usage["completion_tokens"] + + return response_text, usage + + response_text, usage = await asyncio.to_thread(extract_response) return { "choices": [{ "message": { - "content": answer_text + "content": response_text } }], - "usage": { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens - } + "usage": usage } + + except ImportError as e: + _LOGGER.error(f"Google Gemini library not installed: {str(e)}") + raise HomeAssistantError(f"Missing dependency: {str(e)}. Please install google-genai.") except Exception as e: _LOGGER.error(f"Gemini API error: {str(e)}") raise HomeAssistantError(f"Gemini API error: {str(e)}") diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 501b9b1..e8f53c6 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -8,6 +8,7 @@ Config flow for HA text AI integration. """ import logging from typing import Any, Dict, Optional +from datetime import datetime, timedelta import voluptuous as vol from homeassistant import config_entries @@ -147,41 +148,173 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }) ) + # Debug log to identify what's in the input + _LOGGER.debug(f"Provider step input data: {user_input}") + input_copy = user_input.copy() + # Check if CONF_NAME exists in input_copy and ensure it's not empty + if CONF_NAME not in input_copy or not input_copy[CONF_NAME]: + _LOGGER.warning(f"Missing name in configuration input: {input_copy}") + input_copy[CONF_NAME] = f"gemini_assistant_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + _LOGGER.info(f"Auto-generated name: {input_copy[CONF_NAME]}") + + # Ensure API key is present + if CONF_API_KEY not in input_copy or not input_copy[CONF_API_KEY]: + self._errors["base"] = "invalid_auth" + _LOGGER.error("API validation error: 'api_key'") + return self.async_show_form( + step_id="provider", + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str, + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL if self._provider == API_PROVIDER_GEMINI else DEFAULT_MODEL)): str, + vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT if self._provider == API_PROVIDER_GEMINI else DEFAULT_OPENAI_ENDPOINT)): str, + vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional(CONF_MAX_TOKENS, default=input_copy.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=input_copy.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL) + ), + vol.Optional( + CONF_CONTEXT_MESSAGES, + default=input_copy.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ), + vol.Optional( + CONF_MAX_HISTORY_SIZE, + default=input_copy.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=100) + ), + }), + errors=self._errors + ) + try: + # Validate and normalize the name normalized_name = self._validate_and_normalize_name(input_copy[CONF_NAME]) input_copy[CONF_NAME] = normalized_name except ValueError as e: return self.async_show_form( step_id="provider", data_schema=vol.Schema({ - vol.Required(CONF_NAME, default=input_copy[CONF_NAME]): str, - vol.Required(CONF_API_KEY, default=input_copy[CONF_API_KEY]): str, - vol.Required(CONF_MODEL, default=input_copy[CONF_MODEL]): str, - vol.Required(CONF_API_ENDPOINT, default=input_copy[CONF_API_ENDPOINT]): str, + vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str, + vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str, + vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL if self._provider == API_PROVIDER_GEMINI else DEFAULT_MODEL)): str, + vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT if self._provider == API_PROVIDER_GEMINI else DEFAULT_OPENAI_ENDPOINT)): str, vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All( vol.Coerce(float), vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) ), + vol.Optional(CONF_MAX_TOKENS, default=input_copy.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=input_copy.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL) + ), + vol.Optional( + CONF_CONTEXT_MESSAGES, + default=input_copy.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ), + vol.Optional( + CONF_MAX_HISTORY_SIZE, + default=input_copy.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=100) + ), }), errors={"name": str(e)} ) try: - if not await self._async_validate_api(input_copy): - return self.async_show_form( - step_id="provider", - data_schema=vol.Schema({}), - errors=self._errors - ) + # Special handling for Gemini API validation + if self._provider == API_PROVIDER_GEMINI: + # For Gemini, we just check if API key is present as there's no simple endpoint to validate + if not input_copy.get(CONF_API_KEY): + self._errors["base"] = "invalid_auth" + _LOGGER.error("API validation error: 'api_key'") + return self.async_show_form( + step_id="provider", + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str, + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL)): str, + vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT)): str, + # Other fields remain the same + }), + errors=self._errors + ) + else: + # For other providers, validate API connection + if not await self._async_validate_api(input_copy): + return self.async_show_form( + step_id="provider", + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str, + vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str, + vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_MODEL)): str, + vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_OPENAI_ENDPOINT)): str, + vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional(CONF_MAX_TOKENS, default=input_copy.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=input_copy.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL) + ), + vol.Optional( + CONF_CONTEXT_MESSAGES, + default=input_copy.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ), + vol.Optional( + CONF_MAX_HISTORY_SIZE, + default=input_copy.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=100) + ), + }), + errors=self._errors + ) except Exception as e: + # Handle any unexpected exceptions during validation + _LOGGER.exception("Unexpected error during API validation") return self.async_show_form( step_id="provider", - data_schema=vol.Schema({}), + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=input_copy.get(CONF_NAME, "my_assistant")): str, + vol.Required(CONF_API_KEY, default=input_copy.get(CONF_API_KEY, "")): str, + vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, DEFAULT_GEMINI_MODEL if self._provider == API_PROVIDER_GEMINI else DEFAULT_MODEL)): str, + vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, DEFAULT_GEMINI_ENDPOINT if self._provider == API_PROVIDER_GEMINI else DEFAULT_OPENAI_ENDPOINT)): str, + # Other fields remain the same + }), errors={"base": str(e)} ) + # All validation passed, create the entry return await self._create_entry(input_copy) def _validate_and_normalize_name(self, name: str) -> str: @@ -219,23 +352,34 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_validate_api(self, user_input: Dict[str, Any]) -> bool: """Validate API connection.""" try: + if CONF_API_KEY not in user_input: + _LOGGER.error("API validation error: 'api_key'") + self._errors["base"] = "invalid_auth" + return False + session = async_get_clientsession(self.hass) headers = self._get_api_headers(user_input) endpoint = user_input[CONF_API_ENDPOINT].rstrip('/') - check_url = ( - f"{endpoint}/v1/models" if self._provider == API_PROVIDER_ANTHROPIC - else f"{endpoint}/models" - ) - - async with session.get(check_url, headers=headers) as response: - if response.status == 401: + if self._provider == API_PROVIDER_GEMINI: + if not user_input[CONF_API_KEY]: self._errors["base"] = "invalid_auth" return False - elif response.status not in [200, 404]: - self._errors["base"] = "cannot_connect" - return False return True + else: + check_url = ( + f"{endpoint}/v1/models" if self._provider == API_PROVIDER_ANTHROPIC + else f"{endpoint}/models" + ) + + async with session.get(check_url, headers=headers) as response: + if response.status == 401: + self._errors["base"] = "invalid_auth" + return False + elif response.status not in [200, 404]: + self._errors["base"] = "cannot_connect" + return False + return True except Exception as err: _LOGGER.error("API validation error: %s", str(err)) @@ -244,6 +388,9 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _get_api_headers(self, user_input: Dict[str, Any]) -> Dict[str, str]: """Get API headers based on provider.""" + if CONF_API_KEY not in user_input: + return {"Content-Type": "application/json"} + api_key = user_input[CONF_API_KEY] if self._provider == API_PROVIDER_ANTHROPIC: @@ -252,6 +399,11 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "anthropic-version": "2023-06-01", "Content-Type": "application/json" } + elif self._provider == API_PROVIDER_GEMINI: + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } return { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 177e086..86fb530 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -74,7 +74,7 @@ ICONS_SUBDOMAIN = "icons" # Default values DEFAULT_MODEL: Final = "gpt-4o-mini" DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat" -DEFAULT_GEMINI_MODEL: Final = "gemini-pro" +DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash" DEFAULT_TEMPERATURE: Final = 0.1 DEFAULT_MAX_TOKENS: Final = 1000 DEFAULT_REQUEST_INTERVAL: Final = 1.0 diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 4798bbe..94abd5a 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -13,17 +13,17 @@ "loggers": ["custom_components.ha_text_ai"], "mqtt": [], "quality_scale": "silver", - "requirements": [ - "openai>=1.12.0", - "anthropic>=0.8.0", - "google-generativeai>=0.3.0", - "aiohttp>=3.8.0", - "async-timeout>=4.0.0", - "certifi>=2024.2.2" - ], + "requirements": [ + "openai>=1.12.0", + "anthropic>=0.8.0", + "google-genai>=1.16.0", + "aiohttp>=3.8.0", + "async-timeout>=4.0.0", + "certifi>=2024.2.2" + ], "single_config_entry": false, "ssdp": [], "usb": [], - "version": "2.1.6", + "version": "2.1.7", "zeroconf": [] } diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index 3857242..2e7a023 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -9,6 +9,7 @@ Sensor platform for HA Text AI. import logging import math from typing import Any, Dict +from datetime import datetime, timedelta from homeassistant.components.sensor import ( SensorEntity,