fix: Comprehensive v2.4.0 security, stability, and quality improvements

Security:
- Add SSRF protection via HTTPS-only endpoint validation with private IP blocking
- Mask API keys in all log output via safe_log_data()
- Sanitize error responses to prevent internal detail leakage
- Remove API key pre-population in config flow forms
- Harden service schemas with input length limits and type coercion

Bug fixes:
- Fix temperature=0 rejected due to Python falsy value bug (0 or default)
- Fix fire-and-forget race condition in coordinator init (5 async_create_task → awaited async_initialize)
- Fix rate limit state not resetting after successful API calls
- Fix ConnectionError incorrectly setting is_rate_limited=True
- Fix _rotate_history calling async method via sync executor
- Fix shutil.move blocking event loop in history migration
- Fix async_shutdown removing wrong key from hass.data
- Replace os.rename with shutil.move for cross-device safety

Improvements:
- Add asyncio.Lock for request serialization
- Replace async_timeout with stdlib asyncio.timeout (Python 3.12+)
- Use SupportsResponse.OPTIONAL for ask_question and get_history services
- Use Platform.SENSOR enum instead of string literal
- Add strings.json for HA translation framework
- Update DEFAULT_ANTHROPIC_MODEL to claude-sonnet-4-6
- Retry only transient errors (429, timeout) in API client

Cleanup:
- Remove dead code: unused schemas, imports, sync_write_history, check_memory, check_connection
- Remove icon copying code from async_setup
- Remove unused dependencies from manifest (anthropic, openai, certifi, async-timeout)
- Remove empty manifest arrays (bluetooth, mqtt, ssdp, usb, zeroconf)
- Fix duplicate JSON keys in 5 translation files
- Fix Serbian translation: "Прекључено" → "Искључено" for disconnected state

Bump version to 2.4.0
This commit is contained in:
SMKRV
2026-03-12 01:24:01 +03:00
parent ce0a75f219
commit 998103ce73
15 changed files with 564 additions and 460 deletions
+34 -73
View File
@@ -9,24 +9,23 @@ The HA Text AI integration.
from __future__ import annotations
import logging
import os
import shutil
from datetime import datetime, timedelta
from typing import Any, Dict, TypeVar
from datetime import datetime
from typing import Any, Dict
import asyncio
import voluptuous as vol
from async_timeout import timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import aiohttp_client
from .coordinator import HATextAICoordinator
from .api_client import APIClient
from .utils import get_file_hash, safe_log_data
from .utils import safe_log_data, validate_endpoint
from .providers import get_default_endpoint, get_default_model, build_auth_headers
from .const import (
DOMAIN,
@@ -53,24 +52,24 @@ from .const import (
SERVICE_SET_SYSTEM_PROMPT,
DEFAULT_MAX_HISTORY,
CONF_MAX_HISTORY_SIZE,
ICONS_SUBDOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
ConfigType = TypeVar("ConfigType", bound=Dict[str, Any])
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Required("instance"): cv.string,
vol.Required("question"): cv.string,
vol.Optional("system_prompt"): cv.string,
vol.Required("question"): vol.All(cv.string, vol.Length(min=1, max=100000)),
vol.Optional("system_prompt"): vol.All(cv.string, vol.Length(max=50000)),
vol.Optional("model"): cv.string,
vol.Optional("temperature"): cv.positive_float,
vol.Optional("temperature"): vol.All(
vol.Coerce(float), vol.Range(min=0.0, max=2.0)
),
vol.Optional("max_tokens"): cv.positive_int,
vol.Optional("context_messages"): cv.positive_int,
vol.Optional("structured_output", default=False): cv.boolean,
vol.Optional("json_schema"): cv.string,
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
})
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
@@ -98,7 +97,7 @@ def get_coordinator_by_instance(hass: HomeAssistant, instance: str) -> HATextAIC
raise HomeAssistantError(f"Instance {instance} not found")
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
"""Set up the Home Assistant Text AI component."""
# Initialize domain data storage
hass.data.setdefault(DOMAIN, {})
@@ -143,7 +142,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"question": call.data["question"],
"timestamp": datetime.now().isoformat(),
"success": False,
"error": str(err)
"error": "Service call failed"
}
async def async_clear_history(call: ServiceCall) -> None:
@@ -185,7 +184,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SERVICE_ASK_QUESTION,
async_ask_question,
schema=SERVICE_SCHEMA_ASK_QUESTION,
supports_response=True
supports_response=SupportsResponse.OPTIONAL
)
hass.services.async_register(
@@ -199,7 +198,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DOMAIN,
SERVICE_GET_HISTORY,
async_get_history,
schema=SERVICE_SCHEMA_GET_HISTORY
schema=SERVICE_SCHEMA_GET_HISTORY,
supports_response=SupportsResponse.OPTIONAL
)
hass.services.async_register(
@@ -209,53 +209,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
)
# Handle icons
try:
source_icon_path = os.path.join(
os.path.dirname(__file__),
ICONS_SUBDOMAIN,
'icon@2x.png'
)
destination_directory = os.path.join(
hass.config.path('www'),
DOMAIN,
ICONS_SUBDOMAIN
)
destination_icon_path = os.path.join(
destination_directory,
'icon.png'
)
if not os.path.exists(source_icon_path):
_LOGGER.error("Source icon not found: %s", source_icon_path)
return True
def create_directory():
os.makedirs(destination_directory, exist_ok=True)
await hass.async_add_executor_job(create_directory)
should_copy = True
if os.path.exists(destination_icon_path):
source_hash = await hass.async_add_executor_job(get_file_hash, source_icon_path)
dest_hash = await hass.async_add_executor_job(get_file_hash, destination_icon_path)
should_copy = source_hash != dest_hash
if should_copy:
def copy_file():
shutil.copyfile(source_icon_path, destination_icon_path)
await hass.async_add_executor_job(copy_file)
_LOGGER.debug("Icon updated: %s", destination_icon_path)
except PermissionError as e:
_LOGGER.error("Permission denied when managing icons: %s", str(e))
except Exception as e:
_LOGGER.error("Failed to manage icons: %s", str(e))
return True
async def async_check_api(session, endpoint: str, headers: dict, provider: str, api_timeout: int = DEFAULT_API_TIMEOUT) -> bool:
@@ -275,9 +228,9 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
else: # OpenAI
check_url = f"{endpoint}/models"
async with timeout(api_timeout):
async with asyncio.timeout(api_timeout):
async with session.get(check_url, headers=headers) as response:
if response.status in [200, 404]:
if response.status == 200:
return True
elif response.status == 401:
_LOGGER.error("Invalid API key")
@@ -294,7 +247,7 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA Text AI from a config entry."""
_LOGGER.debug(f"Setting up HA Text AI entry: {entry.data}")
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
try:
# Get provider from data or options (options takes precedence)
@@ -308,7 +261,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
session = aiohttp_client.async_get_clientsession(hass)
model = config.get(CONF_MODEL, get_default_model(api_provider))
endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider)).rstrip('/')
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider))
try:
endpoint = validate_endpoint(raw_endpoint)
except ValueError as err:
_LOGGER.error("Invalid API endpoint %s: %s", raw_endpoint, err)
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}")
# API key can now be updated via options
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
@@ -350,13 +308,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_timeout=api_timeout,
)
_LOGGER.debug(f"Created coordinator for {instance_name}")
# Initialize coordinator (directories, history, metrics)
await coordinator.async_initialize()
_LOGGER.debug("Created coordinator for %s", instance_name)
# Store coordinator
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
_LOGGER.debug(f"Stored coordinator in hass.data[{DOMAIN}][{entry.entry_id}]")
_LOGGER.debug("Stored coordinator in hass.data[%s][%s]", DOMAIN, entry.entry_id)
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -364,12 +325,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(async_update_options))
_LOGGER.debug(f"Setup completed for {instance_name}")
_LOGGER.debug("Setup completed for %s", instance_name)
return True
except Exception as err:
_LOGGER.exception(f"Error setting up HA Text AI: {err}")
_LOGGER.exception("Error setting up HA Text AI: %s", err)
raise
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
+35 -27
View File
@@ -10,10 +10,7 @@ import logging
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
from .const import (
DEFAULT_API_TIMEOUT,
@@ -88,38 +85,58 @@ class APIClient:
url: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Make API request with retry logic."""
# Log request without sensitive data
"""Make API request with retry logic for transient errors only."""
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
_LOGGER.debug(f"API Request: URL={url}, Safe payload: {safe_payload}")
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
for attempt in range(API_RETRY_COUNT):
try:
async with timeout(self.api_timeout):
async with self.session.post(
url,
json=payload,
headers=self.headers,
timeout=self.timeout,
) as response:
_LOGGER.debug(f"Response status: {response.status}")
if response.status != 200:
error_data = await response.json()
# Log error without sensitive data
safe_error = {k: v for k, v in error_data.items() if k not in ['message', 'details']}
_LOGGER.error(f"API error (status {response.status}): {safe_error}")
raise HomeAssistantError(f"API error: status {response.status}")
_LOGGER.debug("Response status: %s", response.status)
if response.status == 200:
return await response.json()
# Try to get error details
error_data = {}
try:
error_data = await response.json()
except Exception:
error_data = {"raw": await response.text()}
# Rate limit — retry with backoff
if response.status == 429:
_LOGGER.warning(
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
)
if attempt < API_RETRY_COUNT - 1:
await asyncio.sleep(2 ** attempt)
continue
raise HomeAssistantError("API rate limit exceeded")
# Client/server errors — don't retry
_LOGGER.error("API error (status %d): %s", response.status, error_data)
raise HomeAssistantError(f"API error: status {response.status}")
except asyncio.TimeoutError:
_LOGGER.warning(f"Timeout on attempt {attempt + 1}/{API_RETRY_COUNT}")
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
if attempt == API_RETRY_COUNT - 1:
raise HomeAssistantError("API request timed out")
await asyncio.sleep(1 * (attempt + 1))
await asyncio.sleep(2 ** attempt)
except HomeAssistantError:
raise
except Exception as e:
_LOGGER.warning(f"API request failed on attempt {attempt + 1}/{API_RETRY_COUNT}: {type(e).__name__}")
_LOGGER.warning(
"API request failed on attempt %d/%d: %s",
attempt + 1, API_RETRY_COUNT, type(e).__name__,
)
if attempt == API_RETRY_COUNT - 1:
raise
await asyncio.sleep(1 * (attempt + 1))
await asyncio.sleep(2 ** attempt)
async def create(
self,
@@ -320,15 +337,6 @@ class APIClient:
},
}
async def check_connection(self) -> bool:
"""Check API connection."""
try:
await self._make_request(self.endpoint, {"test": "connection"})
return True
except Exception as e:
_LOGGER.error(f"Connection check failed: {str(e)}")
return False
async def _create_gemini_completion(
self,
model: str,
+26 -16
View File
@@ -49,7 +49,7 @@ from .const import (
DEFAULT_MAX_HISTORY,
CONF_MAX_HISTORY_SIZE,
)
from .utils import normalize_name # noqa: F401 — re-exported for backward compat
from .utils import normalize_name, safe_log_data, validate_endpoint # noqa: F401 — re-exported for backward compat
from .providers import get_default_endpoint, get_default_model
_LOGGER = logging.getLogger(__name__)
@@ -132,16 +132,15 @@ 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}")
_LOGGER.debug("Provider step input data: %s", safe_log_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]}")
_LOGGER.warning("Missing name in configuration input: %s", safe_log_data(input_copy))
input_copy[CONF_NAME] = f"assistant_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
_LOGGER.info("Auto-generated name: %s", input_copy[CONF_NAME])
# Ensure API key is present
if CONF_API_KEY not in input_copy or not input_copy[CONF_API_KEY]:
@@ -197,7 +196,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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_API_KEY): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, get_default_model(self._provider))): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
@@ -259,7 +258,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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_API_KEY): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, get_default_model(self._provider))): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
vol.Optional(CONF_TEMPERATURE, default=input_copy.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)): vol.All(
@@ -302,7 +301,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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_API_KEY): str,
vol.Required(CONF_MODEL, default=input_copy.get(CONF_MODEL, get_default_model(self._provider))): str,
vol.Required(CONF_API_ENDPOINT, default=input_copy.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
# Other fields remain the same
@@ -353,9 +352,16 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._errors["base"] = "invalid_auth"
return False
# Validate endpoint URL (HTTPS-only, SSRF protection)
try:
endpoint = validate_endpoint(user_input[CONF_API_ENDPOINT])
except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect"
return False
session = async_get_clientsession(self.hass)
headers = self._get_api_headers(user_input)
endpoint = user_input[CONF_API_ENDPOINT].rstrip('/')
if self._provider == API_PROVIDER_GEMINI:
if not user_input[CONF_API_KEY]:
@@ -434,7 +440,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if key not in entry_data:
entry_data[key] = value
_LOGGER.debug(f"Creating config entry with data: {entry_data}")
_LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
return self.async_create_entry(
title=instance_name,
@@ -484,13 +490,20 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self._errors["base"] = "invalid_auth"
return False
# Validate endpoint URL (HTTPS-only, SSRF protection)
try:
endpoint = validate_endpoint(endpoint)
except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect"
return False
# For Gemini, just check if API key is present
if provider == API_PROVIDER_GEMINI:
return True
session = async_get_clientsession(self.hass)
headers = self._get_api_headers(api_key, provider)
endpoint = endpoint.rstrip('/')
check_url = (
f"{endpoint}/v1/models" if provider == API_PROVIDER_ANTHROPIC
@@ -607,10 +620,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
data = user_input or current_data
return vol.Schema({
vol.Required(
CONF_API_KEY,
default=data.get(CONF_API_KEY, "")
): str,
vol.Required(CONF_API_KEY): str,
vol.Required(
CONF_API_ENDPOINT,
default=data.get(CONF_API_ENDPOINT, default_endpoint)
+2 -77
View File
@@ -9,15 +9,13 @@ Constants for the HA text AI integration.
import os
import json
from typing import Final
import voluptuous as vol
from homeassistant.const import Platform, CONF_API_KEY, CONF_NAME
from homeassistant.helpers import config_validation as cv
import logging
_LOGGER = logging.getLogger(__name__)
# Domain and platforms
DOMAIN: Final = "ha_text_ai"
PLATFORMS: list[str] = ["sensor"]
PLATFORMS: list[Platform] = [Platform.SENSOR]
# Provider configuration
CONF_API_PROVIDER: Final = "api_provider"
@@ -72,11 +70,9 @@ CONF_JSON_SCHEMA: Final = "json_schema"
ABSOLUTE_MAX_HISTORY_SIZE = 500
MAX_ATTRIBUTE_SIZE = 4 * 1024
MAX_HISTORY_FILE_SIZE = 1 * 1024 * 1024
ICONS_SUBDOMAIN = "icons"
# Default values
DEFAULT_MODEL: Final = "gpt-4o-mini"
DEFAULT_ANTHROPIC_MODEL: Final = "claude-3-5-sonnet"
DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat"
DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash"
DEFAULT_TEMPERATURE: Final = 0.1
@@ -184,74 +180,3 @@ STATE_DISCONNECTED: Final = "disconnected"
EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received"
EVENT_ERROR_OCCURRED: Final = f"{DOMAIN}_error_occurred"
EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed"
# Service schema constants
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Required(CONF_INSTANCE): cv.string,
vol.Required("question"): cv.string,
vol.Optional("system_prompt"): cv.string,
vol.Optional("model"): cv.string,
vol.Optional("temperature"): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional("max_tokens"): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
),
vol.Optional("context_messages"): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=20)
),
vol.Optional(CONF_STRUCTURED_OUTPUT, default=False): cv.boolean,
vol.Optional(CONF_JSON_SCHEMA): cv.string,
})
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
vol.Required(CONF_INSTANCE): cv.string,
vol.Required("prompt"): cv.string
})
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
vol.Required(CONF_INSTANCE): cv.string,
vol.Optional("limit", default=10): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=100),
),
vol.Optional("filter_model"): cv.string
})
# Configuration schema
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_API_PROVIDER): vol.In(API_PROVIDERS),
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string,
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_API_ENDPOINT): cv.string,
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.All(
vol.Coerce(float),
vol.Range(min=MIN_REQUEST_INTERVAL, max=MAX_REQUEST_INTERVAL)
),
vol.Optional(CONF_API_TIMEOUT, default=DEFAULT_API_TIMEOUT): vol.All(
vol.Coerce(int),
vol.Range(min=MIN_API_TIMEOUT, max=MAX_API_TIMEOUT)
),
vol.Optional(CONF_MAX_HISTORY_SIZE, default=DEFAULT_MAX_HISTORY): vol.All( # Correct usage
vol.Coerce(int),
vol.Range(min=1, max=100),
),
vol.Optional(CONF_CONTEXT_MESSAGES, default=DEFAULT_CONTEXT_MESSAGES): vol.All(
vol.Coerce(int),
vol.Range(min=1, max=20)
)
})
}, extra=vol.ALLOW_EXTRA)
+38 -117
View File
@@ -14,8 +14,6 @@ import aiofiles
import os
import json
import asyncio
import psutil
import re
import math
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Union
@@ -116,6 +114,9 @@ class HATextAICoordinator(DataUpdateCoordinator):
self.is_anthropic = is_anthropic
self.api_timeout = api_timeout
# Concurrency control
self._request_lock = asyncio.Lock()
# Initialize essential attributes
self._is_processing = False
self._is_rate_limited = False
@@ -170,14 +171,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
"ha_text_ai_history"
)
# Initialize all async tasks in proper order
self.hass.async_create_task(self._create_history_dir())
self.hass.async_create_task(self._check_history_directory())
self.hass.async_create_task(self._initialize_metrics())
self.hass.async_create_task(self.async_initialize_history_file())
self.hass.async_create_task(self._migrate_history_from_txt_to_json())
# History file path using instance name
# History file path using instance name — must be set before async_initialize
self._history_file = os.path.join(
self._history_dir,
f"{self.normalized_name}_history.json"
@@ -188,7 +182,15 @@ class HATextAICoordinator(DataUpdateCoordinator):
self.context_messages = context_messages
_LOGGER.info(f"Initialized HA Text AI coordinator with instance: {instance_name}")
_LOGGER.info("Initialized HA Text AI coordinator with instance: %s", instance_name)
async def async_initialize(self) -> None:
"""Initialize coordinator: directories, history, metrics. Must be awaited."""
await self._create_history_dir()
await self._check_history_directory()
await self._initialize_metrics()
await self.async_initialize_history_file()
await self._migrate_history_from_txt_to_json()
@property
def last_response(self) -> Dict[str, Any]:
@@ -357,7 +359,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
HomeAssistantError: {"is_ha_error": True},
ConnectionError: {
"is_connection_error": True,
"is_rate_limited": True
},
TimeoutError: {"is_timeout": True},
PermissionError: {"is_permission_denied": True},
@@ -529,67 +530,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
_LOGGER.error(f"Error checking file size for {file_path}: {e}")
return 0
def _sync_write_history_entry(self, entry: dict) -> None:
"""Synchronous method to write history entry with enhanced error handling."""
try:
history = []
if os.path.exists(self._history_file):
try:
with open(self._history_file, 'r', encoding='utf-8') as f:
content = f.read()
if content.strip():
history = json.loads(content)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
_LOGGER.warning(f"Corrupted history file, creating new: {e}")
# Backup corrupted file
backup_path = f"{self._history_file}.corrupted.{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}"
try:
os.rename(self._history_file, backup_path)
_LOGGER.info(f"Corrupted history backed up to: {backup_path}")
except OSError:
pass
except PermissionError as e:
_LOGGER.error(f"Permission denied reading history file: {e}")
raise
except OSError as e:
_LOGGER.error(f"OS error reading history file: {e}")
raise
history.append(entry)
if len(history) > self.max_history_size:
history = history[-self.max_history_size:]
# Write with atomic operation
temp_file = f"{self._history_file}.tmp"
try:
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(history, f, indent=2, ensure_ascii=False)
os.replace(temp_file, self._history_file)
except PermissionError as e:
_LOGGER.error(f"Permission denied writing history file: {e}")
raise
except OSError as e:
_LOGGER.error(f"OS error writing history file: {e}")
# Clean up temp file if it exists
try:
os.remove(temp_file)
except OSError:
pass
raise
except Exception as e:
_LOGGER.error(f"Synchronous history entry writing failed: {e}")
raise
async def _rotate_history(self) -> None:
"""Rotate conversation history with file management."""
try:
_LOGGER.debug(f"Starting history rotation for {self._history_file}")
await self.hass.async_add_executor_job(self._rotate_history_files)
_LOGGER.debug(f"Completed history rotation")
_LOGGER.debug("Starting history rotation for %s", self._history_file)
await self._rotate_history_files()
_LOGGER.debug("Completed history rotation")
except Exception as e:
_LOGGER.error(f"Error rotating history: {e}")
_LOGGER.error("Error rotating history: %s", e)
_LOGGER.debug(traceback.format_exc())
async def _check_history_directory(self) -> None:
@@ -639,9 +587,10 @@ class HATextAICoordinator(DataUpdateCoordinator):
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
)
# Rotate files
# Rotate files (shutil.move for cross-device safety)
import shutil
await self.hass.async_add_executor_job(
os.rename,
shutil.move,
self._history_file,
archive_file
)
@@ -662,7 +611,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
async def _async_update_data(self) -> Dict[str, Any]:
"""Update coordinator data with improved error handling and performance."""
try:
async with asyncio.Semaphore(1):
current_state = self._get_current_state()
# Get limited history with info
@@ -692,7 +640,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
return data
except Exception as err:
_LOGGER.error(f"Error updating data: {err}", exc_info=True)
_LOGGER.error("Error updating data: %s", err, exc_info=True)
return self._get_safe_initial_state()
async def _get_limited_history(self) -> Dict[str, Any]:
@@ -712,7 +660,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
"total_entries": len(self._conversation_history),
"displayed_entries": len(limited_history),
"full_history_available": True,
"history_path": self._history_file,
}
return {
@@ -764,21 +711,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
raise ValueError("Invalid metrics format")
async def async_update_ha_state(self) -> None:
"""Update Home Assistant state."""
"""Update Home Assistant state via coordinator refresh."""
try:
_LOGGER.debug(
f"Requesting state update for {self.instance_name} (normalized: {self.normalized_name})"
"Requesting state update for %s", self.instance_name,
)
await self.async_request_refresh()
# Force update of all entities
entity_id_base = f"sensor.ha_text_ai_{self.normalized_name.lower()}"
for entity_id in self.hass.states.async_entity_ids():
if entity_id.startswith(entity_id_base):
self.hass.states.async_set(entity_id, self._get_current_state())
except Exception as err:
_LOGGER.error(f"Error updating HA state for {self.instance_name}: {err}")
_LOGGER.error("Error updating HA state for %s: %s", self.instance_name, err)
def _get_current_state(self) -> str:
"""Get current state based on internal flags."""
@@ -860,34 +800,30 @@ class HATextAICoordinator(DataUpdateCoordinator):
if self.client is None:
raise HomeAssistantError("AI client not initialized")
async with self._request_lock:
try:
self._is_processing = True
await self.async_update_ha_state()
temp_context_messages = context_messages or self.context_messages
temp_model = model or self.model
temp_temperature = temperature or self.temperature
temp_max_tokens = max_tokens or self.max_tokens
temp_system_prompt = system_prompt or self._system_prompt
temp_context_messages = context_messages if context_messages is not None else self.context_messages
temp_model = model if model is not None else self.model
temp_temperature = temperature if temperature is not None else self.temperature
temp_max_tokens = max_tokens if max_tokens is not None else self.max_tokens
temp_system_prompt = system_prompt if system_prompt is not None else self._system_prompt
# Start timing
start_time = dt_util.utcnow()
# Prepare messages with system prompt
messages = []
if temp_system_prompt:
messages.append({"role": "system", "content": temp_system_prompt})
# Add context from history
context_history = self._conversation_history[-temp_context_messages:]
for entry in context_history:
messages.append({"role": "user", "content": entry["question"]})
messages.append({"role": "assistant", "content": entry["response"]})
# Add current question
messages.append({"role": "user", "content": question})
# Process message
kwargs = {
"model": temp_model,
"temperature": temp_temperature,
@@ -899,7 +835,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
response = await self.async_process_message(question, **kwargs)
# Update metrics
end_time = dt_util.utcnow()
latency = (end_time - start_time).total_seconds()
await self._update_metrics(latency, response)
@@ -929,6 +864,10 @@ class HATextAICoordinator(DataUpdateCoordinator):
json_schema=json_schema, **kwargs
)
# Reset error state on success
self._is_rate_limited = False
self.endpoint_status = "ready"
# Add timestamp and model information to response
timestamp = dt_util.utcnow().isoformat()
model_used = kwargs.get("model", self.model)
@@ -1039,9 +978,10 @@ class HATextAICoordinator(DataUpdateCoordinator):
async with AsyncFileHandler(self._history_file, 'w') as f:
await f.write(json.dumps(history_entries, indent=2))
# Backup old file
# Backup old file (use shutil.move for cross-device safety)
import shutil
backup_file = old_history_file + '.backup'
os.rename(old_history_file, backup_file)
await self.hass.async_add_executor_job(shutil.move, old_history_file, backup_file)
_LOGGER.info(
f"Successfully migrated {len(history_entries)} entries "
@@ -1132,25 +1072,6 @@ class HATextAICoordinator(DataUpdateCoordinator):
self._system_prompt = prompt
await self.async_update_ha_state()
def _check_memory_available(self) -> bool:
"""Check if enough memory is available."""
try:
memory = psutil.virtual_memory()
# Log the total and available memory
_LOGGER.debug("Total memory: %s, Available memory: %s", memory.total, memory.available)
if memory.available > 1024 * 1024 * 100: # 100MB
_LOGGER.debug("Sufficient memory available: %s bytes", memory.available)
return True
else:
_LOGGER.warning("Insufficient memory available: %s bytes", memory.available)
return False
except Exception as e:
_LOGGER.error("Error checking memory availability: %s", e)
return True # Assume memory is available if check fails
async def async_shutdown(self) -> None:
"""Shutdown coordinator."""
_LOGGER.debug(f"Shutting down coordinator for {self.instance_name}")
self.hass.data[DOMAIN].pop(self.instance_name, None)
_LOGGER.debug("Shutting down coordinator for %s", self.instance_name)
+2 -13
View File
@@ -2,7 +2,6 @@
"domain": "ha_text_ai",
"name": "HA Text AI",
"after_dependencies": ["http"],
"bluetooth": [],
"codeowners": ["@smkrv"],
"config_flow": true,
"dependencies": [],
@@ -11,20 +10,10 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"loggers": ["custom_components.ha_text_ai"],
"mqtt": [],
"quality_scale": "silver",
"requirements": [
"aiofiles>=23.0.0",
"aiohttp>=3.8.0",
"anthropic>=0.8.0",
"async-timeout>=4.0.0",
"certifi>=2024.2.2",
"google-genai>=1.16.0",
"openai>=1.12.0"
"aiohttp>=3.8.0"
],
"single_config_entry": false,
"ssdp": [],
"usb": [],
"version": "2.3.0",
"zeroconf": []
"version": "2.4.0"
}
+21 -19
View File
@@ -73,6 +73,7 @@ from .const import (
)
from .coordinator import HATextAICoordinator
from .utils import safe_log_data
_LOGGER = logging.getLogger(__name__)
@@ -83,23 +84,23 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the HA Text AI sensor."""
_LOGGER.debug(f"Starting sensor setup for entry: {entry.entry_id}")
_LOGGER.debug("Starting sensor setup for entry: %s", entry.entry_id)
try:
coordinator = hass.data[DOMAIN][entry.entry_id]
_LOGGER.debug(f"Found coordinator for entry {entry.entry_id}")
_LOGGER.debug("Found coordinator for entry %s", entry.entry_id)
instance_name = coordinator.instance_name
_LOGGER.debug(f"Setting up sensor with instance: {instance_name}")
_LOGGER.debug("Setting up sensor with instance: %s", instance_name)
sensor = HATextAISensor(coordinator, entry)
_LOGGER.debug(f"Created sensor instance: {sensor.entity_id}")
_LOGGER.debug("Created sensor instance: %s", sensor.entity_id)
async_add_entities([sensor], True)
_LOGGER.debug(f"Added sensor entity: {sensor.entity_id}")
_LOGGER.debug("Added sensor entity: %s", sensor.entity_id)
except Exception as err:
_LOGGER.exception(f"Error setting up sensor: {err}")
_LOGGER.exception("Error setting up sensor: %s", err)
raise
class HATextAISensor(CoordinatorEntity, SensorEntity):
@@ -113,7 +114,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
config_entry: ConfigEntry,
) -> None:
"""Initialize the sensor."""
_LOGGER.debug(f"Initializing sensor with config entry: {config_entry.data}")
_LOGGER.debug("Initializing sensor with config entry: %s", safe_log_data(dict(config_entry.data)))
super().__init__(coordinator)
@@ -121,8 +122,8 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
self._instance_name = coordinator.instance_name
self._normalized_name = coordinator.normalized_name
_LOGGER.debug(f"Instance name: {self._instance_name}")
_LOGGER.debug(f"Normalized name: {self._normalized_name}")
_LOGGER.debug("Instance name: %s", self._instance_name)
_LOGGER.debug("Normalized name: %s", self._normalized_name)
self._conversation_history = []
self._system_prompt = None
@@ -131,9 +132,9 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
self.entity_id = f"sensor.ha_text_ai_{self._normalized_name}"
self._attr_unique_id = f"{config_entry.entry_id}"
_LOGGER.debug(f"Created sensor with entity_id: {self.entity_id}")
_LOGGER.debug(f"Sensor name: {self._attr_name}")
_LOGGER.debug(f"Unique ID: {self._attr_unique_id}")
_LOGGER.debug("Created sensor with entity_id: %s", self.entity_id)
_LOGGER.debug("Sensor name: %s", self._attr_name)
_LOGGER.debug("Unique ID: %s", self._attr_unique_id)
self.entity_description = SensorEntityDescription(
key=f"ha_text_ai_{self._normalized_name.lower()}",
@@ -160,7 +161,8 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
)
_LOGGER.debug(
f"Initialized sensor: {self.entity_id} for instance: {self._instance_name}"
"Initialized sensor: %s for instance: %s",
self.entity_id, self._instance_name,
)
@property
@@ -200,7 +202,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
]
metrics_values = {k: sanitized.get(k) for k in metrics_keys if k in sanitized}
_LOGGER.debug(f"Metrics for {self.entity_id}: {metrics_values}")
_LOGGER.debug("Metrics for %s: %s", self.entity_id, metrics_values)
return sanitized
@@ -302,7 +304,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
"""When entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()
_LOGGER.debug(f"Entity {self.entity_id} added to Home Assistant")
_LOGGER.debug("Entity %s added to Home Assistant", self.entity_id)
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@@ -310,7 +312,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
data = self.coordinator.data
if not self.coordinator.last_update_success or not data:
self._current_state = STATE_DISCONNECTED
_LOGGER.warning(f"No data available for {self.entity_id}")
_LOGGER.warning("No data available for %s", self.entity_id)
self.async_write_ha_state()
return
@@ -320,7 +322,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
metrics = data.get("metrics", {})
if isinstance(metrics, dict):
self._metrics.update(metrics)
_LOGGER.debug(f"Updated metrics for {self.entity_id}: {self._metrics}")
_LOGGER.debug("Updated metrics for %s: %s", self.entity_id, self._metrics)
# Update conversation history and system prompt
self._conversation_history = data.get("conversation_history", [])
@@ -344,8 +346,8 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
self._last_update = dt_util.utcnow()
_LOGGER.debug(
f"Updated {self.entity_id} state to: {self._current_state} "
f"(available: {self.available})"
"Updated %s state to: %s (available: %s)",
self.entity_id, self._current_state, self.available,
)
except Exception as err:
+309
View File
@@ -0,0 +1,309 @@
{
"config": {
"step": {
"provider": {
"title": "Provider Settings",
"description": "Provide connection details for your chosen AI provider.",
"data": {
"name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
"api_key": "API key for authentication",
"model": "AI model to use",
"api_endpoint": "Custom API endpoint URL (optional)",
"temperature": "Response creativity (0-2, lower = more focused)",
"max_tokens": "Maximum response length (1-100000 tokens)",
"request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)"
}
},
"user": {
"title": "Configure HA Text AI Instance",
"description": "Set up a new AI assistant instance with your selected provider.",
"data": {
"name": "Instance name (e.g., 'GPT Assistant', 'Claude Helper')",
"api_key": "API key for authentication",
"model": "AI model to use",
"temperature": "Response creativity (0-2, lower = more focused)",
"max_tokens": "Maximum response length (1-100000 tokens)",
"api_endpoint": "Custom API endpoint URL (optional)",
"api_provider": "API Provider",
"request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)"
}
}
},
"error": {
"history_storage_error": "Failed to initialize history storage. Check permissions.",
"history_rotation_error": "Error during history file rotation.",
"history_file_access_error": "Cannot access history storage directory.",
"name_exists": "An instance with this name already exists",
"invalid_name": "Invalid instance name",
"invalid_auth": "Authentication failed - check your API key",
"invalid_api_key": "Invalid API key - please verify your credentials",
"cannot_connect": "Failed to connect to API service",
"invalid_model": "Selected model is not available",
"rate_limit": "Rate limit exceeded",
"context_length": "Context length exceeded",
"rate_limit_exceeded": "API rate limit exceeded",
"maintenance": "Service is under maintenance",
"invalid_response": "Invalid API response received",
"api_error": "API service error occurred",
"timeout": "Request timed out",
"invalid_instance": "Invalid instance specified",
"unknown": "Unexpected error occurred",
"empty": "Name cannot be empty",
"invalid_characters": "Name can only contain letters, numbers, spaces, underscores and hyphens",
"name_too_long": "Name must be 50 characters or less"
},
"abort": {
"already_configured": "Instance already configured"
}
},
"options": {
"step": {
"init": {
"title": "Select Provider",
"description": "Choose the AI provider for this instance. The integration will reload after saving changes.",
"data": {
"api_provider": "API Provider"
}
},
"settings": {
"title": "Connection & Model Settings",
"description": "Configure API credentials and model parameters. Changes will take effect after the integration reloads.",
"data": {
"api_key": "API Key",
"api_endpoint": "API Endpoint URL",
"model": "AI model",
"temperature": "Response creativity (0-2)",
"max_tokens": "Maximum response length (1-100000)",
"request_interval": "Minimum request interval (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)",
"max_history_size": "Maximum conversation history size (1-100)"
}
}
}
},
"selector": {
"api_provider": {
"options": {
"openai": "OpenAI (compatible)",
"anthropic": "Anthropic (compatible)",
"deepseek": "DeepSeek",
"gemini": "Google Gemini"
}
}
},
"services": {
"ask_question": {
"name": "Ask Question (HA Text AI)",
"description": "Send a question to the AI model and receive a detailed response. This service now returns response data directly, eliminating the need for separate text sensors and the 255-character limitation. The response will also be stored in the conversation history.",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to use"
},
"question": {
"name": "Question",
"description": "Your question or prompt for the AI assistant"
},
"context_messages": {
"name": "Context Messages",
"description": "Number of previous messages to include in context (1-20)"
},
"system_prompt": {
"name": "System Prompt",
"description": "Optional system prompt to set context for this specific question"
},
"model": {
"name": "Model",
"description": "Select AI model to use (optional, overrides default setting)"
},
"temperature": {
"name": "Temperature",
"description": "Controls response creativity (0.0-2.0)"
},
"max_tokens": {
"name": "Max Tokens",
"description": "Maximum length of the response (1-100000 tokens)"
},
"structured_output": {
"name": "Structured Output",
"description": "Enable JSON structured output mode. When enabled, the AI will respond with valid JSON matching the provided schema."
},
"json_schema": {
"name": "JSON Schema",
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
}
}
},
"clear_history": {
"name": "Clear History",
"description": "Delete all stored questions and responses from the conversation history",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to clear history for"
}
}
},
"get_history": {
"name": "Get History",
"description": "Retrieve conversation history with optional filtering and sorting",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to get history from"
},
"limit": {
"name": "Limit",
"description": "Number of conversations to return (1-100)"
},
"filter_model": {
"name": "Filter Model",
"description": "Filter conversations by specific AI model"
},
"start_date": {
"name": "Start Date",
"description": "Filter conversations starting from this date/time"
},
"include_metadata": {
"name": "Include Metadata",
"description": "Include additional information like tokens used, response time, etc."
},
"sort_order": {
"name": "Sort Order",
"description": "Sort order for results (newest or oldest first)"
}
}
},
"set_system_prompt": {
"name": "Set System Prompt",
"description": "Set default system behavior instructions for all future conversations",
"fields": {
"instance": {
"name": "Instance",
"description": "Name of the HA Text AI instance to set system prompt for"
},
"prompt": {
"name": "System Prompt",
"description": "Instructions that define how the AI should behave and respond"
}
}
}
},
"entity": {
"sensor": {
"ha_text_ai": {
"name": "{name}",
"state": {
"ready": "Ready",
"processing": "Processing",
"error": "Error",
"disconnected": "Disconnected",
"rate_limited": "Rate Limited",
"maintenance": "Maintenance",
"initializing": "Initializing",
"retrying": "Retrying",
"queued": "Queued"
},
"state_attributes": {
"question": {
"name": "Last Question"
},
"response": {
"name": "Last Response"
},
"model": {
"name": "Current Model"
},
"temperature": {
"name": "Temperature"
},
"max_tokens": {
"name": "Max Tokens"
},
"system_prompt": {
"name": "System Prompt"
},
"response_time": {
"name": "Last Response Time"
},
"total_responses": {
"name": "Total Responses"
},
"error_count": {
"name": "Error Count"
},
"last_error": {
"name": "Last Error"
},
"api_status": {
"name": "API Status"
},
"tokens_used": {
"name": "Total Tokens Used"
},
"average_response_time": {
"name": "Average Response Time"
},
"last_request_time": {
"name": "Last Request Time"
},
"is_processing": {
"name": "Processing Status"
},
"is_rate_limited": {
"name": "Rate Limited Status"
},
"is_maintenance": {
"name": "Maintenance Status"
},
"api_version": {
"name": "API Version"
},
"endpoint_status": {
"name": "Endpoint Status"
},
"performance_metrics": {
"name": "Performance Metrics"
},
"history_size": {
"name": "History Size"
},
"uptime": {
"name": "Uptime"
},
"total_tokens": {
"name": "Total Tokens"
},
"prompt_tokens": {
"name": "Prompt Tokens"
},
"completion_tokens": {
"name": "Completion Tokens"
},
"successful_requests": {
"name": "Successful Requests"
},
"failed_requests": {
"name": "Failed Requests"
},
"average_latency": {
"name": "Average Latency"
},
"max_latency": {
"name": "Maximum Latency"
},
"min_latency": {
"name": "Minimum Latency"
}
}
}
}
}
}
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "Wählen Sie AI-Anbieter",
"description": "Wählen Sie, welchen AI-Dienstanbieter Sie für diese Instanz verwenden möchten.",
"data": {
"api_provider": "API-Anbieter",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)"
}
},
"provider": {
"title": "Anbieter-Einstellungen",
"description": "Geben Sie die Verbindungsdetails für Ihren gewählten AI-Anbieter an.",
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "Select AI Provider",
"description": "Choose which AI service provider to use for this instance.",
"data": {
"api_provider": "API Provider",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)"
}
},
"provider": {
"title": "Provider Settings",
"description": "Provide connection details for your chosen AI provider.",
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "Seleccionar proveedor de IA",
"description": "Elige qué proveedor de servicio de IA utilizar para esta instancia.",
"data": {
"api_provider": "Proveedor de API",
"context_messages": "Número de mensajes de contexto a retener (1-20)",
"max_history_size": "Tamaño máximo del historial de conversación (1-100)"
}
},
"provider": {
"title": "Configuración del proveedor",
"description": "Proporciona los detalles de conexión para tu proveedor de IA elegido.",
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "Seleziona fornitore AI",
"description": "Scegli quale fornitore di servizi AI utilizzare per questa istanza.",
"data": {
"api_provider": "Fornitore API",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)"
}
},
"provider": {
"title": "Impostazioni fornitore",
"description": "Fornisci i dettagli di connessione per il tuo fornitore di AI scelto.",
@@ -1,15 +1,6 @@
{
"config": {
"step": {
"provider": {
"title": "Выбор провайдера ИИ",
"description": "Выберите сервис искусственного интеллекта для этого экземпляра.",
"data": {
"api_provider": "Провайдер API",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)"
}
},
"provider": {
"title": "Настройки провайдера",
"description": "Укажите параметры подключения для выбранного провайдера ИИ.",
@@ -204,7 +204,7 @@
"ready": "Спремно",
"processing": "Обрада",
"error": "Грешка",
"disconnected": "Прекључено",
"disconnected": "Искључено",
"rate_limited": "Ограничење захтева",
"maintenance": "Одржавање",
"initializing": "Инициализује се",
+34 -10
View File
@@ -6,8 +6,9 @@ Utility functions for HA Text AI integration.
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
import hashlib
import ipaddress
from typing import Any
from urllib.parse import urlparse
from homeassistant.const import CONF_API_KEY
@@ -19,18 +20,41 @@ def normalize_name(name: str) -> str:
return normalized.lower()
def get_file_hash(file_path: str) -> str:
"""Calculate SHA256 hash of file."""
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def safe_log_data(
data: dict[str, Any],
sensitive_keys: tuple[str, ...] = (CONF_API_KEY,),
) -> dict[str, Any]:
"""Filter sensitive keys from data for safe logging."""
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
def validate_endpoint(endpoint: str) -> str:
"""Validate API endpoint URL for security.
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection).
Returns the validated endpoint stripped of trailing slashes.
Raises:
ValueError: If the endpoint fails validation.
"""
parsed = urlparse(endpoint)
if parsed.scheme not in ("https",):
raise ValueError("Only HTTPS endpoints are allowed")
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid endpoint URL: no hostname")
# Block private/reserved IPs
try:
addr = ipaddress.ip_address(hostname)
if addr.is_private or addr.is_reserved or addr.is_loopback or addr.is_link_local:
raise ValueError("Private/reserved IP addresses are not allowed")
except ValueError as e:
if "not allowed" in str(e):
raise
# Not an IP address — it's a hostname, which is fine
return endpoint.rstrip("/")