"""The AI Agent implementation with multiple provider support. Example config: ai_agent_ha: ai_provider: openai # or 'llama', 'gemini', 'openrouter', 'anthropic', 'alter', 'zai', 'local_ollama', 'openai_compatible' llama_token: "..." openai_token: "..." gemini_token: "..." openrouter_token: "..." anthropic_token: "..." alter_token: "..." zai_token: "..." zai_endpoint: "general" # or 'coding' for z.ai (3× usage, 1/7 cost) local_ollama_url: "http://localhost:11434/api/generate" # Required for local_ollama provider openai_compatible_url: "http://example.com/v1/" or "http://localhost/v1/" # (Url must end with /v1/) # Model configuration (optional, defaults will be used if not specified) models: openai: "gpt-3.5-turbo" # or "gpt-4", "gpt-4-turbo", etc. llama: "Llama-4-Maverick-17B-128E-Instruct-FP8" gemini: "gemini-2.5-flash" # or "gemini-2.5-pro", "gemini-2.0-flash", etc. openrouter: "openai/gpt-4o" # or any model available on OpenRouter anthropic: "claude-sonnet-4-5-20250929" # or "claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022", "claude-3-opus-20240229", etc. alter: "your-model-name" # model name for Alter API zai: "glm-4.7" # model name for z.ai API (glm-4.7, glm-4.6, glm-4.5, etc.) local_ollama: "llama3.2" # model name for local_ollama provider (optional if your API doesn't require it) openai_compatible: "model unique-id or your-model-name" # model name for your OpenAI-compatible endpoint """ import asyncio import json import logging import os import shutil import tempfile import time from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Union from urllib.parse import quote import aiohttp import yaml # type: ignore[import-untyped] from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util from .const import CONF_OPENAI_BASE_URL, CONF_WEATHER_ENTITY, DOMAIN _LOGGER = logging.getLogger(__name__) # === Security Utilities === def sanitize_for_logging(data: Any, mask: str = "***REDACTED***") -> Any: """Sanitize sensitive data for safe logging. Recursively masks sensitive fields like API keys, tokens, passwords, etc. This prevents accidental exposure of credentials in debug logs. Args: data: The data structure to sanitize (dict, list, str, etc.) mask: The string to use for masking sensitive values Returns: A sanitized copy of the data with sensitive fields masked Example: >>> config = {"openai_token": "sk-abc123", "ai_provider": "openai"} >>> sanitize_for_logging(config) {"openai_token": "***REDACTED***", "ai_provider": "openai"} """ # Sensitive field patterns (case-insensitive) sensitive_patterns = { "token", "key", "password", "secret", "credential", "auth", "authorization", "api_key", "apikey", "llama_token", "openai_token", "gemini_token", "anthropic_token", "openrouter_token", "alter_token", "zai_token", } if isinstance(data, dict): sanitized = {} for key, value in data.items(): # Check if key matches any sensitive pattern key_lower = str(key).lower() is_sensitive = any(pattern in key_lower for pattern in sensitive_patterns) if is_sensitive: sanitized[key] = mask else: # Recursively sanitize nested structures sanitized[key] = sanitize_for_logging(value, mask) return sanitized elif isinstance(data, list): return [sanitize_for_logging(item, mask) for item in data] elif isinstance(data, tuple): return tuple(sanitize_for_logging(item, mask) for item in data) else: # Primitive types (str, int, bool, etc.) - return as-is return data # === AI Client Abstractions === class NonRetryableAIError(Exception): """Provider error that cannot succeed on retry (e.g. deterministic HTTP 4xx). Raised for client errors like "prompt is too long" where re-sending the identical payload is guaranteed to fail again (see issue #80). Rate limiting (429) and request timeouts (408) stay retryable. """ class RateLimitedAIError(Exception): """Provider rate limit (HTTP 429) with an optional server-suggested wait. Carries the provider's retry-after hint so the retry loop can wait long enough for a per-minute token window to reset instead of burning all retries in a few seconds (see issue #80). """ def __init__(self, message: str, retry_after: Optional[float] = None): super().__init__(message) self.retry_after = retry_after class BaseAIClient: async def get_response(self, messages, **kwargs): raise NotImplementedError class LocalOllamaClient(BaseAIClient): """Client for Ollama-style local models using /api/generate style endpoints.""" def __init__(self, url, model=""): self.url = url self.model = model async def get_response(self, messages, **kwargs): _LOGGER.debug( "Making request to local Ollama API with model: '%s' at URL: %s", self.model or "[NO MODEL SPECIFIED]", self.url, ) if not self.model: _LOGGER.warning( "No model specified for local Ollama API request. Some APIs (like Ollama) require a model name." ) headers = {"Content-Type": "application/json"} # Format user prompt from messages prompt = "" for message in messages: role = message.get("role", "") content = message.get("content", "") # Simple formatting: prefixing each message with its role if role == "system": prompt += f"System: {content}\n\n" elif role == "user": prompt += f"User: {content}\n\n" elif role == "assistant": prompt += f"Assistant: {content}\n\n" # Add final prompt prefix for the assistant's response prompt += "Assistant: " # Build a generic payload that works with most local Ollama-style API servers payload = { "prompt": prompt, "stream": False, # Disable streaming to get a single complete response # max_tokens omitted - let local model use its default capacity } # Add model if specified if self.model: payload["model"] = self.model # Note: Payloads don't contain auth tokens (those are in headers), but may contain user prompts _LOGGER.debug( "Local Ollama API request payload: %s", json.dumps(payload, indent=2) ) # Ollama-specific validation if "model" not in payload or not payload["model"]: _LOGGER.warning( "Missing 'model' field in request to local Ollama API. This may cause issues with Ollama." ) elif self.url and "ollama" in self.url.lower(): _LOGGER.debug( "Detected Ollama URL, ensuring model is specified: %s", payload.get("model"), ) async with aiohttp.ClientSession() as session: async with session.post( self.url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: if resp.status != 200: error_text = await resp.text() _LOGGER.error( "Local Ollama API error %d: %s", resp.status, error_text ) # Provide more specific error messages for common Ollama issues if resp.status == 404: if "model" in payload and payload["model"]: raise Exception( f"Model '{payload['model']}' not found. Please ensure the model is installed in Ollama using: ollama pull {payload['model']}" ) else: raise Exception( "Local Ollama API endpoint not found. Please check the URL and ensure Ollama is running." ) elif resp.status == 400: raise Exception( f"Bad request to local Ollama API. Error: {error_text}" ) else: raise Exception( f"Local Ollama API error {resp.status}: {error_text}" ) try: response_text = await resp.text() _LOGGER.debug( "Local Ollama API response (first 200 chars): %s", response_text[:200], ) _LOGGER.debug("Local Ollama API response status: %d", resp.status) # Sanitize headers to avoid logging any auth tokens _LOGGER.debug( "Local Ollama API response headers: %s", sanitize_for_logging(dict(resp.headers)), ) # Try to parse as JSON try: data = json.loads(response_text) # Try common response formats # Ollama format - return only the response text if "response" in data: response_content = data["response"] _LOGGER.debug( "Extracted response content: %s", ( response_content[:100] if response_content else "[EMPTY]" ), ) # Check if response is empty or None if not response_content or response_content.strip() == "": _LOGGER.warning( "Ollama returned empty response. Full data: %s", data, ) # Check if this is a loading response if data.get("done_reason") == "load": _LOGGER.warning( "Ollama is still loading the model. Please wait and try again." ) return json.dumps( { "request_type": "final_response", "response": "The AI model is still loading. Please wait a moment and try again.", } ) elif data.get("done") is False: _LOGGER.warning( "Ollama response indicates it's not done yet." ) return json.dumps( { "request_type": "final_response", "response": "The AI is still processing your request. Please try again.", } ) else: return json.dumps( { "request_type": "final_response", "response": "The AI returned an empty response. Please try rephrasing your question.", } ) # Check if the response looks like JSON response_content = response_content.strip() if response_content.startswith( "{" ) and response_content.endswith("}"): try: # Validate that it's actually JSON and contains valid request_type parsed_json = json.loads(response_content) if ( isinstance(parsed_json, dict) and "request_type" in parsed_json ): _LOGGER.debug( "Local Ollama model provided valid JSON response" ) return response_content else: _LOGGER.debug( "JSON missing request_type, treating as plain text" ) except json.JSONDecodeError: _LOGGER.debug( "Invalid JSON from local Ollama model, treating as plain text" ) pass # If it's plain text, wrap it in the expected JSON format wrapped_response = { "request_type": "final_response", "response": response_content, } _LOGGER.debug("Wrapped plain text response in JSON format") return json.dumps(wrapped_response) # OpenAI-like format elif "choices" in data and len(data["choices"]) > 0: choice = data["choices"][0] if "message" in choice and "content" in choice["message"]: content = choice["message"]["content"] elif "text" in choice: content = choice["text"] else: content = str(data) # Check if it's valid JSON with request_type content = content.strip() if content.startswith("{") and content.endswith("}"): try: parsed_json = json.loads(content) if ( isinstance(parsed_json, dict) and "request_type" in parsed_json ): _LOGGER.debug( "Local Ollama model provided valid JSON response (OpenAI format)" ) return content else: _LOGGER.debug( "JSON missing request_type, treating as plain text (OpenAI format)" ) except json.JSONDecodeError: _LOGGER.debug( "Invalid JSON from local Ollama model, treating as plain text (OpenAI format)" ) pass # Wrap in expected format if plain text wrapped_response = { "request_type": "final_response", "response": content, } return json.dumps(wrapped_response) # Generic content field elif "content" in data: content = data["content"] content = content.strip() if content.startswith("{") and content.endswith("}"): try: parsed_json = json.loads(content) if ( isinstance(parsed_json, dict) and "request_type" in parsed_json ): _LOGGER.debug( "Local Ollama model provided valid JSON response (generic format)" ) return content else: _LOGGER.debug( "JSON missing request_type, treating as plain text (generic format)" ) except json.JSONDecodeError: _LOGGER.debug( "Invalid JSON from local Ollama model, treating as plain text (generic format)" ) pass wrapped_response = { "request_type": "final_response", "response": content, } return json.dumps(wrapped_response) # Handle case where no standard fields are found _LOGGER.warning( "No standard response fields found in local Ollama API response. Full response: %s", data, ) # Check for Ollama-specific edge cases if data.get("done_reason") == "load": return json.dumps( { "request_type": "final_response", "response": "The AI model is still loading. Please wait a moment and try again.", } ) elif data.get("done") is False: return json.dumps( { "request_type": "final_response", "response": "The AI is still processing your request. Please try again.", } ) elif "message" in data: # Some APIs use "message" field message_content = data["message"] if ( isinstance(message_content, dict) and "content" in message_content ): content = message_content["content"] else: content = str(message_content) return json.dumps( {"request_type": "final_response", "response": content} ) # Return the whole data as string if we can't find a specific field return json.dumps( { "request_type": "final_response", "response": f"Received unexpected response format from local Ollama API: {str(data)}", } ) except json.JSONDecodeError: # If not JSON, check if it's a JSON response that got corrupted by wrapping response_text = response_text.strip() if response_text.startswith("{") and response_text.endswith( "}" ): try: parsed_json = json.loads(response_text) if ( isinstance(parsed_json, dict) and "request_type" in parsed_json ): _LOGGER.debug( "Local Ollama model provided valid JSON response (direct)" ) return response_text except json.JSONDecodeError: pass # If not valid JSON, wrap the raw text in expected format _LOGGER.debug("Response is not JSON, wrapping plain text") wrapped_response = { "request_type": "final_response", "response": response_text, } return json.dumps(wrapped_response) except Exception as e: _LOGGER.error( "Failed to parse local Ollama API response: %s", str(e) ) raise Exception( f"Failed to parse local Ollama API response: {str(e)}" ) class OpenaiCompatibleClient(BaseAIClient): """Client for OpenAI-compatible endpoints (e.g., LM Studio, vLLM, etc.). Expected URL format: http://example.com/v1/ This client sends chat completions requests to: {url}/chat/completions No API key is required by default, but can be provided if needed. """ def __init__(self, base_url, model="", api_key=None): # Ensure base_url ends with /v1/ style segment base_url = (base_url or "").strip().rstrip("/") if not base_url: raise Exception("openai_compatible_url is required and must not be empty") self.base_url = base_url # Derive chat completions endpoint self.api_url = f"{self.base_url}/chat/completions" self.model = model self.api_key = api_key or "" # Optional; many local endpoints don’t require it async def get_response(self, messages, **kwargs): _LOGGER.debug( "Making request to OpenAI-compatible endpoint at %s with model: %s", self.api_url, self.model or "[NO MODEL SPECIFIED]", ) if not self.model: _LOGGER.warning( "No model specified for OpenAI-compatible request. Some servers require a model name." ) headers = { "Content-Type": "application/json", } # Add Authorization header only if an API key is set if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" payload = { "model": self.model, "messages": messages, "temperature": 0.7, "top_p": 0.9, # max_tokens omitted - let server/model use its default capacity } _LOGGER.debug( "OpenAI-compatible request payload: %s", json.dumps(payload, indent=2), ) async with aiohttp.ClientSession() as session: async with session.post( self.api_url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: response_text = await resp.text() _LOGGER.debug("OpenAI-compatible API response status: %d", resp.status) _LOGGER.debug( "OpenAI-compatible API response (first 500 chars): %s", response_text[:500], ) if resp.status != 200: _LOGGER.error( "OpenAI-compatible API error %d: %s", resp.status, response_text, ) raise Exception( f"OpenAI-compatible API error {resp.status}: {response_text}" ) try: data = json.loads(response_text) except json.JSONDecodeError as e: _LOGGER.error( "Failed to parse OpenAI-compatible response as JSON: %s", str(e) ) raise Exception( f"Invalid JSON response from OpenAI-compatible API: {response_text[:200]}" ) # Extract text from OpenAI-compatible response choices = data.get("choices", []) if choices and "message" in choices[0]: content = choices[0]["message"].get("content", "") if not content: _LOGGER.warning( "OpenAI-compatible API returned empty content in message" ) _LOGGER.debug( "Full OpenAI-compatible API response: %s", json.dumps(data, indent=2), ) return content else: _LOGGER.warning( "OpenAI-compatible API response missing expected structure" ) _LOGGER.debug( "Full OpenAI-compatible API response: %s", json.dumps(data, indent=2), ) return str(data) class LlamaClient(BaseAIClient): def __init__(self, token, model="Llama-4-Maverick-17B-128E-Instruct-FP8"): self.token = token self.model = model self.api_url = "https://api.llama.com/v1/chat/completions" async def get_response(self, messages, **kwargs): _LOGGER.debug("Making request to Llama API with model: %s", self.model) headers = { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", } payload = { "model": self.model, "messages": messages, "temperature": 0.7, "top_p": 0.9, # max_tokens omitted - let Llama use the model's default capacity } _LOGGER.debug("Llama request payload: %s", json.dumps(payload, indent=2)) async with aiohttp.ClientSession() as session: async with session.post( self.api_url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: if resp.status != 200: error_text = await resp.text() _LOGGER.error("Llama API error %d: %s", resp.status, error_text) raise Exception(f"Llama API error {resp.status}") data = await resp.json() # Extract text from Llama response completion = data.get("completion_message", {}) content = completion.get("content", {}) return content.get("text", str(data)) async def fetch_openai_models(base_url, api_key, timeout=10): """Fetch available OpenAI models dynamically. Returns a list of model IDs suitable for chat (gpt-*, o*). On failure, returns a small safe fallback list. """ if not base_url: base_url = "https://api.openai.com/v1" url = f"{base_url.rstrip('/')}/models" fallback_models = [ "gpt-4.1-mini", "gpt-4o-mini", "o4-mini", ] try: async with aiohttp.ClientSession() as session: async with session.get( url, headers={"Authorization": f"Bearer {api_key}"}, timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: if resp.status != 200: _LOGGER.warning( "Failed to fetch OpenAI models (status=%d), using fallback list", resp.status, ) return fallback_models data = await resp.json() models = data.get("data", []) if not isinstance(models, list): return fallback_models # Filter likely chat-capable models chat_models = [] for m in models: mid = m.get("id", "") if isinstance(mid, str) and ( mid.startswith("gpt-") or mid.startswith("o") ): chat_models.append(mid) if not chat_models: return fallback_models chat_models.sort() _LOGGER.debug("Fetched %d OpenAI chat models", len(chat_models)) return chat_models except Exception as e: _LOGGER.warning("Error fetching OpenAI models, using fallback list: %s", e) return fallback_models async def fetch_gemini_models(api_key, timeout=10): """Fetch available Gemini models dynamically. Returns a list of model IDs suitable for chat. On failure, returns a small safe fallback list. """ if not api_key: return [ "gemini-2.5-flash", "gemini-2.5-pro", ] url = f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}" fallback_models = [ "gemini-2.5-flash", "gemini-2.5-pro", ] try: async with aiohttp.ClientSession() as session: async with session.get( url, timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: if resp.status != 200: _LOGGER.warning( "Failed to fetch Gemini models (status=%d), using fallback list", resp.status, ) return fallback_models data = await resp.json() models = data.get("models", []) if not isinstance(models, list): return fallback_models # Filter likely chat-capable models chat_models = [] for m in models: mid = m.get("name", "") if isinstance(mid, str) and "gemini" in mid.lower(): # Extract model ID from name like "models/gemini-2.5-flash" model_id = mid.split("/")[-1] if "/" in mid else mid chat_models.append(model_id) if not chat_models: return fallback_models chat_models.sort() _LOGGER.debug("Fetched %d Gemini models", len(chat_models)) return chat_models except Exception as e: _LOGGER.warning("Error fetching Gemini models, using fallback list: %s", e) return fallback_models async def fetch_openai_compatible_models(base_url, api_key=None, timeout=10): """Fetch available models from an OpenAI-compatible endpoint. Many local/self-hosted endpoints (LM Studio, vLLM, etc.) support /v1/models. If the endpoint does not support it, fall back to ["Custom..."] only. """ if not base_url: return ["Custom..."] url = f"{base_url.rstrip('/')}/models" try: async with aiohttp.ClientSession() as session: headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" async with session.get( url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout), ) as resp: if resp.status not in (200, 201): _LOGGER.debug( "OpenAI-compatible endpoint did not support /v1/models (status=%d)", resp.status, ) return ["Custom..."] data = await resp.json() models = data.get("data", []) if not isinstance(models, list): return ["Custom..."] # Collect all model IDs model_ids = [] for m in models: mid = m.get("id", "") if isinstance(mid, str) and mid: model_ids.append(mid) if not model_ids: return ["Custom..."] model_ids.sort() _LOGGER.debug( "Fetched %d models from OpenAI-compatible endpoint", len(model_ids) ) return model_ids except Exception as e: _LOGGER.debug( "Error fetching models from OpenAI-compatible endpoint, using Custom only: %s", e, ) return ["Custom..."] class OpenAIClient(BaseAIClient): def __init__(self, token, model="gpt-4.1-mini", base_url=None): self.token = token self.model = model # Default endpoint is OpenAI's Responses API. If the user has pointed Base URL # at a third-party "OpenAI-compatible" gateway (Open WebUI, LM Studio, vLLM, # LiteLLM, ...), those servers only implement /chat/completions, so switch # to Chat Completions when the base URL is not api.openai.com. if base_url and base_url.strip(): base = base_url.strip().rstrip("/") if "api.openai.com" in base: self.api_url = f"{base}/responses" self.use_chat_completions = False else: self.api_url = f"{base}/chat/completions" self.use_chat_completions = True else: self.api_url = "https://api.openai.com/v1/responses" self.use_chat_completions = False def _is_restricted_model(self): """Check if the model has restricted parameters (no temperature, top_p, etc.).""" # Models that don't support temperature, top_p and other parameters restricted_models = ["o3-mini", "o3", "o1-mini", "o1-preview", "o1", "gpt-5"] model_lower = self.model.lower() return any(model_id in model_lower for model_id in restricted_models) async def get_response(self, messages, **kwargs): _LOGGER.debug("Making request to OpenAI API with model: %s", self.model) # Validate token if not self.token or not self.token.startswith("sk-"): raise Exception("Invalid OpenAI API key format") headers = { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", } if self.use_chat_completions: # Chat Completions: forward messages as-is. payload = { "model": self.model, "messages": messages, } if not self._is_restricted_model(): payload["temperature"] = 0.7 payload["top_p"] = 0.9 else: # Responses API: flatten messages into a single input string. parts = [] for msg in messages: role = (msg.get("role") or "user").lower() content = msg.get("content") or "" if role == "system": parts.append(f"System: {content}") elif role == "user": parts.append(f"User: {content}") elif role == "assistant": parts.append(f"Assistant: {content}") else: parts.append(f"{role.capitalize()}: {content}") payload = { "model": self.model, "input": "\n\n".join(parts), } _LOGGER.debug("OpenAI request payload: %s", json.dumps(payload, indent=2)) async with aiohttp.ClientSession() as session: async with session.post( self.api_url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: response_text = await resp.text() _LOGGER.debug("OpenAI API response status: %d", resp.status) _LOGGER.debug("OpenAI API response: %s", response_text[:500]) if resp.status != 200: _LOGGER.error("OpenAI API error %d: %s", resp.status, response_text) raise Exception(f"OpenAI API error {resp.status}: {response_text}") try: data = json.loads(response_text) except json.JSONDecodeError as e: _LOGGER.error("Failed to parse OpenAI response as JSON: %s", str(e)) raise Exception( f"Invalid JSON response from OpenAI: {response_text[:200]}" ) if self.use_chat_completions: # Chat Completions response shape choices = data.get("choices", []) if choices and "message" in choices[0]: return choices[0]["message"].get("content", "") or "" _LOGGER.warning("OpenAI response missing expected structure") _LOGGER.debug( "Full OpenAI response: %s", json.dumps(data, indent=2) ) return str(data) # Extract text from OpenAI Responses API. # Primary field: output_text is an SDK-only convenience property # and is normally absent from the raw HTTP body, so it is only a # fast path when a gateway happens to include it as a string. content = data.get("output_text") if isinstance(content, str) and content: return content # Fallback: the Responses API returns # output: [ { type: "message", content: [ { type: "output_text", # text: "..." }, ... ] }, ... ] # plus, for reasoning models, leading items (e.g. type "reasoning") # that carry no text. Concatenate every output_text block we find. # NOTE: item["content"] is a LIST here, so it must never be # returned directly (that caused issue #75: 'list' object has no # attribute 'strip'). output = data.get("output") if isinstance(output, list): text_parts = [] for item in output: if not isinstance(item, dict): continue content_blocks = item.get("content") if isinstance(content_blocks, list): for block in content_blocks: if isinstance(block, dict) and isinstance( block.get("text"), str ): text_parts.append(block["text"]) elif isinstance(content_blocks, str) and content_blocks: text_parts.append(content_blocks) elif isinstance(item.get("text"), str): text_parts.append(item["text"]) if text_parts: return "".join(text_parts) # Last resort: return full response as string _LOGGER.warning("OpenAI response missing expected structure") _LOGGER.debug("Full OpenAI response: %s", json.dumps(data, indent=2)) return str(data) class GeminiClient(BaseAIClient): def __init__(self, token, model="gemini-2.5-flash"): self.token = token.strip() if token else token # Strip whitespace from token self.model = model # Use v1beta for all models as per Google's current API documentation # All Gemini 2.0/2.5 models are available on v1beta endpoint self.api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" async def get_response(self, messages, **kwargs): _LOGGER.debug("Making request to Gemini API with model: %s", self.model) # Validate token if not self.token: raise Exception("Missing Gemini API key") headers = {"Content-Type": "application/json"} # Convert OpenAI-style messages to Gemini format gemini_contents = [] for message in messages: role = message.get("role", "user") content = message.get("content", "") if role == "system": # Gemini doesn't have a system role, so we prepend it to the first user message if not gemini_contents: gemini_contents.append( {"role": "user", "parts": [{"text": f"System: {content}"}]} ) else: # Add system message as user message gemini_contents.append( {"role": "user", "parts": [{"text": f"System: {content}"}]} ) elif role == "user": gemini_contents.append({"role": "user", "parts": [{"text": content}]}) elif role == "assistant": gemini_contents.append({"role": "model", "parts": [{"text": content}]}) payload = { "contents": gemini_contents, "generationConfig": { "temperature": 0.7, "topP": 0.9, # maxOutputTokens omitted - let Gemini use model's maximum capacity }, } # Add API key as query parameter (URL encoded) url_with_key = f"{self.api_url}?key={quote(self.token)}" _LOGGER.debug("Gemini request payload: %s", json.dumps(payload, indent=2)) async with aiohttp.ClientSession() as session: async with session.post( url_with_key, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: response_text = await resp.text() _LOGGER.debug("Gemini API response status: %d", resp.status) _LOGGER.debug("Gemini API response: %s", response_text[:500]) if resp.status != 200: _LOGGER.error("Gemini API error %d: %s", resp.status, response_text) raise Exception(f"Gemini API error {resp.status}: {response_text}") try: data = json.loads(response_text) except json.JSONDecodeError as e: _LOGGER.error("Failed to parse Gemini response as JSON: %s", str(e)) raise Exception( f"Invalid JSON response from Gemini: {response_text[:200]}" ) # Log token usage for debugging, especially for Gemini 2.5 extended thinking usage_metadata = data.get("usageMetadata", {}) if usage_metadata: _LOGGER.debug( "Gemini token usage - prompt: %d, total: %d, thoughts: %d", usage_metadata.get("promptTokenCount", 0), usage_metadata.get("totalTokenCount", 0), usage_metadata.get("thoughtsTokenCount", 0), ) # Extract text from Gemini response candidates = data.get("candidates", []) if candidates and "content" in candidates[0]: # Check finish reason for potential issues finish_reason = candidates[0].get("finishReason", "") if finish_reason == "MAX_TOKENS": _LOGGER.warning( "Gemini response truncated due to MAX_TOKENS limit. " "Thoughts used: %d tokens. Consider increasing maxOutputTokens.", usage_metadata.get("thoughtsTokenCount", 0), ) parts = candidates[0]["content"].get("parts", []) if parts: content = parts[0].get("text", "") if not content: _LOGGER.warning("Gemini returned empty text content") _LOGGER.debug( "Full Gemini response: %s", json.dumps(data, indent=2) ) return content else: _LOGGER.warning("Gemini response missing parts") _LOGGER.debug( "Full Gemini response: %s", json.dumps(data, indent=2) ) else: _LOGGER.warning("Gemini response missing expected structure") _LOGGER.debug( "Full Gemini response: %s", json.dumps(data, indent=2) ) return str(data) class AnthropicClient(BaseAIClient): def __init__(self, token, model="claude-sonnet-4-5-20250929"): self.token = token self.model = model self.api_url = "https://api.anthropic.com/v1/messages" async def get_response(self, messages, **kwargs): _LOGGER.debug("Making request to Anthropic API with model: %s", self.model) headers = { "x-api-key": self.token, "Content-Type": "application/json", "anthropic-version": "2023-06-01", } # Convert OpenAI-style messages to Anthropic format system_message = None anthropic_messages = [] for message in messages: role = message.get("role", "user") content = message.get("content", "") if role == "system": # Anthropic uses a separate system parameter system_message = content elif role == "user": anthropic_messages.append({"role": "user", "content": content}) elif role == "assistant": anthropic_messages.append({"role": "assistant", "content": content}) payload = { "model": self.model, "max_tokens": 8192, # Maximum for Anthropic Claude models "temperature": 0.7, "messages": anthropic_messages, } # Add system message if present if system_message: payload["system"] = system_message _LOGGER.debug("Anthropic request payload: %s", json.dumps(payload, indent=2)) async with aiohttp.ClientSession() as session: async with session.post( self.api_url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: if resp.status != 200: error_text = await resp.text() _LOGGER.error("Anthropic API error %d: %s", resp.status, error_text) # Surface the API's own error message (e.g. "prompt is too # long: N tokens > 200000 maximum") instead of a bare status # code so the user can see why the request failed (issue #80). try: detail = json.loads(error_text)["error"]["message"] except (ValueError, KeyError, TypeError): detail = error_text[:300] message = f"Anthropic API error {resp.status}: {detail}" if resp.status == 429: # Honor the server's retry-after hint so the retry # loop waits out per-minute token windows. retry_after = None try: header = resp.headers.get("retry-after") if header is not None: retry_after = float(header) except (TypeError, ValueError): retry_after = None raise RateLimitedAIError(message, retry_after=retry_after) if 400 <= resp.status < 500 and resp.status != 408: # Deterministic client error - retrying the identical # payload cannot succeed, so don't burn retries on it. raise NonRetryableAIError(message) raise Exception(message) data = await resp.json() # Extract text from Anthropic response content_blocks = data.get("content", []) if content_blocks and isinstance(content_blocks, list): # Get the text from the first content block for block in content_blocks: if block.get("type") == "text": return block.get("text", str(data)) return str(data) class OpenRouterClient(BaseAIClient): def __init__(self, token, model="openai/gpt-4o"): self.token = token self.model = model self.api_url = "https://openrouter.ai/api/v1/chat/completions" async def get_response(self, messages, **kwargs): _LOGGER.debug("Making request to OpenRouter API with model: %s", self.model) headers = { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", "HTTP-Referer": "https://home-assistant.io", # Optional for OpenRouter rankings "X-Title": "Home Assistant AI Agent", # Optional for OpenRouter rankings } payload = { "model": self.model, "messages": messages, "temperature": 0.7, "top_p": 0.9, # max_tokens omitted - let OpenRouter use the model's maximum capacity } _LOGGER.debug("OpenRouter request payload: %s", json.dumps(payload, indent=2)) async with aiohttp.ClientSession() as session: async with session.post( self.api_url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: if resp.status != 200: error_text = await resp.text() _LOGGER.error( "OpenRouter API error %d: %s", resp.status, error_text ) raise Exception(f"OpenRouter API error {resp.status}") data = await resp.json() # Extract text from OpenRouter response (OpenAI-compatible format) choices = data.get("choices", []) if not choices: _LOGGER.warning("OpenRouter response missing choices") _LOGGER.debug( "Full OpenRouter response: %s", json.dumps(data, indent=2) ) return str(data) if choices and "message" in choices[0]: return choices[0]["message"].get("content", str(data)) return str(data) class AlterClient(BaseAIClient): def __init__(self, token, model=""): self.token = token self.model = model self.api_url = "https://alterhq.com/api/v1/chat/completions" async def get_response(self, messages, **kwargs): _LOGGER.debug("Making request to Alter API with model: %s", self.model) headers = { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", } payload = { "model": self.model, "messages": messages, "temperature": 0.7, "top_p": 0.9, } _LOGGER.debug("Alter request payload: %s", json.dumps(payload, indent=2)) async with aiohttp.ClientSession() as session: async with session.post( self.api_url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: if resp.status != 200: error_text = await resp.text() _LOGGER.error("Alter API error %d: %s", resp.status, error_text) raise Exception(f"Alter API error {resp.status}") data = await resp.json() # Extract text from Alter response (OpenAI-compatible format) choices = data.get("choices", []) if not choices: _LOGGER.warning("Alter response missing choices") _LOGGER.debug("Full Alter response: %s", json.dumps(data, indent=2)) return str(data) if choices and "message" in choices[0]: return choices[0]["message"].get("content", str(data)) return str(data) class ZaiClient(BaseAIClient): def __init__(self, token, model="", endpoint_type="general"): self.token = token self.model = model self.endpoint_type = endpoint_type # General endpoint: https://api.z.ai/api/paas/v4/chat/completions # Coding endpoint: https://api.z.ai/api/coding/paas/v4/chat/completions if endpoint_type == "coding": self.api_url = "https://api.z.ai/api/coding/paas/v4/chat/completions" else: self.api_url = "https://api.z.ai/api/paas/v4/chat/completions" async def get_response(self, messages, **kwargs): _LOGGER.debug( "Making request to z.ai API with model: %s, endpoint: %s", self.model, self.endpoint_type, ) headers = { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", } payload = { "model": self.model, "messages": messages, "temperature": 0.7, "top_p": 0.9, } _LOGGER.debug("z.ai request payload: %s", json.dumps(payload, indent=2)) async with aiohttp.ClientSession() as session: async with session.post( self.api_url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=300), ) as resp: if resp.status != 200: error_text = await resp.text() _LOGGER.error("z.ai API error %d: %s", resp.status, error_text) raise Exception(f"z.ai API error {resp.status}") data = await resp.json() # Extract text from z.ai response (OpenAI-compatible format) choices = data.get("choices", []) if not choices: _LOGGER.warning("z.ai response missing choices") _LOGGER.debug("Full z.ai response: %s", json.dumps(data, indent=2)) return str(data) if choices and "message" in choices[0]: return choices[0]["message"].get("content", str(data)) return str(data) # === Main Agent === class AiAgentHaAgent: """Agent for handling queries with dynamic data requests and multiple AI providers.""" SYSTEM_PROMPT = { "role": "system", "content": ( "You are an AI assistant integrated with Home Assistant.\n" "You can request specific data by using only these commands:\n" "- get_entity_state(entity_id): Get state of a specific entity\n" "- get_entities_by_domain(domain): Get all entities in a domain\n" "- get_entities_by_device_class(device_class, domain?): Get entities with specific device_class (e.g., 'temperature', 'humidity', 'motion')\n" "- get_climate_related_entities(): Get all climate-related entities (climate.* entities + temperature/humidity sensors)\n" "- get_entities_by_area(area_id): Get all entities in a specific area\n" "- get_entities(area_id or area_ids): Get entities by area(s) - supports single area_id or list of area_ids\n" " Use as: get_entities(area_ids=['area1', 'area2']) for multiple areas or get_entities(area_id='single_area')\n" "- get_calendar_events(entity_id?): Get calendar events\n" "- get_automations(): Get all automations\n" "- get_weather_data(): Get current weather and forecast data\n" "- get_entity_registry(): Get entity registry entries (now includes device_class, state_class, unit_of_measurement)\n" "- get_device_registry(): Get device registry entries\n" "- get_area_registry(): Get room/area information\n" "- get_history(entity_id, hours): Get historical state changes\n" "- get_person_data(): Get person tracking information\n" "- get_statistics(entity_id): Get sensor statistics\n" "- get_scenes(): Get scene configurations\n" "- get_dashboards(): Get list of all dashboards\n" "- get_dashboard_config(dashboard_url): Get configuration of a specific dashboard\n" "- set_entity_state(entity_id, state, attributes?): Set state of an entity (e.g., turn on/off lights, open/close covers)\n" "- call_service(domain, service, target?, service_data?): Call any Home Assistant service directly\n" "- create_automation(automation): Create a new automation with the provided configuration\n" "- create_dashboard(dashboard_config): Create a new dashboard with the provided configuration\n" "- update_dashboard(dashboard_url, dashboard_config): Update an existing dashboard configuration\n\n" "IMPORTANT DEVICE_CLASS GUIDANCE:\n" "- Many sensors have a 'device_class' attribute (temperature, humidity, motion, etc.)\n" "- Use get_climate_related_entities() for climate dashboards (includes climate.* entities and temperature/humidity sensors)\n" "- Use get_entities_by_device_class(device_class) to filter by device_class (e.g., 'temperature', 'humidity', 'motion')\n" "- For climate dashboards, use history-graph and gauge cards for temperature/humidity sensors\n\n" "DASHBOARD CREATION:\n" "When a user asks to create a dashboard:\n" "1. Gather entities using get_climate_related_entities() or other get_* commands\n" "2. Respond with JSON using request_type: 'dashboard_suggestion' (NEVER use 'final_response'!)\n" "3. Use Lovelace JSON format (NOT YAML!)\n" "4. Example response structure:\n" '{"request_type": "dashboard_suggestion", "message": "Dashboard created", "dashboard": {"title": "...", "views": [...]}}\n' "5. Do NOT include YAML, markdown, or code blocks - only pure JSON\n\n" "IMPORTANT AREA/FLOOR GUIDANCE:\n" "- When users ask for entities from a specific floor, use get_area_registry() first\n" "- Areas have both 'area_id' and 'floor_id' - these are different concepts\n" "- Filter areas by their floor_id to find all areas on a specific floor\n" "- Use get_entities() with area_ids parameter to get entities from multiple areas efficiently\n" "- Example: get_entities(area_ids=['area1', 'area2', 'area3']) for multiple areas at once\n" "- This is more efficient than calling get_entities_by_area() multiple times\n\n" "AUTOMATION CREATION:\n" "When creating automations, request entities first to know the entity IDs.\n" "For days, use: ['fri', 'mon', 'sat', 'sun', 'thu', 'tue', 'wed']\n\n" "RESPONSE FORMATS - You must ALWAYS respond with valid JSON:\n\n" "For automations:\n" "{\n" ' "request_type": "automation_suggestion",\n' ' "message": "I\'ve created an automation that might help you. Would you like me to create it?",\n' ' "automation": {\n' ' "alias": "Name of the automation",\n' ' "description": "Description of what the automation does",\n' ' "trigger": [...], // Array of trigger conditions\n' ' "condition": [...], // Optional array of conditions\n' ' "action": [...] // Array of actions to perform\n' " }\n" "}\n\n" "For dashboards (WHEN USER ASKS TO CREATE A DASHBOARD):\n" "{\n" ' "request_type": "dashboard_suggestion",\n' ' "message": "Description of the dashboard you created",\n' ' "dashboard": {\n' ' "title": "Dashboard Title",\n' ' "url_path": "url-path",\n' ' "icon": "mdi:icon-name",\n' ' "show_in_sidebar": true,\n' ' "views": [{\n' ' "title": "View Title",\n' ' "cards": [...]\n' " }]\n" " }\n" "}\n\n" "For data requests, use this exact JSON format:\n" "{\n" ' "request_type": "data_request",\n' ' "request": "command_name",\n' ' "parameters": {...}\n' "}\n" 'For get_entities with multiple areas: {"request_type": "get_entities", "parameters": {"area_ids": ["area1", "area2"]}}\n' 'For get_entities with single area: {"request_type": "get_entities", "parameters": {"area_id": "single_area"}}\n\n' "For service calls, use this exact JSON format:\n" "{\n" ' "request_type": "call_service",\n' ' "domain": "light",\n' ' "service": "turn_on",\n' ' "target": {"entity_id": ["entity1", "entity2"]},\n' ' "service_data": {"brightness": 255}\n' "}\n\n" "For answering questions (NOT creating dashboards/automations):\n" "{\n" ' "request_type": "final_response",\n' ' "response": "your answer to the user"\n' "}\n\n" "IMPORTANT: Use 'dashboard_suggestion' when creating dashboards, NOT 'final_response'!\n\n" "CRITICAL FORMATTING RULES:\n" "- You must ALWAYS respond with ONLY a valid JSON object\n" "- DO NOT include any text before the JSON\n" "- DO NOT include any text after the JSON\n" "- DO NOT include explanations or descriptions outside the JSON\n" "- Your entire response must be parseable as JSON\n" "- Use the 'message' field inside the JSON for user-facing text\n" "- NEVER mix regular text with JSON in your response\n\n" "WRONG: 'I'll create this for you. {\"request_type\": ...}'\n" 'CORRECT: \'{"request_type": "dashboard_suggestion", "message": "I\'ll create this for you.", ...}\'' ), } SYSTEM_PROMPT_LOCAL = { "role": "system", "content": ( "You are an AI assistant integrated with Home Assistant.\n" "You can request specific data by using only these commands:\n" "- get_entity_state(entity_id): Get state of a specific entity\n" "- get_entities_by_domain(domain): Get all entities in a domain\n" "- get_entities_by_device_class(device_class, domain?): Get entities with specific device_class (e.g., 'temperature', 'humidity', 'motion')\n" "- get_climate_related_entities(): Get all climate-related entities (climate.* entities + temperature/humidity sensors)\n" "- get_entities_by_area(area_id): Get all entities in a specific area\n" "- get_entities(area_id or area_ids): Get entities by area(s) - supports single area_id or list of area_ids\n" " Use as: get_entities(area_ids=['area1', 'area2']) for multiple areas or get_entities(area_id='single_area')\n" "- get_calendar_events(entity_id?): Get calendar events\n" "- get_automations(): Get all automations\n" "- get_weather_data(): Get current weather and forecast data\n" "- get_entity_registry(): Get entity registry entries (now includes device_class, state_class, unit_of_measurement)\n" "- get_device_registry(): Get device registry entries\n" "- get_area_registry(): Get room/area information\n" "- get_history(entity_id, hours): Get historical state changes\n" "- get_person_data(): Get person tracking information\n" "- get_statistics(entity_id): Get sensor statistics\n" "- get_scenes(): Get scene configurations\n" "- get_dashboards(): Get list of all dashboards\n" "- get_dashboard_config(dashboard_url): Get configuration of a specific dashboard\n" "- set_entity_state(entity_id, state, attributes?): Set state of an entity (e.g., turn on/off lights, open/close covers)\n" "- call_service(domain, service, target?, service_data?): Call any Home Assistant service directly\n" "- create_automation(automation): Create a new automation with the provided configuration\n" "- create_dashboard(dashboard_config): Create a new dashboard with the provided configuration\n" "- update_dashboard(dashboard_url, dashboard_config): Update an existing dashboard configuration\n\n" "IMPORTANT DEVICE_CLASS GUIDANCE:\n" "- Many sensors have a 'device_class' attribute (temperature, humidity, motion, etc.)\n" "- Use get_climate_related_entities() for climate dashboards (includes climate.* entities and temperature/humidity sensors)\n" "- Use get_entities_by_device_class(device_class) to filter by device_class (e.g., 'temperature', 'humidity', 'motion')\n" "- For climate dashboards, use history-graph and gauge cards for temperature/humidity sensors\n\n" "DASHBOARD CREATION:\n" "When a user asks to create a dashboard:\n" "1. Gather entities using get_climate_related_entities() or other get_* commands\n" "2. Respond with JSON using request_type: 'dashboard_suggestion' (NEVER use 'final_response'!)\n" "3. Use Lovelace JSON format (NOT YAML!)\n" "4. Example response structure:\n" '{"request_type": "dashboard_suggestion", "message": "Dashboard created", "dashboard": {"title": "...", "views": [...]}}\n' "5. Do NOT include YAML, markdown, or code blocks - only pure JSON\n\n" "IMPORTANT AREA/FLOOR GUIDANCE:\n" "- When users ask for entities from a specific floor, use get_area_registry() first\n" "- Areas have both 'area_id' and 'floor_id' - these are different concepts\n" "- Filter areas by their floor_id to find all areas on a specific floor\n" "- Use get_entities() with area_ids parameter to get entities from multiple areas efficiently\n" "- Example: get_entities(area_ids=['area1', 'area2', 'area3']) for multiple areas at once\n" "- This is more efficient than calling get_entities_by_area() multiple times\n\n" "AUTOMATION CREATION:\n" "When creating automations, request entities first to know the entity IDs.\n" "For days, use: ['fri', 'mon', 'sat', 'sun', 'thu', 'tue', 'wed']\n\n" "RESPONSE FORMATS - You must ALWAYS respond with valid JSON:\n\n" "For automations:\n" "{\n" ' "request_type": "automation_suggestion",\n' ' "message": "I\'ve created an automation that might help you. Would you like me to create it?",\n' ' "automation": {\n' ' "alias": "Name of the automation",\n' ' "description": "Description of what the automation does",\n' ' "trigger": [...], // Array of trigger conditions\n' ' "condition": [...], // Optional array of conditions\n' ' "action": [...] // Array of actions to perform\n' " }\n" "}\n\n" "For dashboards (WHEN USER ASKS TO CREATE A DASHBOARD):\n" "{\n" ' "request_type": "dashboard_suggestion",\n' ' "message": "Description of the dashboard you created",\n' ' "dashboard": {\n' ' "title": "Dashboard Title",\n' ' "url_path": "url-path",\n' ' "icon": "mdi:icon-name",\n' ' "show_in_sidebar": true,\n' ' "views": [{\n' ' "title": "View Title",\n' ' "cards": [...]\n' " }]\n" " }\n" "}\n\n" "For data requests, use this exact JSON format:\n" "{\n" ' "request_type": "data_request",\n' ' "request": "command_name",\n' ' "parameters": {...}\n' "}\n" 'For get_entities with multiple areas: {"request_type": "get_entities", "parameters": {"area_ids": ["area1", "area2"]}}\n' 'For get_entities with single area: {"request_type": "get_entities", "parameters": {"area_id": "single_area"}}\n\n' "For service calls, use this exact JSON format:\n" "{\n" ' "request_type": "call_service",\n' ' "domain": "light",\n' ' "service": "turn_on",\n' ' "target": {"entity_id": ["entity1", "entity2"]},\n' ' "service_data": {"brightness": 255}\n' "}\n\n" "For answering questions (NOT creating dashboards/automations):\n" "{\n" ' "request_type": "final_response",\n' ' "response": "your answer to the user"\n' "}\n\n" "IMPORTANT: Use 'dashboard_suggestion' when creating dashboards, NOT 'final_response'!\n\n" "CRITICAL FORMATTING RULES:\n" "- You must ALWAYS respond with ONLY a valid JSON object\n" "- DO NOT include any text before the JSON\n" "- DO NOT include any text after the JSON\n" "- DO NOT include explanations or descriptions outside the JSON\n" "- Your entire response must be parseable as JSON\n" "- Use the 'message' field inside the JSON for user-facing text\n" "- NEVER mix regular text with JSON in your response\n\n" "WRONG: 'I'll create this for you. {\"request_type\": ...}'\n" 'CORRECT: \'{"request_type": "dashboard_suggestion", "message": "I\'ll create this for you.", ...}\'' ), } def __init__(self, hass: HomeAssistant, config: Dict[str, Any]): """Initialize the agent with provider selection.""" self.hass = hass self.config = config self.conversation_history: List[Dict[str, Any]] = [] self._cache: Dict[str, Any] = {} self.ai_client: BaseAIClient self._cache_timeout = 300 # 5 minutes self._max_retries = 10 self._retry_delay = 1 # seconds self._rate_limit = 60 # requests per minute self._last_request_time = 0 self._request_count = 0 self._request_window_start = time.time() provider = config.get("ai_provider", "openai") models_config = config.get("models", {}) _LOGGER.debug("Initializing AiAgentHaAgent with provider: %s", provider) _LOGGER.debug("Models config loaded: %s", models_config) # Set the appropriate system prompt based on provider if provider in ("local_ollama", "openai_compatible"): self.system_prompt = self.SYSTEM_PROMPT_LOCAL _LOGGER.debug( "Using local-optimized system prompt for provider: %s", provider ) else: self.system_prompt = self.SYSTEM_PROMPT _LOGGER.debug("Using standard system prompt") # Initialize the appropriate AI client with model selection if provider == "openai": model = models_config.get("openai", "gpt-3.5-turbo") base_url = config.get(CONF_OPENAI_BASE_URL) or "" self.ai_client = OpenAIClient( config.get("openai_token"), model, base_url or None ) elif provider == "gemini": model = models_config.get("gemini", "gemini-2.5-flash") self.ai_client = GeminiClient(config.get("gemini_token"), model) elif provider == "openrouter": model = models_config.get("openrouter", "openai/gpt-4o") self.ai_client = OpenRouterClient(config.get("openrouter_token"), model) elif provider == "anthropic": model = models_config.get("anthropic", "claude-sonnet-4-5-20250929") self.ai_client = AnthropicClient(config.get("anthropic_token"), model) elif provider == "alter": model = models_config.get("alter", "") self.ai_client = AlterClient(config.get("alter_token"), model) elif provider == "zai": model = models_config.get("zai", "glm-4.7") endpoint_type = config.get("zai_endpoint", "general") self.ai_client = ZaiClient(config.get("zai_token"), model, endpoint_type) elif provider == "local_ollama": # Support both new local_ollama_url and legacy local_url url = config.get("local_ollama_url") or config.get("local_url") model = models_config.get("local_ollama") or models_config.get("local", "") if not url: _LOGGER.error("Missing local_ollama_url for local_ollama provider") raise Exception( "Missing local_ollama_url configuration for local_ollama provider" ) self.ai_client = LocalOllamaClient(url, model) elif provider == "openai_compatible": url = config.get("openai_compatible_url") model = models_config.get("openai_compatible", "") api_key = config.get("openai_compatible_api_key", "") or "" if not url: _LOGGER.error( "Missing openai_compatible_url for openai_compatible provider" ) raise Exception( "Missing openai_compatible_url configuration for openai_compatible provider" ) self.ai_client = OpenaiCompatibleClient(url, model, api_key or None) else: # default to llama if somehow specified model = models_config.get("llama", "Llama-4-Maverick-17B-128E-Instruct-FP8") self.ai_client = LlamaClient(config.get("llama_token"), model) _LOGGER.debug( "AiAgentHaAgent initialized successfully with provider: %s, model: %s", provider, model, ) def _validate_api_key(self) -> bool: """Validate the API key format.""" provider = self.config.get("ai_provider", "openai") if provider == "openai": token = self.config.get("openai_token") elif provider == "gemini": token = self.config.get("gemini_token") elif provider == "openrouter": token = self.config.get("openrouter_token") elif provider == "anthropic": token = self.config.get("anthropic_token") elif provider == "alter": token = self.config.get("alter_token") elif provider == "zai": token = self.config.get("zai_token") elif provider == "local_ollama": # For local_ollama, the “token” is actually the URL; support legacy local_url token = self.config.get("local_ollama_url") or self.config.get("local_url") elif provider == "openai_compatible": # For openai_compatible, validate the URL is present token = self.config.get("openai_compatible_url") else: token = self.config.get("llama_token") if not token or not isinstance(token, str): return False # For local_ollama and openai_compatible, validate URL format if provider in ("local_ollama", "openai_compatible"): return bool(token.startswith(("http://", "https://"))) # Add more specific validation based on your API key format return len(token) >= 32 def _check_rate_limit(self) -> bool: """Check if we're within rate limits.""" current_time = time.time() if current_time - self._request_window_start >= 60: self._request_count = 0 self._request_window_start = current_time if self._request_count >= self._rate_limit: return False self._request_count += 1 return True def _get_cached_data(self, key: str) -> Optional[Any]: """Get data from cache if it's still valid.""" if key in self._cache: timestamp, data = self._cache[key] if time.time() - timestamp < self._cache_timeout: return data del self._cache[key] return None def _set_cached_data(self, key: str, data: Any) -> None: """Store data in cache with timestamp.""" self._cache[key] = (time.time(), data) # Maximum size (in characters) of a single data message added to the # conversation. Large installs can return megabytes of entity data, which # blows past the model's context window (~200k tokens for Claude) and makes # every request fail with a deterministic 400 (issue #80). 50k chars is # roughly 13k tokens, which also keeps requests within entry-tier # per-minute token rate limits (e.g. Anthropic tier 1: 30k input # tokens/min) even with a data message persisting in the history window. MAX_DATA_MESSAGE_CHARS = 50_000 # Maximum combined size (in characters) of the message window sent to the # provider per request. Several capped data messages can still stack past # context and per-minute token budgets (issue #80); the most recent # messages are kept. ~100k chars is roughly 25k tokens. MAX_WINDOW_CHARS = 100_000 def _format_data_message(self, data: Any) -> str: """Serialize fetched HA data for the conversation, capping its size. If the payload is too large, list items are dropped from the end and a truncation notice is included so the model knows to request more specific data instead of the full dump. """ message = json.dumps({"data": data}, default=str) if len(message) <= self.MAX_DATA_MESSAGE_CHARS: return message note = ( "Data truncated to fit the model context window. " "Request more specific data (e.g. a specific entity, domain or area) " "to see the rest." ) if isinstance(data, list) and data: truncated = data while len(message) > self.MAX_DATA_MESSAGE_CHARS and len(truncated) > 1: # Scale down proportionally to the overshoot, then re-check # (item sizes vary, so loop until it actually fits). keep = max( 1, len(truncated) * self.MAX_DATA_MESSAGE_CHARS // len(message), ) truncated = truncated[:keep] message = json.dumps( { "data": truncated, "truncated": True, "total_items": len(data), "items_shown": len(truncated), "note": note, }, default=str, ) if len(message) <= self.MAX_DATA_MESSAGE_CHARS: _LOGGER.warning( "Data response truncated from %d to %d items to fit context window", len(data), len(truncated), ) return message # A single item alone exceeds the cap - fall through to the # hard-truncated preview below so the cap always holds. # Oversized non-list payloads (or a single oversized list item): keep # a prefix of the serialized form as a preview. _LOGGER.warning( "Oversized data response hard-truncated from %d chars", len(message) ) preview = message[: self.MAX_DATA_MESSAGE_CHARS] result = json.dumps({"data_preview": preview, "truncated": True, "note": note}) # JSON-escaping the preview can push the result back over the cap; # shrink until the final message actually fits. while len(result) > self.MAX_DATA_MESSAGE_CHARS and preview: preview = preview[: int(len(preview) * 0.9)] result = json.dumps( {"data_preview": preview, "truncated": True, "note": note} ) return result def _sanitize_automation_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """Sanitize automation configuration to prevent injection attacks.""" sanitized: Dict[str, Any] = {} for key, value in config.items(): if key in ["alias", "description"]: # Sanitize strings sanitized[key] = str(value).strip()[:100] # Limit length elif key in ["trigger", "condition", "action"]: # Home Assistant accepts either a single mapping or a list for # trigger/condition/action. Normalize a single mapping to a # one-element list instead of silently dropping it (which would # later raise KeyError and reject a perfectly valid automation). if isinstance(value, list): sanitized[key] = value elif isinstance(value, dict): sanitized[key] = [value] elif key == "mode": # Validate mode if value in ["single", "restart", "queued", "parallel"]: sanitized[key] = value return sanitized @staticmethod def _read_automations_file(path: str) -> List[Dict[str, Any]]: """Read and parse automations.yaml into a list of automations. Runs inside an executor thread. Raises FileNotFoundError when the file does not exist (the caller treats that as "no automations yet"). """ with open(path, "r", encoding="utf-8") as handle: data = yaml.safe_load(handle) if data is None: return [] if not isinstance(data, list): # automations.yaml is always a top-level list. If it is anything # else, refuse to touch it rather than risk clobbering content we # don't understand. raise ValueError("automations.yaml does not contain a list of automations") return data @staticmethod def _write_automations_file(path: str, automations: List[Dict[str, Any]]) -> None: """Safely persist automations to automations.yaml. Runs inside an executor thread. Compared to a naive ``yaml.dump`` to an open file handle, this: * keeps accented/unicode text readable instead of mangling it into ``\\uXXXX`` escapes (``allow_unicode=True``), * preserves key order so automations stay diff-friendly (``sort_keys=False``), * never line-wraps long Jinja templates (``width``), * validates that the serialized YAML round-trips back to the same data before touching disk, * backs up the previous file to ``.bak`` so the user can roll back, * writes atomically (temp file + ``os.replace``) so a crash or a bad write can never leave a half-written / corrupted file in place. """ # Use safe_dump (the SafeDumper) so the writer mirrors the safe_load # reader: only plain YAML types are ever emitted, never opaque # ``!!python/object`` tags. content = yaml.safe_dump( automations, default_flow_style=False, allow_unicode=True, sort_keys=False, width=4096, ) # Guard against ever writing YAML that does not decode back to the # exact same data structure. if yaml.safe_load(content) != automations: raise ValueError( "Refusing to write automations.yaml: serialized YAML did not " "round-trip cleanly" ) path_exists = os.path.exists(path) # Back up the existing file before overwriting it. if path_exists: shutil.copy2(path, f"{path}.bak") # Atomic write: write to a temp file in the same directory, fsync, then # atomically replace the target. directory = os.path.dirname(path) or "." fd, tmp_path = tempfile.mkstemp( prefix=".automations.", suffix=".yaml.tmp", dir=directory ) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) # Preserve the original file's permission bits. mkstemp creates the # temp file as 0600, so without this an atomic replace would strip # any group/world bits the user or an external editor relied on. if path_exists: shutil.copymode(path, tmp_path) os.replace(tmp_path, path) except BaseException: # Never leave a stray temp file behind on failure. if os.path.exists(tmp_path): os.remove(tmp_path) raise async def get_entity_state(self, entity_id: str) -> Dict[str, Any]: """Get the state of a specific entity.""" try: _LOGGER.debug("Requesting entity state for: %s", entity_id) state = self.hass.states.get(entity_id) if not state: _LOGGER.warning("Entity not found: %s", entity_id) return {"error": f"Entity {entity_id} not found"} # Get area information from entity/device registry # Wrapped in try-except to handle cases where registries aren't available (e.g., in tests) area_id = None area_name = None try: from homeassistant.helpers import area_registry as ar from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er entity_registry = er.async_get(self.hass) device_registry = dr.async_get(self.hass) area_registry = ar.async_get(self.hass) if entity_registry and hasattr(entity_registry, "async_get"): # Try to find the entity in the registry entity_entry = entity_registry.async_get(entity_id) if entity_entry: _LOGGER.debug("Entity %s found in registry", entity_id) # Check if entity has a direct area assignment if hasattr(entity_entry, "area_id") and entity_entry.area_id: area_id = entity_entry.area_id _LOGGER.debug( "Entity %s has direct area assignment: %s", entity_id, area_id, ) # Otherwise check if the entity's device has an area elif ( hasattr(entity_entry, "device_id") and entity_entry.device_id and device_registry and hasattr(device_registry, "async_get") ): _LOGGER.debug( "Entity %s has device_id: %s, checking device area", entity_id, entity_entry.device_id, ) device_entry = device_registry.async_get( entity_entry.device_id ) if device_entry: if ( hasattr(device_entry, "area_id") and device_entry.area_id ): area_id = device_entry.area_id _LOGGER.debug( "Device %s has area: %s", entity_entry.device_id, area_id, ) else: _LOGGER.debug( "Device %s has no area assigned", entity_entry.device_id, ) else: _LOGGER.debug( "Device %s not found in registry", entity_entry.device_id, ) else: _LOGGER.debug( "Entity %s has no area_id and no device_id", entity_id ) else: _LOGGER.debug( "Entity %s not found in entity registry", entity_id ) else: _LOGGER.debug("Entity registry not available for %s", entity_id) # Get area name from area_id if ( area_id and area_registry and hasattr(area_registry, "async_get_area") ): area_entry = area_registry.async_get_area(area_id) if area_entry and hasattr(area_entry, "name"): area_name = area_entry.name _LOGGER.debug( "Resolved area_id %s to area_name: %s", area_id, area_name ) else: _LOGGER.debug("Could not resolve area_id %s to name", area_id) elif area_id: _LOGGER.debug( "Have area_id %s but area_registry not available", area_id ) except Exception as e: # Registries not available (likely in test environment) - skip area information _LOGGER.warning( "Exception retrieving area information for %s: %s", entity_id, str(e), ) result = { "entity_id": state.entity_id, "state": state.state, "last_changed": ( state.last_changed.isoformat() if state.last_changed else None ), "friendly_name": state.attributes.get("friendly_name"), "area_id": area_id, "area_name": area_name, "attributes": { k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in state.attributes.items() }, } _LOGGER.debug( "Retrieved entity state for %s: area_id=%s, area_name=%s", entity_id, area_id, area_name, ) return result except Exception as e: _LOGGER.exception("Error getting entity state: %s", str(e)) return {"error": f"Error getting entity state: {str(e)}"} async def get_entities_by_domain(self, domain: str) -> List[Dict[str, Any]]: """Get all entities for a specific domain.""" try: _LOGGER.debug("Requesting all entities for domain: %s", domain) states = [ state for state in self.hass.states.async_all() if state.entity_id.startswith(f"{domain}.") ] _LOGGER.debug("Found %d entities in domain %s", len(states), domain) return [await self.get_entity_state(state.entity_id) for state in states] except Exception as e: _LOGGER.exception("Error getting entities by domain: %s", str(e)) return [{"error": f"Error getting entities for domain {domain}: {str(e)}"}] async def get_entities_by_device_class( self, device_class: str, domain: str = None ) -> List[Dict[str, Any]]: """Get all entities with a specific device_class. Args: device_class: The device class to filter by (e.g., 'temperature', 'humidity', 'motion') domain: Optional domain to restrict search (e.g., 'sensor', 'binary_sensor') Returns: List of entity state dictionaries that match the device_class """ try: _LOGGER.debug( "Requesting all entities with device_class: %s (domain: %s)", device_class, domain or "all", ) matching_entities = [] for state in self.hass.states.async_all(): # Filter by domain if specified if domain and not state.entity_id.startswith(f"{domain}."): continue # Check if this entity has the matching device_class entity_device_class = state.attributes.get("device_class") if entity_device_class == device_class: matching_entities.append(state.entity_id) _LOGGER.debug( "Found %d entities with device_class %s", len(matching_entities), device_class, ) # Get full state information for each matching entity return [ await self.get_entity_state(entity_id) for entity_id in matching_entities ] except Exception as e: _LOGGER.exception("Error getting entities by device_class: %s", str(e)) return [ { "error": f"Error getting entities with device_class {device_class}: {str(e)}" } ] async def get_climate_related_entities(self) -> List[Dict[str, Any]]: """Get all climate-related entities including climate domain and temperature/humidity sensors. Returns: List of entity state dictionaries for: - All climate.* entities (thermostats, HVAC systems) - All sensor.* entities with device_class: temperature - All sensor.* entities with device_class: humidity """ try: _LOGGER.debug("Requesting all climate-related entities") climate_entities = [] # Get all climate domain entities (thermostats, HVAC) climate_domain = await self.get_entities_by_domain("climate") climate_entities.extend(climate_domain) # Get temperature sensors temp_sensors = await self.get_entities_by_device_class( "temperature", "sensor" ) climate_entities.extend(temp_sensors) # Get humidity sensors humidity_sensors = await self.get_entities_by_device_class( "humidity", "sensor" ) climate_entities.extend(humidity_sensors) # Deduplicate by entity_id (edge case: if an entity appears in multiple categories) seen_entity_ids = set() unique_entities = [] for entity in climate_entities: entity_id = entity.get("entity_id") if entity_id and entity_id not in seen_entity_ids: seen_entity_ids.add(entity_id) unique_entities.append(entity) _LOGGER.debug( "Found %d total climate-related entities (deduplicated from %d)", len(unique_entities), len(climate_entities), ) return unique_entities except Exception as e: _LOGGER.exception("Error getting climate-related entities: %s", str(e)) return [{"error": f"Error getting climate-related entities: {str(e)}"}] async def get_entities_by_area(self, area_id: str) -> List[Dict[str, Any]]: """Get all entities for a specific area.""" try: _LOGGER.debug("Requesting all entities for area: %s", area_id) # Get entity registry to find entities assigned to the area from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er entity_registry = er.async_get(self.hass) device_registry = dr.async_get(self.hass) entities_in_area = [] # Find entities assigned to the area (directly or through their device) for entity in entity_registry.entities.values(): # Check if entity is directly assigned to the area if entity.area_id == area_id: entities_in_area.append(entity.entity_id) # Check if entity's device is assigned to the area elif entity.device_id: device = device_registry.devices.get(entity.device_id) if device and device.area_id == area_id: entities_in_area.append(entity.entity_id) _LOGGER.debug( "Found %d entities in area %s", len(entities_in_area), area_id ) # Get state information for each entity result = [] for entity_id in entities_in_area: state_info = await self.get_entity_state(entity_id) if not state_info.get("error"): # Only include entities that exist result.append(state_info) return result except Exception as e: _LOGGER.exception("Error getting entities by area: %s", str(e)) return [{"error": f"Error getting entities for area {area_id}: {str(e)}"}] async def get_entities(self, area_id=None, area_ids=None) -> List[Dict[str, Any]]: """Get entities by area(s) - flexible method that supports single area or multiple areas.""" try: # Handle different parameter formats areas_to_process = [] if area_ids: # Multiple areas provided if isinstance(area_ids, list): areas_to_process = area_ids else: areas_to_process = [area_ids] elif area_id: # Single area provided if isinstance(area_id, list): areas_to_process = area_id else: areas_to_process = [area_id] else: return [{"error": "No area_id or area_ids provided"}] _LOGGER.debug("Requesting entities for areas: %s", areas_to_process) all_entities = [] for area in areas_to_process: entities_in_area = await self.get_entities_by_area(area) all_entities.extend(entities_in_area) # Remove duplicates based on entity_id seen_entities = set() unique_entities = [] for entity in all_entities: if isinstance(entity, dict) and "entity_id" in entity: if entity["entity_id"] not in seen_entities: seen_entities.add(entity["entity_id"]) unique_entities.append(entity) else: unique_entities.append(entity) # Keep error messages _LOGGER.debug( "Found %d unique entities across %d areas", len(unique_entities), len(areas_to_process), ) return unique_entities except Exception as e: _LOGGER.exception("Error getting entities: %s", str(e)) return [{"error": f"Error getting entities: {str(e)}"}] async def get_calendar_events( self, entity_id: Optional[str] = None ) -> List[Dict[str, Any]]: """Get calendar events, optionally filtered by entity_id.""" try: if entity_id: _LOGGER.debug( "Requesting calendar events for specific entity: %s", entity_id ) return [await self.get_entity_state(entity_id)] _LOGGER.debug("Requesting all calendar events") return await self.get_entities_by_domain("calendar") except Exception as e: _LOGGER.exception("Error getting calendar events: %s", str(e)) return [{"error": f"Error getting calendar events: {str(e)}"}] async def get_automations(self) -> List[Dict[str, Any]]: """Get all automations.""" try: _LOGGER.debug("Requesting all automations") return await self.get_entities_by_domain("automation") except Exception as e: _LOGGER.exception("Error getting automations: %s", str(e)) return [{"error": f"Error getting automations: {str(e)}"}] async def get_entity_registry(self) -> List[Dict]: """Get entity registry entries with device_class and other metadata. Area information is resolved from the entity or its device. """ _LOGGER.debug("Requesting all entity registry entries") try: from homeassistant.helpers import area_registry as ar from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er entity_registry = er.async_get(self.hass) if not entity_registry: return [] device_registry = dr.async_get(self.hass) area_registry = ar.async_get(self.hass) result = [] for entry in entity_registry.entities.values(): # Get the current state to access device_class and other attributes state = self.hass.states.get(entry.entity_id) device_class = state.attributes.get("device_class") if state else None state_class = state.attributes.get("state_class") if state else None unit_of_measurement = ( state.attributes.get("unit_of_measurement") if state else None ) # Resolve area_id and area_name # First check entity's direct area assignment area_id = entry.area_id area_name = None # If entity doesn't have area, check device's area if not area_id and entry.device_id and device_registry: device_entry = device_registry.async_get(entry.device_id) if device_entry and hasattr(device_entry, "area_id"): area_id = device_entry.area_id # Resolve area_name from area_id if area_id and area_registry: area_entry = area_registry.async_get_area(area_id) if area_entry and hasattr(area_entry, "name"): area_name = area_entry.name result.append( { "entity_id": entry.entity_id, "device_id": entry.device_id, "platform": entry.platform, "disabled": entry.disabled, "area_id": area_id, "area_name": area_name, "original_name": entry.original_name, "unique_id": entry.unique_id, "device_class": device_class, "state_class": state_class, "unit_of_measurement": unit_of_measurement, } ) return result except Exception as e: _LOGGER.exception("Error getting entity registry entries: %s", str(e)) return [{"error": f"Error getting entity registry entries: {str(e)}"}] async def get_device_registry(self) -> List[Dict]: """Get device registry entries""" _LOGGER.debug("Requesting all device registry entries") try: from homeassistant.helpers import device_registry as dr registry = dr.async_get(self.hass) if not registry: return [] return [ { "id": device.id, "name": device.name, "model": device.model, "manufacturer": device.manufacturer, "sw_version": device.sw_version, "hw_version": device.hw_version, "connections": ( list(device.connections) if device.connections else [] ), "identifiers": ( list(device.identifiers) if device.identifiers else [] ), "area_id": device.area_id, "disabled": device.disabled_by is not None, "entry_type": ( device.entry_type.value if device.entry_type else None ), "name_by_user": device.name_by_user, } for device in registry.devices.values() ] except Exception as e: _LOGGER.exception("Error getting device registry entries: %s", str(e)) return [{"error": f"Error getting device registry entries: {str(e)}"}] async def get_history(self, entity_id: str, hours: int = 24) -> List[Dict]: """Get historical state changes for an entity""" _LOGGER.debug("Requesting historical state changes for entity: %s", entity_id) try: from homeassistant.components.recorder.history import get_significant_states now = dt_util.utcnow() start = now - timedelta(hours=hours) # Get history using the recorder history module history_data = await self.hass.async_add_executor_job( get_significant_states, self.hass, start, now, [entity_id], ) # Convert to serializable format result = [] for entity_id_key, states in history_data.items(): for state in states: # Skip if it's a dict (mypy type narrowing) if isinstance(state, dict): continue result.append( { "entity_id": state.entity_id, "state": state.state, "last_changed": state.last_changed.isoformat(), "last_updated": state.last_updated.isoformat(), "attributes": dict(state.attributes), } ) return result except Exception as e: _LOGGER.exception("Error getting history: %s", str(e)) return [{"error": f"Error getting history: {str(e)}"}] async def get_area_registry(self) -> Dict[str, Any]: """Get area registry information""" _LOGGER.debug("Get area registry information") try: from homeassistant.helpers import area_registry as ar registry = ar.async_get(self.hass) if not registry: return {} result = {} for area in registry.areas.values(): result[area.id] = { "name": area.name, "normalized_name": area.normalized_name, "picture": area.picture, "icon": area.icon, "floor_id": area.floor_id, "labels": list(area.labels) if area.labels else [], } return result except Exception as e: _LOGGER.exception("Error getting area registry: %s", str(e)) return {"error": f"Error getting area registry: {str(e)}"} async def get_person_data(self) -> List[Dict]: """Get person tracking information""" _LOGGER.debug("Requesting person tracking information") try: result = [] for state in self.hass.states.async_all("person"): result.append( { "entity_id": state.entity_id, "name": state.attributes.get("friendly_name", state.entity_id), "state": state.state, "latitude": state.attributes.get("latitude"), "longitude": state.attributes.get("longitude"), "source": state.attributes.get("source"), "gps_accuracy": state.attributes.get("gps_accuracy"), "last_changed": ( state.last_changed.isoformat() if state.last_changed else None ), } ) return result except Exception as e: _LOGGER.exception("Error getting person tracking information: %s", str(e)) return [{"error": f"Error getting person tracking information: {str(e)}"}] async def get_statistics(self, entity_id: str) -> Dict: """Get statistics for an entity""" _LOGGER.debug("Requesting statistics for entity: %s", entity_id) try: from homeassistant.components import recorder # Check if recorder is available if not self.hass.data.get(recorder.DATA_INSTANCE): return {"error": "Recorder component is not available"} # from homeassistant.components.recorder.statistics import get_latest_short_term_statistics import homeassistant.components.recorder.statistics as stats_module # Get latest statistics stats = await self.hass.async_add_executor_job( # get_latest_short_term_statistics, stats_module.get_last_short_term_statistics, self.hass, 1, entity_id, True, set(), ) if entity_id in stats: stat_data = stats[entity_id][0] if stats[entity_id] else {} return { "entity_id": entity_id, "start": stat_data.get("start"), "mean": stat_data.get("mean"), "min": stat_data.get("min"), "max": stat_data.get("max"), "last_reset": stat_data.get("last_reset"), "state": stat_data.get("state"), "sum": stat_data.get("sum"), } else: return {"error": f"No statistics available for entity {entity_id}"} except Exception as e: _LOGGER.exception("Error getting statistics: %s", str(e)) return {"error": f"Error getting statistics: {str(e)}"} async def get_scenes(self) -> List[Dict]: """Get scene configurations""" _LOGGER.debug("Requesting scene configurations") try: result = [] for state in self.hass.states.async_all("scene"): result.append( { "entity_id": state.entity_id, "name": state.attributes.get("friendly_name", state.entity_id), "last_activated": state.attributes.get("last_activated"), "icon": state.attributes.get("icon"), "last_changed": ( state.last_changed.isoformat() if state.last_changed else None ), } ) return result except Exception as e: _LOGGER.exception("Error getting scene configurations: %s", str(e)) return [{"error": f"Error getting scene configurations: {str(e)}"}] async def get_weather_data(self) -> Dict[str, Any]: """Get weather data from any available weather entity in the system.""" try: # Find all weather entities weather_entities = [ state for state in self.hass.states.async_all() if state.domain == "weather" ] if not weather_entities: return { "error": "No weather entities found in the system. Please add a weather integration." } # Use the first available weather entity state = weather_entities[0] _LOGGER.debug("Using weather entity: %s", state.entity_id) # Get all available attributes all_attributes = state.attributes _LOGGER.debug( "Available weather attributes: %s", json.dumps(all_attributes) ) # Get forecast data forecast = all_attributes.get("forecast", []) # Process forecast data processed_forecast = [] for day in forecast: forecast_entry = { "datetime": day.get("datetime"), "temperature": day.get("temperature"), "condition": day.get("condition"), "precipitation": day.get("precipitation"), "precipitation_probability": day.get("precipitation_probability"), "humidity": day.get("humidity"), "wind_speed": day.get("wind_speed"), "wind_bearing": day.get("wind_bearing"), } # Only add entries that have at least some data if any(v is not None for v in forecast_entry.values()): processed_forecast.append(forecast_entry) # Get current weather data current = { "entity_id": state.entity_id, "temperature": all_attributes.get("temperature"), "humidity": all_attributes.get("humidity"), "pressure": all_attributes.get("pressure"), "wind_speed": all_attributes.get("wind_speed"), "wind_bearing": all_attributes.get("wind_bearing"), "condition": state.state, "forecast_available": len(processed_forecast) > 0, } # Log the processed data for debugging _LOGGER.debug( "Processed weather data: %s", json.dumps( {"current": current, "forecast_count": len(processed_forecast)} ), ) return {"current": current, "forecast": processed_forecast} except Exception as e: _LOGGER.exception("Error getting weather data: %s", str(e)) return {"error": f"Error getting weather data: {str(e)}"} async def create_automation( self, automation_config: Dict[str, Any] ) -> Dict[str, Any]: """Create a new automation with validation and sanitization.""" try: _LOGGER.debug( "Creating automation with config: %s", json.dumps(automation_config) ) # Validate required fields if not all( key in automation_config for key in ["alias", "trigger", "action"] ): return {"error": "Missing required fields in automation configuration"} # Sanitize configuration sanitized_config = self._sanitize_automation_config(automation_config) # Make sure the core building blocks survived sanitization. A # malformed trigger/action (e.g. not a list or mapping) is dropped # by the sanitizer, so validate here and fail with a clear message # instead of raising KeyError further down. if not sanitized_config.get("alias"): return {"error": "Automation must include a non-empty alias"} if not sanitized_config.get("trigger"): return {"error": "Automation must include at least one trigger"} if not sanitized_config.get("action"): return {"error": "Automation must include at least one action"} # Generate a unique ID for the automation automation_id = f"ai_agent_auto_{int(time.time() * 1000)}" # Create the automation entry automation_entry = { "id": automation_id, "alias": sanitized_config["alias"], "description": sanitized_config.get("description", ""), "trigger": sanitized_config["trigger"], "condition": sanitized_config.get("condition", []), "action": sanitized_config["action"], "mode": sanitized_config.get("mode", "single"), } # Read current automations.yaml using async executor automations_path = self.hass.config.path("automations.yaml") try: current_automations = await self.hass.async_add_executor_job( self._read_automations_file, automations_path ) except FileNotFoundError: current_automations = [] # Check for duplicate automation names if any( auto.get("alias") == automation_entry["alias"] for auto in current_automations ): return { "error": f"An automation with the name '{automation_entry['alias']}' already exists" } # Append new automation current_automations.append(automation_entry) # Write back to file safely: backs up the previous file, validates # the YAML round-trips, preserves unicode/key order, and replaces # the file atomically so a bad write can never corrupt it. await self.hass.async_add_executor_job( self._write_automations_file, automations_path, current_automations, ) # Reload automations await self.hass.services.async_call("automation", "reload") # Clear automation-related caches self._cache.clear() return { "success": True, "message": f"Automation '{automation_entry['alias']}' created successfully", } except Exception as e: _LOGGER.exception("Error creating automation: %s", str(e)) return {"error": f"Error creating automation: {str(e)}"} async def get_dashboards(self) -> List[Dict[str, Any]]: """Get list of all dashboards.""" try: _LOGGER.debug("Requesting all dashboards") # Get dashboards via WebSocket API ws_api = self.hass.data.get("websocket_api") if not ws_api: return [{"error": "WebSocket API not available"}] # Use the lovelace service to get dashboards try: from homeassistant.components.lovelace import DOMAIN as LOVELACE_DOMAIN # Get lovelace data using property access (required for HA 2026.2+) # lovelace_data is a LovelaceData dataclass with a 'dashboards' attribute lovelace_data = self.hass.data.get(LOVELACE_DOMAIN) if lovelace_data is None: return [{"error": "Lovelace not available"}] # Safety check for dashboards attribute (backward compatibility) if not hasattr(lovelace_data, "dashboards"): return [{"error": "Lovelace dashboards not available"}] # Use property access instead of dictionary access dashboards = lovelace_data.dashboards # Get YAML dashboard configs for metadata (title, icon, etc.) # yaml_dashboards contains the configuration with metadata yaml_configs = getattr(lovelace_data, "yaml_dashboards", {}) or {} dashboard_list = [] # Iterate over all dashboards (None key = default dashboard) for url_path, dashboard_obj in dashboards.items(): # Try to get metadata from yaml_dashboards first yaml_config = yaml_configs.get(url_path, {}) or {} # Get title - check yaml config, then use defaults title = yaml_config.get("title") if not title: title = ( "Overview" if url_path is None else (url_path or "Dashboard") ) # Get icon - check yaml config, then use defaults icon = yaml_config.get("icon") if not icon: icon = "mdi:home" if url_path is None else "mdi:view-dashboard" # Get sidebar/admin settings from yaml config or defaults show_in_sidebar = yaml_config.get("show_in_sidebar", True) require_admin = yaml_config.get("require_admin", False) dashboard_list.append( { "url_path": url_path, "title": title, "icon": icon, "show_in_sidebar": show_in_sidebar, "require_admin": require_admin, } ) _LOGGER.debug("Found %d dashboards", len(dashboard_list)) return dashboard_list except Exception as e: _LOGGER.warning("Could not get dashboards via lovelace: %s", str(e)) return [{"error": f"Could not retrieve dashboards: {str(e)}"}] except Exception as e: _LOGGER.exception("Error getting dashboards: %s", str(e)) return [{"error": f"Error getting dashboards: {str(e)}"}] async def get_dashboard_config( self, dashboard_url: Optional[str] = None ) -> Dict[str, Any]: """Get configuration of a specific dashboard.""" try: _LOGGER.debug( "Requesting dashboard config for: %s", dashboard_url or "default" ) # Get dashboard configuration try: from homeassistant.components.lovelace import DOMAIN as LOVELACE_DOMAIN # Get lovelace data using property access (required for HA 2026.2+) lovelace_data = self.hass.data.get(LOVELACE_DOMAIN) if lovelace_data is None: return {"error": "Lovelace not available"} # Safety check for dashboards attribute (backward compatibility) if not hasattr(lovelace_data, "dashboards"): return {"error": "Lovelace dashboards not available"} # Use property access instead of dictionary access # The dashboards dict uses None as key for the default dashboard dashboards = lovelace_data.dashboards # Get the dashboard (None key = default dashboard) dashboard_key = None if dashboard_url is None else dashboard_url if dashboard_key in dashboards: dashboard = dashboards[dashboard_key] config = await dashboard.async_get_info() return dict(config) if config else {"error": "No dashboard config"} else: if dashboard_url is None: return {"error": "Default dashboard not found"} else: return {"error": f"Dashboard '{dashboard_url}' not found"} except Exception as e: _LOGGER.warning("Could not get dashboard config: %s", str(e)) return {"error": f"Could not retrieve dashboard config: {str(e)}"} except Exception as e: _LOGGER.exception("Error getting dashboard config: %s", str(e)) return {"error": f"Error getting dashboard config: {str(e)}"} async def create_dashboard( self, dashboard_config: Dict[str, Any] ) -> Dict[str, Any]: """Create a new dashboard using Home Assistant's Lovelace WebSocket API.""" try: _LOGGER.debug( "Creating dashboard with config: %s", json.dumps(dashboard_config, default=str), ) # Validate required fields if not dashboard_config.get("title"): return {"error": "Dashboard title is required"} if not dashboard_config.get("url_path"): return {"error": "Dashboard URL path is required"} # Sanitize the URL path url_path = ( dashboard_config["url_path"].lower().replace(" ", "-").replace("_", "-") ) # Prepare dashboard configuration for Lovelace dashboard_data = { "title": dashboard_config["title"], "icon": dashboard_config.get("icon", "mdi:view-dashboard"), "show_in_sidebar": dashboard_config.get("show_in_sidebar", True), "require_admin": dashboard_config.get("require_admin", False), "views": dashboard_config.get("views", []), } try: # Create dashboard file directly - this is the most reliable method import os import yaml # Create the dashboard YAML file lovelace_config_file = self.hass.config.path( f"ui-lovelace-{url_path}.yaml" ) # Use async_add_executor_job to perform file I/O asynchronously def write_dashboard_file(): with open(lovelace_config_file, "w") as f: yaml.dump( dashboard_data, f, default_flow_style=False, allow_unicode=True, ) await self.hass.async_add_executor_job(write_dashboard_file) _LOGGER.info( "Successfully created dashboard file: %s", lovelace_config_file ) # Now update configuration.yaml try: config_file = self.hass.config.path("configuration.yaml") dashboard_config_entry = { url_path: { "mode": "yaml", "title": dashboard_config["title"], "icon": dashboard_config.get("icon", "mdi:view-dashboard"), "show_in_sidebar": dashboard_config.get( "show_in_sidebar", True ), "filename": f"ui-lovelace-{url_path}.yaml", } } def update_config_file(): try: with open(config_file, "r") as f: content = f.read() # Dashboard configuration to add dashboard_yaml = f""" {url_path}: mode: yaml title: {dashboard_config['title']} icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} filename: ui-lovelace-{url_path}.yaml""" # Check if lovelace section exists if "lovelace:" not in content: # Add complete lovelace section at the end lovelace_section = f""" # Lovelace dashboards configuration added by AI Agent lovelace: dashboards: {dashboard_yaml} """ with open(config_file, "a") as f: f.write(lovelace_section) return True # If lovelace exists, check for dashboards section lines = content.split("\n") new_lines = [] dashboard_added = False in_lovelace = False lovelace_indent = 0 for i, line in enumerate(lines): new_lines.append(line) # Detect lovelace section if ( line.strip() == "lovelace:" or line.strip().startswith("lovelace:") ): in_lovelace = True lovelace_indent = len(line) - len(line.lstrip()) continue # If we're in lovelace section if in_lovelace: current_indent = ( len(line) - len(line.lstrip()) if line.strip() else 0 ) # If we hit another top-level section, we're out of lovelace if ( line.strip() and current_indent <= lovelace_indent and not line.startswith(" ") ): if line.strip() != "lovelace:": in_lovelace = False # Look for dashboards section if in_lovelace and "dashboards:" in line: # Add our dashboard after the dashboards: line new_lines.append(dashboard_yaml) dashboard_added = True in_lovelace = False # We're done break # If we found lovelace but no dashboards section, add it if not dashboard_added and "lovelace:" in content: # Find lovelace section and add dashboards new_lines = [] for line in lines: new_lines.append(line) if ( line.strip() == "lovelace:" or line.strip().startswith("lovelace:") ): # Add dashboards section right after lovelace new_lines.append(" dashboards:") new_lines.append(dashboard_yaml) dashboard_added = True break if dashboard_added: with open(config_file, "w") as f: f.write("\n".join(new_lines)) return True else: # Last resort: append to end of file with open(config_file, "a") as f: f.write(f"\n dashboards:\n{dashboard_yaml}\n") return True except Exception as e: _LOGGER.error( "Failed to update configuration.yaml: %s", str(e) ) # Fallback to simple append method try: with open(config_file, "r") as f: content = f.read() # Check if lovelace section exists if "lovelace:" not in content: # Add lovelace section lovelace_config = f""" # Lovelace dashboards lovelace: dashboards: {url_path}: mode: yaml title: {dashboard_config['title']} icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} filename: ui-lovelace-{url_path}.yaml """ with open(config_file, "a") as f: f.write(lovelace_config) else: # Add to existing lovelace section (simple approach) dashboard_entry = f""" {url_path}: mode: yaml title: {dashboard_config['title']} icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} filename: ui-lovelace-{url_path}.yaml """ # Find the dashboards section and add to it lines = content.split("\n") new_lines = [] in_dashboards = False dashboards_indented = False for line in lines: new_lines.append(line) if ( "dashboards:" in line and "lovelace" in content[: content.find(line)] ): in_dashboards = True # Add our dashboard entry after dashboards: new_lines.append(dashboard_entry.rstrip()) in_dashboards = False # If we couldn't find dashboards section, add it under lovelace if not any("dashboards:" in line for line in lines): for i, line in enumerate(new_lines): if line.strip() == "lovelace:": new_lines.insert(i + 1, " dashboards:") new_lines.insert( i + 2, dashboard_entry.rstrip() ) break with open(config_file, "w") as f: f.write("\n".join(new_lines)) return True except Exception as fallback_error: _LOGGER.error( "Fallback config update also failed: %s", str(fallback_error), ) return False config_updated = await self.hass.async_add_executor_job( update_config_file ) if config_updated: success_message = f"""Dashboard '{dashboard_config['title']}' created successfully! ✅ Dashboard file created: ui-lovelace-{url_path}.yaml ✅ Configuration.yaml updated automatically 🔄 Please restart Home Assistant to see your new dashboard in the sidebar.""" return { "success": True, "message": success_message, "url_path": url_path, "restart_required": True, } else: # Config update failed, provide manual instructions config_instructions = f"""Dashboard '{dashboard_config['title']}' created successfully! ✅ Dashboard file created: ui-lovelace-{url_path}.yaml ⚠️ Could not automatically update configuration.yaml Please manually add this to your configuration.yaml: lovelace: dashboards: {url_path}: mode: yaml title: {dashboard_config['title']} icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} filename: ui-lovelace-{url_path}.yaml Then restart Home Assistant to see your new dashboard in the sidebar.""" return { "success": True, "message": config_instructions, "url_path": url_path, "restart_required": True, } except Exception as config_error: _LOGGER.error( "Error updating configuration.yaml: %s", str(config_error) ) # Provide manual instructions as fallback config_instructions = f"""Dashboard '{dashboard_config['title']}' created successfully! ✅ Dashboard file created: ui-lovelace-{url_path}.yaml ⚠️ Could not automatically update configuration.yaml Please manually add this to your configuration.yaml: lovelace: dashboards: {url_path}: mode: yaml title: {dashboard_config['title']} icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} filename: ui-lovelace-{url_path}.yaml Then restart Home Assistant to see your new dashboard in the sidebar.""" return { "success": True, "message": config_instructions, "url_path": url_path, "restart_required": True, } except Exception as e: _LOGGER.error("Failed to create dashboard file: %s", str(e)) return {"error": f"Failed to create dashboard file: {str(e)}"} except Exception as e: _LOGGER.exception("Error creating dashboard: %s", str(e)) return {"error": f"Error creating dashboard: {str(e)}"} async def update_dashboard( self, dashboard_url: str, dashboard_config: Dict[str, Any] ) -> Dict[str, Any]: """Update an existing dashboard using Home Assistant's Lovelace WebSocket API.""" try: _LOGGER.debug( "Updating dashboard %s with config: %s", dashboard_url, json.dumps(dashboard_config, default=str), ) # Prepare updated dashboard configuration dashboard_data = { "title": dashboard_config.get("title", "Updated Dashboard"), "icon": dashboard_config.get("icon", "mdi:view-dashboard"), "show_in_sidebar": dashboard_config.get("show_in_sidebar", True), "require_admin": dashboard_config.get("require_admin", False), "views": dashboard_config.get("views", []), } try: # Update dashboard file directly import os import yaml # Try updating the YAML file dashboard_file = self.hass.config.path( f"ui-lovelace-{dashboard_url}.yaml" ) # Check if file exists asynchronously def check_file_exists(): return os.path.exists(dashboard_file) file_exists = await self.hass.async_add_executor_job(check_file_exists) if not file_exists: dashboard_file = self.hass.config.path( f"dashboards/{dashboard_url}.yaml" ) file_exists = await self.hass.async_add_executor_job( lambda: os.path.exists(dashboard_file) ) if file_exists: # Use async_add_executor_job to perform file I/O asynchronously def update_dashboard_file(): with open(dashboard_file, "w") as f: yaml.dump( dashboard_data, f, default_flow_style=False, allow_unicode=True, ) await self.hass.async_add_executor_job(update_dashboard_file) _LOGGER.info( "Successfully updated dashboard file: %s", dashboard_file ) return { "success": True, "message": f"Dashboard '{dashboard_url}' updated successfully!", } else: return {"error": f"Dashboard file for '{dashboard_url}' not found"} except Exception as e: _LOGGER.error("Failed to update dashboard file: %s", str(e)) return {"error": f"Failed to update dashboard file: {str(e)}"} except Exception as e: _LOGGER.exception("Error updating dashboard: %s", str(e)) return {"error": f"Error updating dashboard: {str(e)}"} async def process_query( self, user_query: str, provider: Optional[str] = None, debug: bool = False ) -> Dict[str, Any]: """Process a user query with input validation and rate limiting.""" try: if not user_query or not isinstance(user_query, str): return {"success": False, "error": "Invalid query format"} # Get the correct configuration for the requested provider if provider and provider in self.hass.data[DOMAIN]["configs"]: config = self.hass.data[DOMAIN]["configs"][provider] else: config = self.config _LOGGER.debug(f"Processing query with provider: {provider}") # Log sanitized config (masks all tokens/keys for security) _LOGGER.debug( f"Using config: {json.dumps(sanitize_for_logging(config), default=str)}" ) selected_provider = provider or config.get("ai_provider", "llama") models_config = config.get("models", {}) provider_config = { "openai": { "token_key": "openai_token", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get("openai", "gpt-3.5-turbo"), "client_class": OpenAIClient, }, "gemini": { "token_key": "gemini_token", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get("gemini", "gemini-1.5-flash"), "client_class": GeminiClient, }, "openrouter": { "token_key": "openrouter_token", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get("openrouter", "openai/gpt-4o"), "client_class": OpenRouterClient, }, "llama": { "token_key": "llama_token", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get( "llama", "Llama-4-Maverick-17B-128E-Instruct-FP8" ), "client_class": LlamaClient, }, "anthropic": { "token_key": "anthropic_token", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get( "anthropic", "claude-sonnet-4-5-20250929" ), "client_class": AnthropicClient, }, "alter": { "token_key": "alter_token", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get("alter", ""), "client_class": AlterClient, }, "zai": { "token_key": "zai_token", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get("zai", ""), "client_class": ZaiClient, }, "local_ollama": { "token_key": "local_ollama_url", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get("local_ollama", ""), "client_class": LocalOllamaClient, }, "openai_compatible": { "token_key": "openai_compatible_url", # nosec B105 - dict-key field name, not a credential value (false positive) "model": models_config.get("openai_compatible", ""), "client_class": OpenaiCompatibleClient, }, } # Validate provider and get configuration if selected_provider not in provider_config: _LOGGER.warning( f"Invalid provider {selected_provider}, falling back to llama" ) selected_provider = "llama" provider_settings = provider_config[selected_provider] token = self.config.get(provider_settings["token_key"]) def _with_debug(result: Dict[str, Any]) -> Dict[str, Any]: """Attach a sanitized trace when UI requests debug info.""" if debug and "debug" not in result: result["debug"] = self._build_debug_trace( selected_provider, provider_settings, config.get("zai_endpoint", "general"), ) return result # Validate token/URL if not token: is_url_provider = selected_provider in ( "local_ollama", "openai_compatible", ) error_msg = f"No {'URL' if is_url_provider else 'token'} configured for provider {selected_provider}" _LOGGER.error(error_msg) return _with_debug({"success": False, "error": error_msg}) # Initialize client try: if selected_provider == "zai": # ZaiClient takes (token, model, endpoint_type) endpoint_type = config.get("zai_endpoint", "general") self.ai_client = provider_settings["client_class"]( token=token, model=provider_settings["model"], endpoint_type=endpoint_type, ) _LOGGER.debug( f"Initialized {selected_provider} client with model {provider_settings['model']}, endpoint_type {endpoint_type}" ) elif selected_provider in ("local_ollama", "openai_compatible"): # LocalOllamaClient and OpenaiCompatibleClient take (url, model) if selected_provider == "local_ollama": # Support legacy local_url url = token or config.get("local_url") self.ai_client = provider_settings["client_class"]( url, provider_settings["model"] ) else: url = token api_key = config.get("openai_compatible_api_key", "") or "" self.ai_client = provider_settings["client_class"]( url, provider_settings["model"], api_key or None ) _LOGGER.debug( f"Initialized {selected_provider} client with model {provider_settings['model']}" ) else: # Other clients take (token, model) self.ai_client = provider_settings["client_class"]( token=token, model=provider_settings["model"] ) _LOGGER.debug( f"Initialized {selected_provider} client with model {provider_settings['model']}" ) except Exception as e: error_msg = f"Error initializing {selected_provider} client: {str(e)}" _LOGGER.error(error_msg) return _with_debug({"success": False, "error": error_msg}) # Process the query with rate limiting and retries if not self._check_rate_limit(): return _with_debug( { "success": False, "error": "Rate limit exceeded. Please wait before trying again.", } ) # Sanitize user input user_query = user_query.strip()[:1000] # Limit length and trim whitespace _LOGGER.debug("Processing new query: %s", user_query) # Check cache for identical query cache_key = f"query_{hash(user_query)}_{provider}_{debug}" cached_result = self._get_cached_data(cache_key) if cached_result: return ( dict(cached_result) if isinstance(cached_result, dict) else {"error": "Invalid cached result"} ) # Add system message to conversation if it's the first message if not self.conversation_history: _LOGGER.debug("Adding system message to new conversation") self.conversation_history.append(self.system_prompt) # Remember where this query started so a failure can be rolled # back instead of leaving dangling/oversized messages that poison # every subsequent query (issue #80). history_checkpoint = len(self.conversation_history) # Add user query to conversation self.conversation_history.append({"role": "user", "content": user_query}) _LOGGER.debug("Added user query to conversation history") # Prevent infinite loops while leaving room for multi-step # discovery: with data responses capped (issue #80) the model may # need several narrower data requests instead of one big dump. max_iterations = 8 iteration = 0 while iteration < max_iterations: iteration += 1 _LOGGER.debug(f"Processing iteration {iteration} of {max_iterations}") try: # Get AI response _LOGGER.debug("Requesting response from AI provider") response = await self._get_ai_response() _LOGGER.debug("Received response from AI provider: %s", response) try: # Try to parse the response as JSON with simplified approach response_clean = response.strip() # Remove potential BOM and other invisible characters import codecs if response_clean.startswith(codecs.BOM_UTF8.decode("utf-8")): response_clean = response_clean[1:] # Remove other common invisible characters invisible_chars = [ "\ufeff", "\u200b", "\u200c", "\u200d", "\u2060", ] for char in invisible_chars: response_clean = response_clean.replace(char, "") _LOGGER.debug( "Cleaned response length: %d", len(response_clean) ) _LOGGER.debug( "Cleaned response first 100 chars: %s", response_clean[:100] ) _LOGGER.debug( "Cleaned response last 100 chars: %s", response_clean[-100:] ) # Simple strategy: try to parse the cleaned response directly response_data = None try: _LOGGER.debug("Attempting basic JSON parse...") response_data = json.loads(response_clean) _LOGGER.debug("Basic JSON parse succeeded!") except json.JSONDecodeError as e: _LOGGER.warning("Basic JSON parse failed: %s", str(e)) _LOGGER.debug("JSON error position: %d", e.pos) if e.pos < len(response_clean): _LOGGER.debug( "Character at error position: %s (ord: %d)", repr(response_clean[e.pos]), ord(response_clean[e.pos]), ) _LOGGER.debug( "Context around error: %s", repr( response_clean[max(0, e.pos - 10) : e.pos + 10] ), ) # Fallback: try to extract JSON by finding the first { and last } json_start = response_clean.find("{") json_end = response_clean.rfind("}") if ( json_start != -1 and json_end != -1 and json_end > json_start ): json_part = response_clean[json_start : json_end + 1] _LOGGER.debug( "Trying fallback extraction from pos %d to %d", json_start, json_end, ) _LOGGER.debug("Extracted JSON: %s", json_part[:200]) try: response_data = json.loads(json_part) _LOGGER.debug("Fallback JSON extraction succeeded!") except json.JSONDecodeError as e2: _LOGGER.warning( "Fallback JSON extraction also failed: %s", str(e2), ) raise e # Re-raise the original error else: _LOGGER.warning( "Could not find JSON boundaries in response" ) raise e # Re-raise the original error if response_data is None: raise json.JSONDecodeError( "All parsing strategies failed", response_clean, 0 ) _LOGGER.debug("Successfully parsed JSON response") _LOGGER.debug( "Parsed response type: %s", response_data.get("request_type", "unknown"), ) # Check if this is a data request (either format) data_request_types = [ "get_entity_state", "get_entities_by_domain", "get_entities_by_device_class", "get_climate_related_entities", "get_entities_by_area", "get_entities", "get_calendar_events", "get_automations", "get_entity_registry", "get_device_registry", "get_weather_data", "get_area_registry", "get_history", "get_person_data", "get_statistics", "get_scenes", "get_dashboards", "get_dashboard_config", "set_entity_state", "create_automation", "create_dashboard", "update_dashboard", ] if ( response_data.get("request_type") == "data_request" or response_data.get("request_type") in data_request_types ): # Handle data request (both standard format and direct request type) if response_data.get("request_type") == "data_request": request_type = response_data.get("request") else: request_type = response_data.get("request_type") parameters = response_data.get("parameters", {}) _LOGGER.debug( "Processing data request: %s with parameters: %s", request_type, json.dumps(parameters), ) # Add AI's response to conversation history self.conversation_history.append( { "role": "assistant", "content": json.dumps( response_data ), # Store clean JSON } ) # Get requested data data: Union[Dict[str, Any], List[Dict[str, Any]]] if request_type == "get_entity_state": data = await self.get_entity_state( parameters.get("entity_id") ) elif request_type == "get_entities_by_domain": data = await self.get_entities_by_domain( parameters.get("domain") ) elif request_type == "get_entities_by_area": data = await self.get_entities_by_area( parameters.get("area_id") ) elif request_type == "get_entities": data = await self.get_entities( area_id=parameters.get("area_id"), area_ids=parameters.get("area_ids"), ) elif request_type == "get_entities_by_device_class": data = await self.get_entities_by_device_class( parameters.get("device_class"), parameters.get("domain"), ) elif request_type == "get_climate_related_entities": data = await self.get_climate_related_entities() elif request_type == "get_calendar_events": data = await self.get_calendar_events( parameters.get("entity_id") ) elif request_type == "get_automations": data = await self.get_automations() elif request_type == "get_entity_registry": data = await self.get_entity_registry() elif request_type == "get_device_registry": data = await self.get_device_registry() elif request_type == "get_weather_data": data = await self.get_weather_data() elif request_type == "get_area_registry": data = await self.get_area_registry() elif request_type == "get_history": data = await self.get_history( parameters.get("entity_id"), parameters.get("hours", 24), ) elif request_type == "get_person_data": data = await self.get_person_data() elif request_type == "get_statistics": data = await self.get_statistics( parameters.get("entity_id") ) elif request_type == "get_scenes": data = await self.get_scenes() elif request_type == "get_dashboards": data = await self.get_dashboards() elif request_type == "get_dashboard_config": data = await self.get_dashboard_config( parameters.get("dashboard_url") ) elif request_type == "set_entity_state": data = await self.set_entity_state( parameters.get("entity_id"), parameters.get("state"), parameters.get("attributes"), ) elif request_type == "create_automation": data = await self.create_automation( parameters.get("automation") ) elif request_type == "create_dashboard": data = await self.create_dashboard( parameters.get("dashboard_config") ) elif request_type == "update_dashboard": data = await self.update_dashboard( parameters.get("dashboard_url"), parameters.get("dashboard_config"), ) else: data = { "error": f"Unknown request type: {request_type}" } _LOGGER.warning( "Unknown request type: %s", request_type ) # Check if any data request resulted in an error if isinstance(data, dict) and "error" in data: return _with_debug( {"success": False, "error": data["error"]} ) elif isinstance(data, list) and any( "error" in item for item in data if isinstance(item, dict) ): errors = [ item["error"] for item in data if isinstance(item, dict) and "error" in item ] return _with_debug( {"success": False, "error": "; ".join(errors)} ) _LOGGER.debug( "Retrieved data for request: %s", json.dumps(data, default=str), ) # Add data to conversation as a user message (not system to avoid overwriting system prompt in Anthropic API) self.conversation_history.append( { "role": "user", "content": self._format_data_message(data), } ) continue elif response_data.get("request_type") == "final_response": # Add final response to conversation history self.conversation_history.append( { "role": "assistant", "content": json.dumps( response_data ), # Store clean JSON } ) # Return final response _LOGGER.debug( "Received final response: %s", response_data.get("response"), ) result = { "success": True, "answer": response_data.get("response", ""), } result = _with_debug(result) self._set_cached_data(cache_key, result) return result elif ( response_data.get("request_type") == "automation_suggestion" ): # Add automation suggestion to conversation history self.conversation_history.append( { "role": "assistant", "content": json.dumps( response_data ), # Store clean JSON } ) # Return automation suggestion _LOGGER.debug( "Received automation suggestion: %s", json.dumps(response_data.get("automation")), ) result = { "success": True, "answer": json.dumps(response_data), } result = _with_debug(result) self._set_cached_data(cache_key, result) return result elif ( response_data.get("request_type") == "dashboard_suggestion" ): # Add dashboard suggestion to conversation history self.conversation_history.append( { "role": "assistant", "content": json.dumps( response_data ), # Store clean JSON } ) # Return dashboard suggestion _LOGGER.debug( "Received dashboard suggestion: %s", json.dumps(response_data.get("dashboard")), ) result = { "success": True, "answer": json.dumps(response_data), } result = _with_debug(result) self._set_cached_data(cache_key, result) return result elif response_data.get("request_type") in [ "get_entities", "get_entities_by_area", ]: # Handle direct get_entities request (for backward compatibility) parameters = response_data.get("parameters", {}) _LOGGER.debug( "Processing direct get_entities request with parameters: %s", json.dumps(parameters), ) # Add AI's response to conversation history self.conversation_history.append( { "role": "assistant", "content": json.dumps( response_data ), # Store clean JSON } ) # Get entities data if response_data.get("request_type") == "get_entities": data = await self.get_entities( area_id=parameters.get("area_id"), area_ids=parameters.get("area_ids"), ) else: # get_entities_by_area data = await self.get_entities_by_area( parameters.get("area_id") ) _LOGGER.debug( "Retrieved %d entities", len(data) if isinstance(data, list) else 1, ) # Add data to conversation as a user message (not system to avoid overwriting system prompt in Anthropic API) self.conversation_history.append( { "role": "user", "content": self._format_data_message(data), } ) continue elif response_data.get("request_type") == "call_service": # Handle service call request domain = response_data.get("domain") service = response_data.get("service") target = response_data.get("target", {}) service_data = response_data.get("service_data", {}) # Resolve nested requests in target if target and "entity_id" in target: entity_id_value = target["entity_id"] if ( isinstance(entity_id_value, dict) and "request_type" in entity_id_value ): # This is a nested request, resolve it nested_request_type = entity_id_value.get( "request_type" ) nested_parameters = entity_id_value.get( "parameters", {} ) _LOGGER.debug( "Resolving nested request: %s with parameters: %s", nested_request_type, json.dumps(nested_parameters), ) # Resolve the nested request if nested_request_type == "get_entities": entities_data = await self.get_entities( area_id=nested_parameters.get("area_id"), area_ids=nested_parameters.get("area_ids"), ) elif nested_request_type == "get_entities_by_area": entities_data = await self.get_entities_by_area( nested_parameters.get("area_id") ) elif ( nested_request_type == "get_entities_by_domain" ): entities_data = ( await self.get_entities_by_domain( nested_parameters.get("domain") ) ) else: _LOGGER.error( "Unsupported nested request type: %s", nested_request_type, ) return { "success": False, "error": f"Unsupported nested request type: {nested_request_type}", } # Extract entity IDs from the resolved data if isinstance(entities_data, list): entity_ids = [ entity.get("entity_id") for entity in entities_data if entity.get("entity_id") ] target["entity_id"] = entity_ids _LOGGER.debug( "Resolved nested request to entity IDs: %s", entity_ids, ) else: _LOGGER.error( "Nested request returned unexpected data format" ) return _with_debug( { "success": False, "error": "Nested request returned unexpected data format", } ) # Handle backward compatibility with old format if not domain or not service: request = response_data.get("request") parameters = response_data.get("parameters", {}) if request and "entity_id" in parameters: entity_id = parameters["entity_id"] # Infer domain from entity_id if "." in entity_id: domain = entity_id.split(".")[0] service = request target = {"entity_id": entity_id} # Remove entity_id from parameters to avoid duplication service_data = { k: v for k, v in parameters.items() if k != "entity_id" } _LOGGER.debug( "Converted old format: domain=%s, service=%s", domain, service, ) _LOGGER.debug( "Processing service call: %s.%s with target: %s and data: %s", domain, service, json.dumps(target), json.dumps(service_data), ) # Add AI's response to conversation history self.conversation_history.append( { "role": "assistant", "content": json.dumps( response_data ), # Store clean JSON } ) # Call the service data = await self.call_service( domain, service, target, service_data ) # Check if service call resulted in an error if isinstance(data, dict) and "error" in data: return _with_debug( {"success": False, "error": data["error"]} ) _LOGGER.debug( "Service call completed: %s", json.dumps(data, default=str), ) # Add data to conversation as a user message (not system to avoid overwriting system prompt in Anthropic API) self.conversation_history.append( { "role": "user", "content": self._format_data_message(data), } ) # Go to next iteration to continue the loop continue # Unknown request type _LOGGER.warning( "Unknown response type: %s", response_data.get("request_type"), ) return _with_debug( { "success": False, "error": f"Unknown response type: {response_data.get('request_type')}", } ) except json.JSONDecodeError as e: # Check if this is a local provider that might have already wrapped the response provider = self.config.get("ai_provider", "unknown") if provider in ("local_ollama", "openai_compatible"): _LOGGER.debug( "Local provider returned non-JSON response (this is normal and handled): %s", response[:200], ) else: # Log more of the response to help with debugging for non-local providers response_preview = ( response[:1000] if len(response) > 1000 else response ) _LOGGER.warning( "Failed to parse response as JSON: %s. Response length: %d. Response preview: %s", str(e), len(response), response_preview, ) # Log additional debugging information _LOGGER.debug( "First 50 characters as bytes: %s", response[:50].encode("utf-8") if response else b"", ) _LOGGER.debug( "Response starts with: %s", repr(response[:10]) if response else "None", ) # Also log the response to a separate debug file for detailed analysis (non-local providers only) if provider not in ("local_ollama", "openai_compatible"): try: import os debug_dir = "/config/ai_agent_ha_debug" def write_debug_file(): if not os.path.exists(debug_dir): os.makedirs(debug_dir) import datetime timestamp = datetime.datetime.now().strftime( "%Y%m%d_%H%M%S" ) debug_file = os.path.join( debug_dir, f"failed_response_{timestamp}.txt" ) with open(debug_file, "w", encoding="utf-8") as f: f.write(f"Timestamp: {timestamp}\n") f.write(f"Provider: {provider}\n") f.write(f"Error: {str(e)}\n") f.write(f"Response length: {len(response)}\n") f.write( f"Response bytes: {response.encode('utf-8') if response else b''}\n" ) f.write(f"Response repr: {repr(response)}\n") f.write(f"Full response:\n{response}\n") return debug_file # Run file operations in executor to avoid blocking debug_file = await self.hass.async_add_executor_job( write_debug_file ) _LOGGER.info( "Failed response saved to debug file: %s", debug_file, ) except Exception as debug_error: _LOGGER.debug( "Could not save debug file: %s", str(debug_error) ) # Check if this looks like a corrupted automation suggestion if ( response.strip().startswith( '{"request_type": "automation_suggestion' ) and len(response) > 10000 and response.count("for its use in various fields") > 50 ): _LOGGER.warning( "Detected corrupted automation suggestion response with repetitive text" ) result = _with_debug( { "success": False, "error": "AI generated corrupted automation response. Please try again with a more specific automation request.", } ) self._set_cached_data(cache_key, result) return result # If response is not valid JSON, try to wrap it as a final response try: # Truncate extremely long responses to prevent memory issues response_to_wrap = response if len(response) > 50000: response_to_wrap = ( response[:5000] + "... [Response truncated due to excessive length]" ) _LOGGER.warning( "Truncated extremely long response from %d to 5000 characters", len(response), ) wrapped_response = { "request_type": "final_response", "response": response_to_wrap, } # Keep the conversation paired: record the assistant # reply so history doesn't end with a dangling user # message (issue #80). self.conversation_history.append( {"role": "assistant", "content": response_to_wrap} ) result = { "success": True, "answer": json.dumps(wrapped_response), } _LOGGER.debug("Wrapped non-JSON response as final_response") except Exception as wrap_error: _LOGGER.error( "Failed to wrap response: %s", str(wrap_error) ) result = { "success": False, "error": f"Invalid response format: {str(e)}", } result = _with_debug(result) self._set_cached_data(cache_key, result) return result except Exception as e: _LOGGER.exception("Error processing AI response: %s", str(e)) # Roll back this query's messages so the failure doesn't # poison subsequent queries (issue #80). del self.conversation_history[history_checkpoint:] return _with_debug( { "success": False, "error": f"Error processing AI response: {str(e)}", } ) # If we've reached max iterations without a final response _LOGGER.warning("Reached maximum iterations without final response") # Roll back this query's messages so the failure doesn't poison # subsequent queries (issue #80). del self.conversation_history[history_checkpoint:] result = { "success": False, "error": "Maximum iterations reached without final response", } result = _with_debug(result) self._set_cached_data(cache_key, result) return result except Exception as e: _LOGGER.exception("Error in process_query: %s", str(e)) return _with_debug( {"success": False, "error": f"Error in process_query: {str(e)}"} ) def _build_debug_trace( self, provider: Optional[str], provider_settings: Optional[Dict[str, Any]], endpoint_type: Optional[str], ) -> Dict[str, Any]: """Return a sanitized snapshot of the HA↔AI conversation for UI display.""" history_tail = ( self.conversation_history[-20:] if self.conversation_history else [] ) return { "provider": provider, "model": provider_settings.get("model") if provider_settings else None, "endpoint_type": endpoint_type, "conversation": history_tail, } async def _get_ai_response(self) -> str: """Get response from the selected AI provider with retries and rate limiting.""" if not self._check_rate_limit(): raise Exception("Rate limit exceeded. Please try again later.") retry_count = 0 last_error = None # Limit conversation history to the last 10 messages to prevent token # overflow, and cap the window's total size as well: even # individually-capped data messages can stack up past context and # per-minute token budgets (issue #80). Most recent messages win. candidates = [ m for m in self.conversation_history[-10:] if m.get("role") != "system" ] recent_messages: List[Dict[str, Any]] = [] total_chars = 0 for message in reversed(candidates): size = len(str(message.get("content") or "")) if recent_messages and total_chars + size > self.MAX_WINDOW_CHARS: break recent_messages.insert(0, message) total_chars += size # Dropping/slicing can cut mid-turn; ensure the window starts with a # user turn (issue #80). while recent_messages and recent_messages[0].get("role") == "assistant": recent_messages.pop(0) # System prompt is always the first message recent_messages = [self.system_prompt] + recent_messages _LOGGER.debug("Sending %d messages to AI provider", len(recent_messages)) _LOGGER.debug("AI provider: %s", self.config.get("ai_provider", "unknown")) while retry_count < self._max_retries: try: _LOGGER.debug( "Attempt %d/%d: Calling AI client", retry_count + 1, self._max_retries, ) response = await self.ai_client.get_response(recent_messages) # Every client is expected to return a string. Guard against a # client handing back a non-string (e.g. a raw list/dict from an # unexpected provider response shape) so the downstream string # operations below don't crash with an unhelpful AttributeError # and burn all retries (see issue #75). if response is not None and not isinstance(response, str): _LOGGER.warning( "AI client returned non-string response of type %s; coercing to str", type(response).__name__, ) response = ( json.dumps(response) if isinstance(response, (list, dict)) else str(response) ) _LOGGER.debug( "AI client returned response of length: %d", len(response or "") ) _LOGGER.debug("AI response preview: %s", (response or "")[:200]) # Check for extremely long responses that might indicate model issues if response and len(response) > 50000: _LOGGER.warning( "AI returned extremely long response (%d characters), this may indicate a model issue", len(response), ) # Check for repetitive patterns that indicate a corrupted response if response.count("for its use in various fields") > 50: _LOGGER.error( "Detected corrupted repetitive response, aborting this iteration" ) raise Exception( "AI generated corrupted response with repetitive text. Please try again with a clearer request." ) # Check if response is empty if not response or response.strip() == "": _LOGGER.warning( "AI client returned empty response on attempt %d", retry_count + 1, ) if retry_count + 1 >= self._max_retries: raise Exception( "AI provider returned empty response after all retries" ) else: retry_count += 1 await asyncio.sleep(self._retry_delay * retry_count) continue return str(response) except NonRetryableAIError: # Deterministic client error (e.g. 400 "prompt is too long") - # retrying the same payload cannot succeed (issue #80). raise except Exception as e: _LOGGER.error( "AI client error on attempt %d: %s", retry_count + 1, str(e) ) last_error = e retry_count += 1 if retry_count < self._max_retries: delay: float = self._retry_delay * retry_count if isinstance(e, RateLimitedAIError) and e.retry_after: # Wait at least as long as the provider asked for # (capped at 60s) so per-minute token windows can # actually reset (issue #80). delay = max(delay, min(e.retry_after, 60)) await asyncio.sleep(delay) continue raise Exception( f"Failed after {retry_count} retries. Last error: {str(last_error)}" ) def clear_conversation_history(self) -> None: """Clear the conversation history and cache.""" self.conversation_history = [] self._cache.clear() _LOGGER.debug("Conversation history and cache cleared") async def set_entity_state( self, entity_id: str, state: str, attributes: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Set the state of an entity.""" try: _LOGGER.debug( "Setting state for entity %s to %s with attributes: %s", entity_id, state, json.dumps(attributes or {}), ) # Validate entity exists if not self.hass.states.get(entity_id): return {"error": f"Entity {entity_id} not found"} # Call the appropriate service based on the domain domain = entity_id.split(".")[0] if domain == "light": service = ( "turn_on" if state.lower() in ["on", "true", "1"] else "turn_off" ) service_data = {"entity_id": entity_id} if attributes and service == "turn_on": service_data.update(attributes) await self.hass.services.async_call("light", service, service_data) elif domain == "switch": service = ( "turn_on" if state.lower() in ["on", "true", "1"] else "turn_off" ) await self.hass.services.async_call( "switch", service, {"entity_id": entity_id} ) elif domain == "cover": if state.lower() in ["open", "up"]: service = "open_cover" elif state.lower() in ["close", "down"]: service = "close_cover" elif state.lower() == "stop": service = "stop_cover" else: return {"error": f"Invalid state {state} for cover entity"} await self.hass.services.async_call( "cover", service, {"entity_id": entity_id} ) elif domain == "climate": service_data = {"entity_id": entity_id} if state.lower() in ["on", "true", "1"]: service = "turn_on" elif state.lower() in ["off", "false", "0"]: service = "turn_off" elif state.lower() in ["heat", "cool", "dry", "fan_only", "auto"]: service = "set_hvac_mode" service_data["hvac_mode"] = state.lower() else: return {"error": f"Invalid state {state} for climate entity"} await self.hass.services.async_call("climate", service, service_data) elif domain == "fan": service = ( "turn_on" if state.lower() in ["on", "true", "1"] else "turn_off" ) service_data = {"entity_id": entity_id} if attributes and service == "turn_on": service_data.update(attributes) await self.hass.services.async_call("fan", service, service_data) else: # For other domains, try to set the state directly self.hass.states.async_set(entity_id, state, attributes or {}) # Get the new state to confirm the change new_state = self.hass.states.get(entity_id) return { "success": True, "entity_id": entity_id, "new_state": new_state.state, "new_attributes": new_state.attributes, } except Exception as e: _LOGGER.exception("Error setting entity state: %s", str(e)) return {"error": f"Error setting entity state: {str(e)}"} async def call_service( self, domain: str, service: str, target: Optional[Dict[str, Any]] = None, service_data: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Call a Home Assistant service.""" try: _LOGGER.debug( "Calling service %s.%s with target: %s and data: %s", domain, service, json.dumps(target or {}), json.dumps(service_data or {}), ) # Prepare the service call data call_data = {} # Add target entities if provided if target: if "entity_id" in target: entity_ids = target["entity_id"] if isinstance(entity_ids, list): call_data["entity_id"] = entity_ids else: call_data["entity_id"] = [entity_ids] # Add other target properties for key, value in target.items(): if key != "entity_id": call_data[key] = value # Add service data if provided if service_data: call_data.update(service_data) _LOGGER.debug("Final service call data: %s", json.dumps(call_data)) # Call the service await self.hass.services.async_call(domain, service, call_data) # Get the updated states of affected entities result_entities = [] if "entity_id" in call_data: for entity_id in call_data["entity_id"]: state = self.hass.states.get(entity_id) if state: result_entities.append( { "entity_id": entity_id, "state": state.state, "attributes": dict(state.attributes), } ) return { "success": True, "service": f"{domain}.{service}", "entities_affected": result_entities, "message": f"Successfully called {domain}.{service}", } except Exception as e: _LOGGER.exception( "Error calling service %s.%s: %s", domain, service, str(e) ) return {"error": f"Error calling service {domain}.{service}: {str(e)}"} async def save_user_prompt_history( self, user_id: str, history: List[str] ) -> Dict[str, Any]: """Save user's prompt history to HA storage.""" try: store: Store = Store(self.hass, 1, f"ai_agent_ha_history_{user_id}") await store.async_save({"history": history}) return {"success": True} except Exception as e: _LOGGER.exception("Error saving prompt history: %s", str(e)) return {"error": f"Error saving prompt history: {str(e)}"} async def load_user_prompt_history(self, user_id: str) -> Dict[str, Any]: """Load user's prompt history from HA storage.""" try: store: Store = Store(self.hass, 1, f"ai_agent_ha_history_{user_id}") data = await store.async_load() history = data.get("history", []) if data else [] return {"success": True, "history": history} except Exception as e: _LOGGER.exception("Error loading prompt history: %s", str(e)) return {"error": f"Error loading prompt history: {str(e)}", "history": []}