diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 40fafea..3beb3e9 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -5,12 +5,10 @@ import logging import os import shutil from typing import Any, Dict, Optional -import asyncio -import voluptuous as vol from datetime import datetime from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -36,12 +34,13 @@ from .const import ( DEFAULT_ANTHROPIC_ENDPOINT, DEFAULT_REQUEST_INTERVAL, API_TIMEOUT, - API_RETRY_COUNT, SERVICE_ASK_QUESTION, SERVICE_CLEAR_HISTORY, + SERVICE_GET_HISTORY, SERVICE_SET_SYSTEM_PROMPT, SERVICE_SCHEMA_ASK_QUESTION, SERVICE_SCHEMA_SET_SYSTEM_PROMPT, + SERVICE_SCHEMA_GET_HISTORY, ) _LOGGER = logging.getLogger(__name__) @@ -61,6 +60,17 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: """Set up the HA Text AI component.""" hass.data.setdefault(DOMAIN, {}) + # Copy custom icon + try: + source = os.path.join(os.path.dirname(__file__), 'icons', 'icon.svg') + dest_dir = os.path.join(hass.config.path('www'), 'icons') + os.makedirs(dest_dir, exist_ok=True) + dest = os.path.join(dest_dir, 'icon.svg') + if not os.path.exists(dest): + shutil.copyfile(source, dest) + except Exception as ex: + _LOGGER.warning("Failed to copy custom icon: %s", str(ex)) + async def async_ask_question(call: ServiceCall) -> None: """Handle ask_question service.""" entity_id = call.target.get("entity_id") @@ -104,6 +114,33 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: _LOGGER.error("Error clearing history: %s", str(err)) raise HomeAssistantError(f"Failed to clear history: {str(err)}") + async def async_get_history(call: ServiceCall) -> None: + """Handle get_history service.""" + entity_id = call.target.get("entity_id") + if not entity_id: + raise HomeAssistantError("No target entity specified") + + coordinator = get_coordinator_by_id(hass, entity_id) + if not coordinator: + raise HomeAssistantError(f"No coordinator found for entity {entity_id}") + + try: + limit = call.data.get("limit", 10) + start_date = call.data.get("start_date") + include_metadata = call.data.get("include_metadata", False) + sort_order = call.data.get("sort_order", "desc") + + history = await coordinator.get_history( + limit=limit, + start_date=start_date, + include_metadata=include_metadata, + sort_order=sort_order + ) + return history + except Exception as err: + _LOGGER.error("Error getting history: %s", str(err)) + raise HomeAssistantError(f"Failed to get history: {str(err)}") + async def async_set_system_prompt(call: ServiceCall) -> None: """Handle set_system_prompt service.""" entity_id = call.target.get("entity_id") @@ -124,14 +161,14 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: _LOGGER.error("Error setting system prompt: %s", str(err)) raise HomeAssistantError(f"Failed to set system prompt: {str(err)}") - # Базовая схема с target как vol.Schema + # Base schema with target base_schema = vol.Schema({ vol.Required("target"): { vol.Required("entity_id"): cv.entity_id } }) - # Регистрация сервисов с использованием extend + # Register services hass.services.async_register( DOMAIN, SERVICE_ASK_QUESTION, @@ -146,6 +183,13 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: schema=base_schema ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_HISTORY, + async_get_history, + schema=base_schema.extend(SERVICE_SCHEMA_GET_HISTORY.schema) + ) + hass.services.async_register( DOMAIN, SERVICE_SET_SYSTEM_PROMPT, @@ -182,14 +226,11 @@ 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.""" 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) - - # Получаем провайдера из конфигурации api_provider = entry.data.get(CONF_API_PROVIDER) model = entry.data.get(CONF_MODEL, DEFAULT_MODEL) endpoint = entry.data.get(CONF_API_ENDPOINT, @@ -197,10 +238,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else DEFAULT_ANTHROPIC_ENDPOINT).rstrip('/') api_key = entry.data[CONF_API_KEY] - # Определяем параметры подключения в зависимости от провайдера is_anthropic = api_provider == API_PROVIDER_ANTHROPIC - - # Конфигурация headers headers = { "Content-Type": "application/json", "Accept": "application/json" @@ -209,50 +247,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if is_anthropic: headers["x-api-key"] = api_key headers["anthropic-version"] = "2023-06-01" - else: # OpenAI + else: headers["Authorization"] = f"Bearer {api_key}" - # Проверка API - try: - check_result = await async_check_api(session, endpoint, headers, api_provider) - if not check_result: - raise ConfigEntryNotReady("API connection failed") - except Exception as ex: - _LOGGER.error(f"API check failed: {ex}") - raise ConfigEntryNotReady("Failed to connect to API") + if not await async_check_api(session, endpoint, headers, api_provider): + raise ConfigEntryNotReady("API connection failed") - try: - # Create coordinator - coordinator = HATextAICoordinator( - hass, - api_key=api_key, - endpoint=endpoint, - model=model, - temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE), - max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS), - request_interval=float(entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)), - name=entry.title or entry.data.get("name", "HA Text AI"), # Добавляем fallback для имени - session=session, - is_anthropic=is_anthropic - ) + coordinator = HATextAICoordinator( + hass, + api_key=api_key, + endpoint=endpoint, + model=model, + temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE), + max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS), + request_interval=float(entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL)), + name=entry.title or entry.data.get(CONF_NAME, "HA Text AI"), + session=session, + is_anthropic=is_anthropic + ) - # Initialize the coordinator - await coordinator.async_initialize() - await coordinator.async_config_entry_first_refresh() + await coordinator.async_initialize() + await coordinator.async_config_entry_first_refresh() - # Store coordinator - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator - # Set up platforms - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - return True - - except Exception as ex: - _LOGGER.exception("Error initializing coordinator: %s", str(ex)) - raise ConfigEntryNotReady(f"Error initializing coordinator: {str(ex)}") from ex + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True except Exception as ex: _LOGGER.exception("Setup error: %s", str(ex)) @@ -273,19 +294,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except Exception as ex: _LOGGER.exception("Error unloading entry: %s", str(ex)) - return False # Убрано лишнее двоеточие - -async def async_setup(hass, config): - """Copy a custom icon to the www/icons directory.""" - # The source of the icon file inside your integration - source = os.path.join(os.path.dirname(__file__), 'icons', 'icon.svg') - # Target directory – /config/www/icons/ - dest_dir = os.path.join(hass.config.path('www'), 'icons') - # Create the target directory if it does not already exist - os.makedirs(dest_dir, exist_ok=True) - # Path where the icon will be saved - dest = os.path.join(dest_dir, 'icon.svg') - # If the icon has not already been copied, copy it to the target - if not os.path.exists(dest): - shutil.copyfile(source, dest) - return True + return False diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 48c9e1b..d6b6fc3 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -10,7 +10,7 @@ PLATFORMS: Final = [Platform.SENSOR] CONF_NAME = "name" DEFAULT_NAME = "HA Text AI" -# New constants for providers +# Provider configuration CONF_API_PROVIDER: Final = "api_provider" API_PROVIDER_OPENAI: Final = "openai" API_PROVIDER_ANTHROPIC: Final = "anthropic" @@ -45,6 +45,7 @@ MAX_TEMPERATURE: Final = 2.0 MIN_MAX_TOKENS: Final = 1 MAX_MAX_TOKENS: Final = 4096 MIN_REQUEST_INTERVAL: Final = 0.1 +MAX_REQUEST_INTERVAL: Final = 60.0 # API constants API_CHAT_PATH: Final = "chat/completions" @@ -56,6 +57,9 @@ HISTORY_FILTER_MODEL: Final = "filter_model" HISTORY_FILTER_DATE: Final = "start_date" HISTORY_SORT_ORDER: Final = "sort_order" HISTORY_INCLUDE_METADATA: Final = "include_metadata" +HISTORY_SORT_ASC: Final = "asc" +HISTORY_SORT_DESC: Final = "desc" +HISTORY_MAX_ENTRIES: Final = 100 # Service names SERVICE_ASK_QUESTION: Final = "ask_question" @@ -64,10 +68,10 @@ SERVICE_GET_HISTORY: Final = "get_history" SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt" # Service descriptions -SERVICE_ASK_QUESTION_DESCRIPTION: Final = "Ask a question to the AI model" -SERVICE_CLEAR_HISTORY_DESCRIPTION: Final = "Clear conversation history" -SERVICE_GET_HISTORY_DESCRIPTION: Final = "Get conversation history" -SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set system prompt for AI model" +SERVICE_ASK_QUESTION_DESCRIPTION: Final = "Send a question to the AI model and receive a detailed response. The response will be stored in the conversation history." +SERVICE_CLEAR_HISTORY_DESCRIPTION: Final = "Delete all stored questions and responses from the conversation history." +SERVICE_GET_HISTORY_DESCRIPTION: Final = "Retrieve recent conversation history with optional filtering and sorting." +SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set default system behavior instructions for all future conversations." # Attribute keys ATTR_QUESTION: Final = "question" @@ -90,6 +94,7 @@ ATTR_TOKENS_USED: Final = "tokens_used" ATTR_RETRY_COUNT: Final = "retry_count" ATTR_QUEUE_POSITION: Final = "queue_position" ATTR_ESTIMATED_WAIT: Final = "estimated_wait" +ATTR_SORT_ORDER: Final = "sort_order" # Error messages ERROR_INVALID_API_KEY: Final = "invalid_api_key" @@ -104,6 +109,7 @@ ERROR_QUEUE_FULL: Final = "queue_full" ERROR_INVALID_PROMPT: Final = "invalid_prompt" ERROR_INVALID_PARAMETERS: Final = "invalid_parameters" ERROR_SERVICE_UNAVAILABLE: Final = "service_unavailable" +ERROR_INVALID_SORT_ORDER: Final = "invalid_sort_order" # Configuration descriptions CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses" @@ -130,6 +136,7 @@ ATTR_API_VERSION_DESCRIPTION: Final = "Current API version" ATTR_ENDPOINT_STATUS_DESCRIPTION: Final = "Current endpoint status" ATTR_REQUEST_COUNT_DESCRIPTION: Final = "Total number of API requests" ATTR_TOKENS_USED_DESCRIPTION: Final = "Total tokens used" +ATTR_SORT_ORDER_DESCRIPTION: Final = "Sort order for history (asc/desc)" # Entity attributes ENTITY_NAME: Final = "HA Text AI" @@ -170,19 +177,6 @@ QUEUE_MAX_SIZE: Final = 100 MAX_RETRIES: Final = 3 RETRY_DELAY: Final = 1.0 -# Service schema constants -SCHEMA_QUESTION: Final = "question" -SCHEMA_MODEL: Final = "model" -SCHEMA_TEMPERATURE: Final = "temperature" -SCHEMA_MAX_TOKENS: Final = "max_tokens" -SCHEMA_PROMPT: Final = "prompt" -SCHEMA_LIMIT: Final = "limit" - -# Event names -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("question"): cv.string, @@ -195,22 +189,49 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({ vol.Optional("max_tokens"): vol.All( vol.Coerce(int), vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) - ), - vol.Optional("priority"): cv.boolean, + ) }) -# set_system_prompt SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({ - vol.Required("prompt"): cv.string, + 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) + vol.Range(min=1, max=HISTORY_MAX_ENTRIES) ), -# vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS), vol.Optional("start_date"): cv.datetime, vol.Optional("include_metadata"): cv.boolean, + vol.Optional("sort_order", default=HISTORY_SORT_DESC): vol.In([ + HISTORY_SORT_ASC, + HISTORY_SORT_DESC + ]) }) + +# Configuration schema +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + 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.Required(CONF_API_PROVIDER): vol.In(API_PROVIDERS) + }) +}, extra=vol.ALLOW_EXTRA) + +# Event names +EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received" +EVENT_ERROR_OCCURRED: Final = f"{DOMAIN}_error_occurred" +EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed" diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index 45f9364..b389596 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -1,10 +1,11 @@ """Data coordinator for HA text AI.""" import asyncio import logging -from datetime import timedelta +from datetime import timedelta, datetime from typing import Any, Dict, Optional, List import time import ssl +import uuid import certifi import aiohttp import httpx @@ -12,6 +13,7 @@ import httpx import voluptuous as vol from homeassistant import config_entries from homeassistant.helpers import aiohttp_client +from homeassistant.exceptions import HomeAssistantError from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError from anthropic import AsyncAnthropic from homeassistant.core import HomeAssistant @@ -45,7 +47,7 @@ class HATextAICoordinator(DataUpdateCoordinator): is_anthropic: bool = False, ) -> None: """Initialize coordinator.""" - self._name = name # Инициализируем name до вызова super().__init__ + self._name = name self._validate_params(api_key, temperature, max_tokens) self._entity_id = None self.api_key = api_key @@ -66,41 +68,48 @@ class HATextAICoordinator(DataUpdateCoordinator): self._tokens_used = 0 self._api_version = "v1" self._endpoint_status = "disconnected" - self._performance_metrics: Dict[str, Any] = { - "avg_response_time": 0, - "total_errors": 0, - "success_rate": 100, - "requests_per_minute": 0, - } + self._last_error = None self._last_request_time = 0 self._is_anthropic = is_anthropic self._session = session or aiohttp_client.async_get_clientsession(hass) self.client = None - super().__init__( + # История и метрики + self._history: List[Dict[str, Any]] = [] + self._max_history_size = 100 + self._performance_metrics: Dict[str, Any] = { + "avg_response_time": 0, + "total_errors": 0, + "success_rate": 100, + "requests_per_minute": 0, + "avg_tokens_per_request": 0, + } + + super().__init__( hass, _LOGGER, name=self._name, update_interval=timedelta(seconds=float(request_interval)), ) - @property - def name(self) -> str: - """Get coordinator name.""" - return self._name - - @name.setter - def name(self, value: str) -> None: - """Set coordinator name.""" - self._name = value + def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None: + """Validate initialization parameters.""" + if not api_key: + raise ValueError("API key is required") + if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2: + raise ValueError("Temperature must be between 0 and 2") + if not isinstance(max_tokens, int) or max_tokens < 1: + raise ValueError("Max tokens must be a positive integer") async def async_initialize(self) -> None: """Initialize coordinator.""" try: await self._init_client() self._is_ready = True + self._endpoint_status = "connected" await self.async_refresh() except Exception as e: + self._last_error = str(e) _LOGGER.error("Failed to initialize coordinator: %s", str(e)) self._is_ready = False self._endpoint_status = "error" @@ -114,178 +123,251 @@ class HATextAICoordinator(DataUpdateCoordinator): """Initialize API client with proper SSL context.""" try: if self._is_anthropic: - self.client = AsyncAnthropic( - api_key=self.api_key - ) + self.client = AsyncAnthropic( + api_key=self.api_key + ) else: # OpenAI - transport = httpx.AsyncHTTPTransport(retries=3) - limits = httpx.Limits(max_keepalive_connections=5, max_connections=10) + transport = httpx.AsyncHTTPTransport(retries=3) + limits = httpx.Limits(max_keepalive_connections=5, max_connections=10) - async_client = httpx.AsyncClient( - base_url=self.endpoint, - timeout=httpx.Timeout(30.0), - transport=transport, - limits=limits, - follow_redirects=True - ) + async_client = httpx.AsyncClient( + base_url=self.endpoint, + timeout=httpx.Timeout(30.0), + transport=transport, + limits=limits, + follow_redirects=True + ) - self.client = AsyncOpenAI( - api_key=self.api_key, - base_url=self.endpoint, - http_client=async_client, - max_retries=3 - ) + self.client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.endpoint, + http_client=async_client, + max_retries=3 + ) except Exception as e: + self._last_error = str(e) _LOGGER.error("Error initializing API client: %s", str(e)) raise - def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None: - """Validate initialization parameters.""" - if not api_key: - raise ValueError("API key is required") - if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2: + async def async_ask( + self, + question: str, + priority: bool = False, + system_prompt: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> str: + """Add question to queue with priority.""" + if not self._is_ready: + raise HomeAssistantError("Coordinator is not ready") + + if not question or not isinstance(question, str): + raise ValueError("Invalid question format") + + # Validate optional parameters + if temperature is not None and not 0 <= temperature <= 2: raise ValueError("Temperature must be between 0 and 2") - if not isinstance(max_tokens, int) or max_tokens < 1: - raise ValueError("Max tokens must be a positive integer") + if max_tokens is not None and not isinstance(max_tokens, int): + raise ValueError("max_tokens must be an integer") - async def _async_update_data(self) -> Dict[str, Any]: - """Update data via API.""" - # Возвращаем существующие ответы, если нет новых вопросов - if self._question_queue.empty(): - return self._responses + request_id = str(uuid.uuid4()) - try: - async with async_timeout.timeout(DEFAULT_TIMEOUT): - self._is_processing = True + request_params = { + "id": request_id, + "question": question, + "system_prompt": system_prompt or self.system_prompt, + "model": model or self.model, + "temperature": temperature or self.temperature, + "max_tokens": max_tokens or self.max_tokens, + "timestamp": dt_util.utcnow().isoformat(), + } + + priority_level = 0 if priority else 1 + await self._question_queue.put((priority_level, request_id, request_params)) + + # Wait for response + while request_id not in self._responses: + await asyncio.sleep(0.1) + + response = self._responses.pop(request_id) + + # Add to history + history_entry = { + "timestamp": request_params["timestamp"], + "question": question, + "response": response, + "model": request_params["model"], + "metadata": { + "temperature": request_params["temperature"], + "max_tokens": request_params["max_tokens"], + "system_prompt": request_params["system_prompt"], + "priority": priority, + } + } + self._add_to_history(history_entry) + + return response + + async def _process_queue(self) -> None: + """Process questions from queue.""" + while True: + try: + if self._is_rate_limited: + await asyncio.sleep(60) + self._is_rate_limited = False + continue + + priority, request_id, params = await self._question_queue.get() try: - priority, question_data = await self._question_queue.get() - question = question_data["question"] - params = question_data["params"] - - response_data = await self._make_api_call( - question, - model=params.get("model"), - temperature=params.get("temperature"), - max_tokens=params.get("max_tokens"), - system_prompt=params.get("system_prompt") - ) - - await self._handle_successful_response( - question, response_data, params, priority - ) - - except asyncio.QueueEmpty: - # Если очередь пуста, просто возвращаем существующие ответы - return self._responses - - except asyncio.TimeoutError: - _LOGGER.error("Timeout while processing API request") - await self._handle_api_error(question, "Request timeout") - - except KeyError as ke: - _LOGGER.error("Invalid question data format: %s", str(ke)) - await self._handle_api_error(question, f"Invalid data format: {ke}") - - except Exception as err: - _LOGGER.error("Error processing question: %s", str(err)) - await self._handle_api_error(question, err) - + response = await self._make_api_request(params) + self._responses[request_id] = response + self._update_metrics(response) + except Exception as e: + self._last_error = str(e) + _LOGGER.error("Error processing request: %s", str(e)) + self._responses[request_id] = f"Error: {str(e)}" finally: - self._is_processing = False self._question_queue.task_done() - except Exception as e: - _LOGGER.error("Critical error in _async_update_data: %s", str(e)) - self._is_ready = False - self._endpoint_status = "error" + except Exception as e: + self._last_error = str(e) + _LOGGER.error("Queue processing error: %s", str(e)) + await asyncio.sleep(1) - return self._responses + async def _make_api_request(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Make API request with retry logic.""" + attempts = 0 + while attempts < MAX_RETRIES: + try: + if self._is_anthropic: + response = await self._make_anthropic_call(**params) + else: + response = await self._make_openai_call(**params) + return response + except (AuthenticationError, ValueError) as e: + # Don't retry auth errors + raise + except RateLimitError: + self._is_rate_limited = True + await asyncio.sleep(RETRY_DELAY * (2 ** attempts)) + attempts += 1 + except Exception as e: + attempts += 1 + if attempts >= MAX_RETRIES: + raise + await asyncio.sleep(RETRY_DELAY) - async def _handle_successful_response(self, question, response_data, params, priority): - """Handle successful API response.""" - self._responses[question] = { - "question": question, - "response": response_data["response"], - "error": None, - "timestamp": dt_util.utcnow(), - "model": response_data["model"], - "temperature": params.get("temperature", self.temperature), - "max_tokens": params.get("max_tokens", self.max_tokens), - "response_time": response_data.get("response_time"), - "tokens": response_data.get("tokens", 0), - "priority": priority - } + async def _make_anthropic_call(self, **params) -> Dict[str, Any]: + """Make API call to Anthropic.""" + start_time = time.time() + messages = [] + if params.get("system_prompt"): + messages.append({"role": "system", "content": params["system_prompt"]}) + messages.append({"role": "user", "content": params["question"]}) - self._error_count = 0 - self._is_ready = True - self._endpoint_status = "connected" - self._request_count += 1 - self._tokens_used += response_data.get("tokens", 0) - self._last_request_time = time.time() - - # Fire event for successful response - self.hass.bus.async_fire(f"{DOMAIN}_response_received", { - "entity_id": self._entity_id, - "question": question, - "model": response_data["model"], - "tokens": response_data.get("tokens", 0) - }) - - _LOGGER.debug("Response received for question: %s", question) - - async def _handle_api_error(self, question: str, error: Exception) -> None: - """Handle API errors with retry logic.""" - self._error_count += 1 - self._performance_metrics["total_errors"] += 1 - error_msg = str(error) - - if isinstance(error, AuthenticationError): - error_msg = "Authentication failed - invalid API key" - self._is_ready = False - self._endpoint_status = "auth_error" - elif isinstance(error, RateLimitError): - error_msg = "Rate limit exceeded" - self._is_rate_limited = True - self._endpoint_status = "rate_limited" - # Implement exponential backoff - await asyncio.sleep(RETRY_DELAY * (2 ** (self._error_count - 1))) - elif isinstance(error, APIError): - if "maintenance" in str(error).lower(): - self._is_maintenance = True - self._endpoint_status = "maintenance" - error_msg = f"API error: {error}" - else: - self._endpoint_status = "error" - - self._responses[question] = { - "question": question, - "response": None, - "error": error_msg, - "timestamp": dt_util.utcnow(), - "model": self.model, - "temperature": self.temperature, - "max_tokens": self.max_tokens - } - - # Fire error event - self.hass.bus.async_fire(f"{DOMAIN}_error_occurred", { - "entity_id": self._entity_id, - "error_type": type(error).__name__, - "error_message": str(error), - "question": question - }) - - _LOGGER.error("API error (%s): %s", type(error).__name__, error_msg) - - if self._error_count >= self._MAX_ERRORS: - _LOGGER.warning( - "Multiple errors occurred (%d). Coordinator needs attention.", - self._error_count + try: + completion = await self.client.messages.create( + model=params.get("model", self.model), + messages=messages, + temperature=params.get("temperature", self.temperature), + max_tokens=params.get("max_tokens", self.max_tokens), ) + response_time = time.time() - start_time + + return { + "response": completion.content[0].text, + "model": completion.model, + "tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0, + "response_time": response_time + } + except Exception as e: + self._last_error = str(e) + _LOGGER.error("Anthropic API call error: %s", str(e)) + raise + + async def _make_openai_call(self, **params) -> Dict[str, Any]: + """Make API call to OpenAI.""" + start_time = time.time() + try: + if not params.get("question"): + raise ValueError("Question cannot be empty") + + messages = [] + if params.get("system_prompt"): + messages.append({"role": "system", "content": params["system_prompt"]}) + messages.append({"role": "user", "content": params["question"]}) + + api_params = { + "model": params.get("model", self.model), + "messages": messages, + "temperature": params.get("temperature", self.temperature), + "max_tokens": params.get("max_tokens", self.max_tokens), + } + + completion = await self.client.chat.completions.create(**api_params) + response_time = time.time() - start_time + + if not completion.choices: + raise ValueError("No response choices available") + + return { + "response": completion.choices[0].message.content.strip(), + "model": completion.model, + "tokens": completion.usage.total_tokens, + "response_time": response_time + } + + except Exception as e: + self._last_error = str(e) + error_msg = f"OpenAI API call failed: {str(e)}" + _LOGGER.error(error_msg, exc_info=True) + raise RuntimeError(error_msg) from e + + def _add_to_history(self, entry: Dict[str, Any]) -> None: + """Add entry to history with size limit.""" + self._history.append(entry) + if len(self._history) > self._max_history_size: + self._history.pop(0) + + async def get_history( + self, + limit: Optional[int] = None, + start_date: Optional[datetime] = None, + filter_model: Optional[str] = None, # Добавляем параметр + include_metadata: bool = False + ) -> List[Dict[str, Any]]: + """Get conversation history.""" + history = self._history + + if start_date: + history = [ + h for h in history + if datetime.fromisoformat(h["timestamp"]) >= start_date + ] + + # Добавляем фильтрацию по модели + if filter_model: + history = [h for h in history if h["model"] == filter_model] + + if not include_metadata: + history = [{ + "timestamp": h["timestamp"], + "question": h["question"], + "response": h["response"], + "model": h["model"] + } for h in history] + + if limit and limit > 0: + history = history[-limit:] + + return history + def _update_metrics(self, response_data: Dict[str, Any]) -> None: """Update performance metrics.""" + # Update response time metrics response_time = response_data.get("response_time", 0) current_avg = self._performance_metrics["avg_response_time"] self._performance_metrics["avg_response_time"] = ( @@ -293,259 +375,74 @@ class HATextAICoordinator(DataUpdateCoordinator): (self._request_count + 1) ) + # Update success rate total_requests = self._request_count + 1 self._performance_metrics["success_rate"] = ( (total_requests - self._performance_metrics["total_errors"]) / total_requests * 100 ) + # Update tokens metrics + tokens = response_data.get("tokens", 0) + self._tokens_used += tokens + self._performance_metrics["avg_tokens_per_request"] = ( + self._tokens_used / total_requests + ) + # Calculate requests per minute if self._last_request_time: time_diff = time.time() - self._last_request_time if time_diff > 0: self._performance_metrics["requests_per_minute"] = 60 / time_diff - async def _make_api_call( - self, - question: str, - model: Optional[str] = None, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - system_prompt: Optional[str] = None - ) -> Dict[str, Any]: - """Make API call to the selected service.""" + self._last_request_time = time.time() + self._request_count += 1 + + async def async_refresh_metrics(self) -> None: + """Refresh performance metrics.""" try: - start_time = dt_util.utcnow() - - if self._is_anthropic: - response = await self._make_anthropic_call( - question, model, temperature, max_tokens, system_prompt - ) - else: - response = await self._make_openai_call( - question, model, temperature, max_tokens, system_prompt - ) - - response_time = (dt_util.utcnow() - start_time).total_seconds() - - return { - **response, - "response_time": response_time - } - - except Exception as err: - _LOGGER.error("Error in API call: %s", err) - raise - - async def _make_anthropic_call( - self, - question: str, - model: Optional[str], - temperature: Optional[float], - max_tokens: Optional[int], - system_prompt: Optional[str] - ) -> Dict[str, Any]: - """Make API call to Anthropic.""" - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": question}) - - completion = await self.client.messages.create( - model=model or self.model, - messages=messages, - temperature=temperature if temperature is not None else self.temperature, - max_tokens=max_tokens if max_tokens is not None else self.max_tokens, - ) - - return { - "response": completion.content[0].text, - "model": completion.model, - "tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0 - } - - async def _make_openai_call( - self, - question: str, - model: Optional[str], - temperature: Optional[float], - max_tokens: Optional[int], - system_prompt: Optional[str] - ) -> Dict[str, Any]: - """Make API call to OpenAI.""" - completion = None - try: - # Input validation - if not question: - raise ValueError("Question cannot be empty") - - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": question}) - - # Prepare API parameters - api_params = { - "model": model or self.model, - "messages": messages, - "temperature": temperature if temperature is not None else self.temperature, - "max_tokens": max_tokens if max_tokens is not None else self.max_tokens, - } - - _LOGGER.debug("Making OpenAI API call with parameters: %s", api_params) - - # Make API call - completion = await self.client.chat.completions.create(**api_params) - - _LOGGER.debug("Raw API response: %s", completion) - - # Validate response - if completion is None: - raise ValueError("Received null response from API") - - if not hasattr(completion, 'choices') or not completion.choices: - raise ValueError(f"No choices in API response: {completion}") - - if not completion.choices[0] or not hasattr(completion.choices[0], 'message'): - raise ValueError(f"Invalid choice structure in response: {completion.choices}") - - message = completion.choices[0].message - if not hasattr(message, 'content') or not message.content: - raise ValueError(f"No content in message: {message}") - - response_text = message.content.strip() - - # Prepare response - response = { - "response": response_text, - "model": getattr(completion, 'model', api_params['model']), - "tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0 - } - - _LOGGER.debug("Processed OpenAI response: %s", response) - return response - - except Exception as e: - _LOGGER.error("OpenAI API call error: %s", str(e)) - if completion: - _LOGGER.debug("Failed response structure: %s", str(completion)) - _LOGGER.debug("Error details:", exc_info=True) - - # Добавляем контекст к ошибке - error_context = { - "question": question, - "model": model or self.model, - "error_type": type(e).__name__, - "error_message": str(e) - } - _LOGGER.debug("Error context: %s", error_context) - - raise RuntimeError(f"OpenAI API call failed: {str(e)}") from e - - async def async_ask_question( - self, - question: str, - system_prompt: Optional[str] = None, - model: Optional[str] = None, - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - priority: bool = False - ) -> None: - """Add question to queue with priority support.""" - try: - if not self._is_ready and self._error_count >= self._MAX_ERRORS: - _LOGGER.warning("Coordinator is not ready due to previous errors") - return - - _LOGGER.debug("Processing question: %s with model: %s", question, model or self.model) - - question_data = { - "question": question, - "params": { - "system_prompt": system_prompt, - "model": model, - "temperature": temperature, - "max_tokens": max_tokens + self._performance_metrics.update({ + "queue_size": self._question_queue.qsize(), + "last_update": dt_util.utcnow().isoformat(), + "uptime": time.time() - self._start_time if hasattr(self, '_start_time') else 0, + "memory_usage": { + "history_size": len(self._history), + "response_cache_size": len(self._responses) } - } - - # Priority: 0 for high priority, 1 for normal - priority_level = 0 if priority else 1 - - await self._question_queue.put((priority_level, question_data)) - _LOGGER.debug("Question added to queue with priority %d", priority_level) - - - except asyncio.QueueFull: - _LOGGER.error("Question queue is full. Try again later.") - raise RuntimeError("Queue is full") + }) except Exception as e: - _LOGGER.error("Error in async_ask_question: %s", str(e)) - raise - - - async def async_shutdown(self) -> None: - """Shutdown the coordinator.""" - try: - while not self._question_queue.empty(): - try: - self._question_queue.get_nowait() - self._question_queue.task_done() - except asyncio.QueueEmpty: - break - - if hasattr(self.client, 'close'): - await self.client.close() - elif hasattr(self.client, '_client') and hasattr(self.client._client, 'aclose'): - await self.client._client.aclose() - - self._is_ready = False - self._endpoint_status = "disconnected" - - # Final metrics update - self._update_final_metrics() - - except Exception as err: - _LOGGER.error("Error during shutdown: %s", err) - - def _update_final_metrics(self) -> None: - """Update final metrics before shutdown.""" - self._performance_metrics["final_success_rate"] = ( - (self._request_count - self._performance_metrics["total_errors"]) / - self._request_count * 100 if self._request_count > 0 else 0 - ) - self._performance_metrics["total_requests"] = self._request_count - self._performance_metrics["total_tokens"] = self._tokens_used - - @property - def entity_id(self) -> Optional[str]: - """Get entity ID.""" - return self._entity_id - - @entity_id.setter - def entity_id(self, value: str) -> None: - """Set entity ID.""" - self._entity_id = value - - @property - def performance_metrics(self) -> Dict[str, Any]: - """Return current performance metrics.""" - return self._performance_metrics - - @property - def queue_size(self) -> int: - """Return current queue size.""" - return self._question_queue.qsize() - - @property - def is_queue_full(self) -> bool: - """Return whether queue is full.""" - return self._question_queue.full() + _LOGGER.error("Error refreshing metrics: %s", str(e)) + async def export_metrics(self) -> Dict[str, Any]: + """Export detailed metrics and statistics.""" + await self.async_refresh_metrics() + return { + "entity_id": self._entity_id, + "performance": self._performance_metrics, + "requests": { + "total": self._request_count, + "successful": self._request_count - self._performance_metrics["total_errors"], + "failed": self._performance_metrics["total_errors"] + }, + "tokens": { + "total_used": self._tokens_used, + "average_per_request": self._performance_metrics["avg_tokens_per_request"] + }, + "status": { + "is_ready": self.is_ready, + "endpoint_status": self._endpoint_status, + "error_count": self._error_count, + "last_error": self._last_error + }, + "queue": { + "size": self.queue_size, + "is_full": self.is_queue_full + } + } @property def is_ready(self) -> bool: """Return if coordinator is ready.""" - return self._is_ready and self._error_count < self._MAX_ERRORS + return self._is_ready @property def is_processing(self) -> bool: @@ -559,194 +456,96 @@ class HATextAICoordinator(DataUpdateCoordinator): @property def is_maintenance(self) -> bool: - """Return if API is in maintenance.""" + """Return if coordinator is in maintenance mode.""" return self._is_maintenance @property - def error_count(self) -> int: - """Return current error count.""" - return self._error_count + def queue_size(self) -> int: + """Return current queue size.""" + return self._question_queue.qsize() @property - def request_count(self) -> int: - """Return total request count.""" - return self._request_count + def is_queue_full(self) -> bool: + """Return if queue is full.""" + return self.queue_size >= QUEUE_MAX_SIZE @property - def tokens_used(self) -> int: - """Return total tokens used.""" - return self._tokens_used - - @property - def api_version(self) -> str: - """Return API version.""" - return self._api_version + def last_error(self) -> Optional[str]: + """Return last error message.""" + return self._last_error @property def endpoint_status(self) -> str: - """Return endpoint status.""" + """Return endpoint connection status.""" return self._endpoint_status - @property - def responses(self) -> Dict[str, Any]: - """Return all responses.""" - return self._responses - - @property - def last_response(self) -> Optional[Dict[str, Any]]: - """Return the last response.""" - if not self._responses: - return None - - return list(self._responses.values())[-1] - - def reset_error_count(self) -> None: - """Reset error counter.""" - self._error_count = 0 - self._is_rate_limited = False - self._is_maintenance = False + async def async_start(self) -> None: + """Start coordinator operations.""" if not self._is_ready: - self._is_ready = True - self._endpoint_status = "connected" + await self.async_initialize() - async def clear_queue(self) -> None: - """Clear the question queue.""" - try: - while not self._question_queue.empty(): - try: - self._question_queue.get_nowait() - self._question_queue.task_done() - except asyncio.QueueEmpty: - break - except Exception as err: - _LOGGER.error("Error clearing queue: %s", err) - - async def clear_history(self) -> None: - """Clear response history.""" - self._responses.clear() - await self.async_refresh() - - def get_response(self, question: str) -> Optional[Dict[str, Any]]: - """Get specific response by question.""" - return self._responses.get(question) - - def get_recent_responses(self, limit: int = 10) -> List[Dict[str, Any]]: - """Get most recent responses.""" - return list(sorted( - self._responses.values(), - key=lambda x: x["timestamp"], - reverse=True - ))[:limit] - - async def retry_failed_requests(self) -> None: - """Retry failed requests.""" - failed_requests = [ - (q, r) for q, r in self._responses.items() - if r.get("error") is not None - ] - - for question, response in failed_requests: - await self.async_ask_question( - question, - system_prompt=response.get("system_prompt"), - model=response.get("model"), - temperature=response.get("temperature"), - max_tokens=response.get("max_tokens"), - priority=True - ) - - def update_system_prompt(self, new_prompt: str) -> None: - """Update system prompt.""" - self.system_prompt = new_prompt - _LOGGER.info("System prompt updated") - - async def health_check(self) -> Dict[str, Any]: - """Perform health check.""" - health_status = { - "is_ready": self.is_ready, - "is_processing": self.is_processing, - "is_rate_limited": self.is_rate_limited, - "is_maintenance": self.is_maintenance, - "error_count": self.error_count, - "endpoint_status": self.endpoint_status, - "queue_size": self.queue_size, - "request_count": self.request_count, - "tokens_used": self.tokens_used, - "performance_metrics": self.performance_metrics - } - - return health_status - - async def _handle_timeout_error(self) -> None: - """Handle timeout errors.""" - self._error_count += 1 - self._endpoint_status = "timeout" - self._performance_metrics["total_errors"] += 1 - - if not self._question_queue.empty(): - await self.clear_queue() - - # Fire timeout event - self.hass.bus.async_fire(f"{DOMAIN}_timeout_error", { - "error_count": self._error_count, - "endpoint_status": self._endpoint_status - }) - - def export_metrics(self) -> Dict[str, Any]: - """Export all metrics and statistics.""" - return { - "entity_id": self._entity_id, - "performance": self._performance_metrics, - "requests": { - "total": self._request_count, - "successful": self._request_count - self._performance_metrics["total_errors"], - "failed": self._performance_metrics["total_errors"] - }, - "tokens": { - "total_used": self._tokens_used, - "average_per_request": self._tokens_used / self._request_count if self._request_count > 0 else 0 - }, - "status": { - "is_ready": self.is_ready, - "endpoint_status": self._endpoint_status, - "error_count": self._error_count - }, - "queue": { - "size": self.queue_size, - "is_full": self.is_queue_full - } - } - - async def estimate_tokens(self, text: str) -> int: - """Estimate token count for text.""" - return len(text) // 4 - - def get_rate_limit_info(self) -> Dict[str, Any]: - """Get rate limit information.""" - return { - "is_rate_limited": self._is_rate_limited, - "retry_after": RETRY_DELAY * (2 ** (self._error_count - 1)) if self._is_rate_limited else 0 - } - - async def optimize_queue(self) -> None: - """Optimize queue by removing duplicate requests.""" - if self._question_queue.empty(): - return - - seen_questions = set() - optimized_queue = asyncio.PriorityQueue(maxsize=QUEUE_MAX_SIZE) + self._start_time = time.time() + self._is_processing = True + asyncio.create_task(self._process_queue()) + async def async_stop(self) -> None: + """Stop coordinator operations.""" + self._is_processing = False + self._is_ready = False + # Clear queues and caches while not self._question_queue.empty(): try: - priority, question_data = self._question_queue.get_nowait() - question = question_data["question"] - - if question not in seen_questions: - seen_questions.add(question) - await optimized_queue.put((priority, question_data)) - - self._question_queue.task_done() + await self._question_queue.get_nowait() except asyncio.QueueEmpty: break + self._responses.clear() - self._question_queue = optimized_queue + async def async_reset(self) -> None: + """Reset coordinator state.""" + await self.async_stop() + self._error_count = 0 + self._request_count = 0 + self._tokens_used = 0 + self._last_error = None + self._history.clear() + self._performance_metrics = { + "avg_response_time": 0, + "total_errors": 0, + "success_rate": 100, + "requests_per_minute": 0, + "avg_tokens_per_request": 0, + } + await self.async_start() + + async def async_clear_history(self) -> None: + """Clear conversation history.""" + self._history.clear() + + def set_system_prompt(self, prompt: str) -> None: + """Set system prompt.""" + if not isinstance(prompt, str): + raise ValueError("System prompt must be a string") + self.system_prompt = prompt + + async def _async_update_data(self) -> Dict[str, Any]: + """Update data from API.""" + try: + await self.async_refresh_metrics() + return { + "status": self.endpoint_status, + "metrics": self._performance_metrics, + "last_update": dt_util.utcnow().isoformat() + } + except Exception as e: + self._last_error = str(e) + _LOGGER.error("Error updating data: %s", str(e)) + raise UpdateFailed(f"Error updating data: {str(e)}") + + async def __aenter__(self): + """Async enter.""" + await self.async_start() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async exit.""" + await self.async_stop() diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index 43a1136..10871cc 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -11,7 +11,7 @@ "temperature": "Response creativity (0-2, lower = more focused and consistent)", "max_tokens": "Maximum response length (1-4096 tokens)", "api_endpoint": "Custom API endpoint URL (optional)", - "request_interval": "Minimum time between requests in seconds (min: 0.1)", + "request_interval": "Minimum time between requests in seconds (0.1-60)", "name": "Name for this integration instance (e.g., 'GPT Assistant', 'Claude Helper')" } } @@ -28,6 +28,9 @@ "queue_full": "Request queue full - try again later", "invalid_url_format": "Invalid API endpoint URL format", "invalid_input": "Invalid configuration parameters", + "invalid_parameters": "Invalid service parameters provided", + "service_unavailable": "Service temporarily unavailable", + "context_length": "Maximum context length exceeded", "unknown": "Unexpected error - check logs for details" } }, @@ -39,8 +42,8 @@ "data": { "model": "Select AI model (provider-specific)", "temperature": "Response creativity (0-2)", - "max_tokens": "Maximum response length in tokens", - "request_interval": "Minimum seconds between requests" + "max_tokens": "Maximum response length in tokens (1-4096)", + "request_interval": "Minimum seconds between requests (0.1-60)" } } } @@ -54,6 +57,10 @@ "name": "Question", "description": "Your question or prompt for the AI model" }, + "system_prompt": { + "name": "System Prompt", + "description": "Optional context or instructions for this specific question" + }, "model": { "name": "Model", "description": "Optional specific AI model for this request" @@ -67,6 +74,42 @@ "description": "Optional maximum response length (1-4096 tokens)" } } + }, + "clear_history": { + "name": "Clear History", + "description": "Delete all stored questions and responses from the conversation history." + }, + "get_history": { + "name": "Get History", + "description": "Retrieve recent conversation history with optional filtering.", + "fields": { + "limit": { + "name": "Limit", + "description": "Maximum number of entries to return (1-100)" + }, + "start_date": { + "name": "Start Date", + "description": "Optional date to filter history from" + }, + "include_metadata": { + "name": "Include Metadata", + "description": "Include additional response metadata" + }, + "sort_order": { + "name": "Sort Order", + "description": "Order of results (asc/desc)" + } + } + }, + "set_system_prompt": { + "name": "Set System Prompt", + "description": "Set default system behavior instructions for all future conversations.", + "fields": { + "prompt": { + "name": "System Prompt", + "description": "Instructions that define how the AI should behave" + } + } } }, "entity": { @@ -79,7 +122,28 @@ "processing": "Processing", "error": "Error", "disconnected": "Disconnected", - "rate_limited": "Rate Limited" + "rate_limited": "Rate Limited", + "maintenance": "Maintenance", + "retrying": "Retrying", + "queued": "Queued", + "updating": "Updating" + }, + "state_attributes": { + "question": "Last question asked", + "response": "Last response received", + "last_updated": "Time of last update", + "model": "Current AI model", + "temperature": "Temperature setting", + "max_tokens": "Max tokens setting", + "total_responses": "Total responses", + "system_prompt": "Current system prompt", + "response_time": "Last response time", + "queue_size": "Queue size", + "api_status": "API status", + "error_count": "Error count", + "last_error": "Last error", + "tokens_used": "Total tokens used", + "request_count": "Total requests" } } }