mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 07:03:58 +08:00
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:
@@ -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}")
|
||||
async with self.session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
) as response:
|
||||
_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,
|
||||
|
||||
Reference in New Issue
Block a user