mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 23:24:03 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5037622fb6 | ||
|
|
962a089bf4 | ||
|
|
4c56565b66 | ||
|
|
8432038f09 | ||
|
|
929d916d41 | ||
|
|
675975d951 | ||
|
|
524ec87395 | ||
|
|
94f8193996 | ||
|
|
7c3fcf73c3 | ||
|
|
20bbf89679 | ||
|
|
86dca52d07 | ||
|
|
78d1561e74 | ||
|
|
054e4af258 | ||
|
|
dc03faa97e | ||
|
|
9a7635c2ae | ||
|
|
df3d79c20c | ||
|
|
d6144be7ed | ||
|
|
9afbb904b3 | ||
|
|
93558b2444 | ||
|
|
9f93f1ee18 | ||
|
|
30a9b53ba1 | ||
|
|
5175970d55 | ||
|
|
f6bfbd4a07 | ||
|
|
4ddb0dc977 | ||
|
|
24dc4ac4d4 | ||
|
|
976f4f16a3 | ||
|
|
2bdef2b494 | ||
|
|
42324a793b | ||
|
|
30aa894634 | ||
|
|
398b2550a9 | ||
|
|
f1deaa2014 | ||
|
|
f4b0ce902e | ||
|
|
d899144149 | ||
|
|
5d49b4a40b | ||
|
|
1a84727cf1 | ||
|
|
d3c7e25202 | ||
|
|
5e82b9669c | ||
|
|
103f9d59e9 | ||
|
|
6ad67a5acf | ||
|
|
12e5778a1c | ||
|
|
5c85cba606 | ||
|
|
63d28e12d6 | ||
|
|
605c73c6c4 | ||
|
|
9d54f88520 | ||
|
|
34e93e0045 | ||
|
|
34bfd7dfe1 | ||
|
|
e3487a48cb | ||
|
|
72a98d0076 |
@@ -2,13 +2,42 @@ name: Validate with hassfest
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'custom_components/**'
|
||||||
|
- '.github/workflows/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'custom_components/**'
|
||||||
|
- '.github/workflows/**'
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 0 * * *"
|
- cron: "0 0 * * *" # Run daily at midnight UTC
|
||||||
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
|
name: Validate with hassfest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: ⤵️ Check out code from GitHub
|
||||||
- uses: home-assistant/actions/hassfest@master
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🚀 Run hassfest validation
|
||||||
|
uses: home-assistant/actions/hassfest@master
|
||||||
|
|
||||||
|
- name: ℹ️ Print hassfest version
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "Hassfest version: $(hassfest --version)"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
name: Validate
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 0 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.x"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
pytest
|
|
||||||
@@ -1,145 +1,198 @@
|
|||||||
# 🤖 HA text AI Integration for Home Assistant
|
# 🤖 HA Text AI for Home Assistant
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|
    [](https://opensource.org/licenses/MIT) [](https://github.com/hacs/integration)
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
[](https://github.com/hacs/integration)
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Powerful OpenAI integration for Home Assistant enabling natural language interaction with your smart home
|
Transform your smart home experience with powerful AI assistance powered by multiple AI providers including OpenAI GPT and Anthropic Claude models. Get intelligent responses, automate complex scenarios, and enhance your home automation with advanced natural language processing.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Table of Contents
|
## 🌟 Features
|
||||||
- [Features](#-features)
|
|
||||||
- [Installation](#-installation)
|
|
||||||
- [Configuration](#-configuration)
|
|
||||||
- [Services](#-services)
|
|
||||||
- [Advanced Usage](#-advanced-usage)
|
|
||||||
- [Troubleshooting](#-troubleshooting)
|
|
||||||
- [Contributing](#-contributing)
|
|
||||||
|
|
||||||
## ✨ Features
|
- 🧠 **Multi-Provider AI Integration**:
|
||||||
|
- Support for OpenAI GPT models
|
||||||
|
- Anthropic Claude integration
|
||||||
|
- Custom API endpoints
|
||||||
|
- Flexible model selection
|
||||||
|
- 💬 **Advanced Language Processing**:
|
||||||
|
- Context-aware responses
|
||||||
|
- Multi-turn conversations
|
||||||
|
- Custom system instructions
|
||||||
|
- Natural conversation flow
|
||||||
|
- 📝 **Enhanced Memory Management**:
|
||||||
|
- Persistent conversation history
|
||||||
|
- Context-aware responses
|
||||||
|
- Customizable history limits
|
||||||
|
- Model-specific filtering
|
||||||
|
- ⚡ **Performance Optimization**:
|
||||||
|
- Efficient token usage
|
||||||
|
- Smart rate limiting
|
||||||
|
- Response caching
|
||||||
|
- Request interval control
|
||||||
|
- 🎯 **Advanced Customization**:
|
||||||
|
- Per-request model selection
|
||||||
|
- Adjustable parameters
|
||||||
|
- Custom system prompts
|
||||||
|
- Temperature control
|
||||||
|
- 🔒 **Enhanced Security**:
|
||||||
|
- Secure API key storage
|
||||||
|
- Rate limiting protection
|
||||||
|
- Error handling
|
||||||
|
- Usage monitoring
|
||||||
|
- 🎨 **Improved User Experience**:
|
||||||
|
- Intuitive configuration UI
|
||||||
|
- Detailed sensor attributes
|
||||||
|
- Rich service interface
|
||||||
|
- Model selection UI
|
||||||
|
- 🔄 **Automation Integration**:
|
||||||
|
- Event-driven responses
|
||||||
|
- Conditional logic support
|
||||||
|
- Template compatibility
|
||||||
|
- Model-specific automation
|
||||||
|
|
||||||
- 🔄 **Real-time AI Interaction**: Seamless communication with OpenAI's latest models
|
## 📋 Prerequisites
|
||||||
- 📝 **Conversation History**: Track and manage your AI interactions
|
|
||||||
- ⚙️ **Customizable Settings**: Fine-tune AI behavior with adjustable parameters
|
|
||||||
- 🔌 **Easy Integration**: Simple setup process through HACS or manual installation
|
|
||||||
- 🎯 **System Prompts**: Set context for more relevant AI responses
|
|
||||||
|
|
||||||
## 🚀 Installation
|
- Home Assistant 2023.8.0 or newer
|
||||||
|
- API key from supported providers:
|
||||||
|
- OpenAI ([Get key](https://platform.openai.com/account/api-keys))
|
||||||
|
- Anthropic ([Get key](https://console.anthropic.com/))
|
||||||
|
- Python 3.9 or newer
|
||||||
|
- Stable internet connection
|
||||||
|
|
||||||
### HACS Installation (Recommended)
|
## ⚡ Installation
|
||||||
1. Ensure [HACS](https://hacs.xyz/) is installed
|
|
||||||
2. Search for "HA text AI" in HACS
|
|
||||||
3. Click Install
|
|
||||||
4. Restart Home Assistant
|
|
||||||
|
|
||||||
<details>
|
### HACS Installation (Recommended)
|
||||||
<summary>Manual Installation Steps</summary>
|
1. Open HACS in Home Assistant
|
||||||
|
2. Click on the three dots in the top right corner
|
||||||
|
3. Select "Custom repositories"
|
||||||
|
4. Add `https://github.com/smkrv/ha-text-ai` as Integration
|
||||||
|
5. Click "Add"
|
||||||
|
6. Click on "Integrations" in HACS
|
||||||
|
7. Search for "HA Text AI"
|
||||||
|
8. Click "Download"
|
||||||
|
9. Restart Home Assistant
|
||||||
|
|
||||||
```bash
|
### Manual Installation
|
||||||
# 1. Navigate to your Home Assistant configuration directory
|
1. Download the latest release
|
||||||
cd ~/.homeassistant
|
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory
|
||||||
|
3. Restart Home Assistant
|
||||||
# 2. Create custom_components directory if it doesn't exist
|
4. Add configuration via UI or YAML
|
||||||
mkdir -p custom_components
|
|
||||||
|
|
||||||
# 3. Clone the repository
|
|
||||||
git clone https://github.com/smkrv/ha-text-ai.git custom_components/ha-text-ai
|
|
||||||
|
|
||||||
# 4. Restart Home Assistant
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ Configuration
|
||||||
|
|
||||||
### Basic Configuration
|
### Via UI (Recommended)
|
||||||
|
1. Go to Settings → Devices & Services
|
||||||
|
2. Click "Add Integration"
|
||||||
|
3. Search for "HA Text AI"
|
||||||
|
4. Follow the configuration steps
|
||||||
|
|
||||||
|
### Via YAML
|
||||||
```yaml
|
```yaml
|
||||||
ha-text-ai:
|
ha_text_ai:
|
||||||
api_key: your_openai_api_key
|
api_key: !secret ai_api_key
|
||||||
model: gpt-3.5-turbo
|
model: gpt-3.5-turbo # or claude-3-sonnet
|
||||||
|
temperature: 0.7
|
||||||
|
max_tokens: 1000
|
||||||
|
request_interval: 1.0
|
||||||
|
api_endpoint: https://api.openai.com/v1 # optional, for custom endpoints
|
||||||
|
system_prompt: |
|
||||||
|
You are a home automation expert assistant.
|
||||||
|
Focus on practical and efficient solutions.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Configuration
|
## 🛠️ Available Services
|
||||||
```yaml
|
|
||||||
ha-text-ai:
|
|
||||||
api_key: your_openai_api_key
|
|
||||||
model: gpt-4
|
|
||||||
temperature: 0.8
|
|
||||||
max_tokens: 2000
|
|
||||||
api_endpoint: https://custom-endpoint.com/v1
|
|
||||||
request_interval: 2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠 Services
|
### ask_question
|
||||||
|
|
||||||
### Ask Question
|
|
||||||
```yaml
|
```yaml
|
||||||
service: ha-text-ai.ask_question
|
service: ha_text_ai.ask_question
|
||||||
data:
|
data:
|
||||||
question: "What's the weather like today?"
|
question: "What's the optimal temperature for sleeping?"
|
||||||
model: "gpt-4" # optional
|
model: "claude-3-sonnet" # optional
|
||||||
temperature: 0.7 # optional
|
temperature: 0.5 # optional
|
||||||
|
max_tokens: 500 # optional
|
||||||
|
system_prompt: "You are a sleep optimization expert" # optional
|
||||||
```
|
```
|
||||||
|
|
||||||
### More Services
|
### set_system_prompt
|
||||||
- `ha-text-ai.clear_history`: Reset conversation history
|
|
||||||
- `ha-text-ai.get_history`: Retrieve past interactions
|
|
||||||
- `ha-text-ai.set_system_prompt`: Configure AI behavior
|
|
||||||
|
|
||||||
## 🔍 Advanced Usage
|
|
||||||
|
|
||||||
### Automation Example
|
|
||||||
```yaml
|
```yaml
|
||||||
automation:
|
service: ha_text_ai.set_system_prompt
|
||||||
trigger:
|
data:
|
||||||
platform: state
|
prompt: |
|
||||||
entity_id: binary_sensor.motion
|
You are a home automation expert focused on:
|
||||||
to: 'on'
|
1. Energy efficiency
|
||||||
action:
|
2. Comfort optimization
|
||||||
service: ha-text-ai.ask_question
|
3. Security considerations
|
||||||
data:
|
Provide practical, actionable advice.
|
||||||
question: "What should I do when motion is detected?"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Troubleshooting
|
### clear_history
|
||||||
|
```yaml
|
||||||
|
service: ha_text_ai.clear_history
|
||||||
|
```
|
||||||
|
|
||||||
<details>
|
### get_history
|
||||||
<summary>Common Issues and Solutions</summary>
|
```yaml
|
||||||
|
service: ha_text_ai.get_history
|
||||||
|
data:
|
||||||
|
limit: 5 # optional
|
||||||
|
filter_model: "gpt-4" # optional
|
||||||
|
```
|
||||||
|
|
||||||
### API Key Issues
|
## 📘 FAQ
|
||||||
- Verify API key format
|
|
||||||
- Check API key permissions
|
|
||||||
- Ensure proper configuration in secrets.yaml
|
|
||||||
|
|
||||||
### Connection Problems
|
**Q: Which AI providers are supported?**
|
||||||
- Verify internet connection
|
|
||||||
- Check API endpoint accessibility
|
|
||||||
- Review Home Assistant logs
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 👥 Contributing
|
A: Currently OpenAI (GPT models) and Anthropic (Claude models) are supported, with more providers planned.
|
||||||
|
|
||||||
We welcome contributions! Here's how you can help:
|
**Q: How can I reduce API costs?**
|
||||||
|
|
||||||
|
A: Use GPT-3.5-Turbo or Claude-3-Sonnet for most queries, implement caching, and optimize token usage.
|
||||||
|
|
||||||
|
**Q: Can I use custom models?**
|
||||||
|
|
||||||
|
A: Yes, you can configure custom endpoints and use any compatible model by specifying it in the configuration.
|
||||||
|
|
||||||
|
**Q: How do I switch between different AI providers?**
|
||||||
|
|
||||||
|
A: Simply change the model parameter in your configuration or service calls to use the desired provider's model.
|
||||||
|
|
||||||
|
**Q: How can I reduce API costs?**
|
||||||
|
|
||||||
|
A: Use GPT-3.5-Turbo for most queries, implement caching, and optimize token usage.
|
||||||
|
|
||||||
|
**Q: Is my data secure?**
|
||||||
|
|
||||||
|
A: Yes, API keys are stored securely and data is transmitted via encrypted connections.
|
||||||
|
|
||||||
|
**Q: Can I use custom models?**
|
||||||
|
|
||||||
|
A: Yes, configure custom endpoints and models via configuration options.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md).
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch
|
2. Create feature branch (`git checkout -b feature/Enhancement`)
|
||||||
3. Commit your changes
|
3. Commit changes (`git commit -m 'Add Enhancement'`)
|
||||||
4. Push to the branch
|
4. Push branch (`git push origin feature/Enhancement`)
|
||||||
5. Submit a pull request
|
5. Open Pull Request
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**[Documentation](https://github.com/smkrv/ha-text-ai/wiki)** | **[Report Bug](https://github.com/smkrv/ha-text-ai/issues)** | **[Request Feature](https://github.com/smkrv/ha-text-ai/issues)**
|
Made with ❤️ for the Home Assistant Community
|
||||||
|
|
||||||
|
[Report Bug](https://github.com/smkrv/ha-text-ai/issues) · [Request Feature](https://github.com/smkrv/ha-text-ai/issues)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,207 +1,375 @@
|
|||||||
"""The HA text AI integration."""
|
"""The HA Text AI integration."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Dict, Optional
|
||||||
|
import asyncio
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_API_KEY, Platform
|
from homeassistant.const import CONF_API_KEY
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.helpers import aiohttp_client
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from async_timeout import timeout
|
||||||
|
|
||||||
|
from .coordinator import HATextAICoordinator
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
SERVICE_ASK_QUESTION,
|
|
||||||
SERVICE_CLEAR_HISTORY,
|
|
||||||
SERVICE_GET_HISTORY,
|
|
||||||
SERVICE_SET_SYSTEM_PROMPT,
|
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
CONF_TEMPERATURE,
|
CONF_TEMPERATURE,
|
||||||
CONF_MAX_TOKENS,
|
CONF_MAX_TOKENS,
|
||||||
CONF_API_ENDPOINT,
|
CONF_API_ENDPOINT,
|
||||||
CONF_REQUEST_INTERVAL,
|
CONF_REQUEST_INTERVAL,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
DEFAULT_API_ENDPOINT,
|
||||||
|
DEFAULT_REQUEST_INTERVAL,
|
||||||
|
API_VERSION,
|
||||||
|
API_MODELS_PATH,
|
||||||
|
API_CHAT_PATH,
|
||||||
|
API_TIMEOUT,
|
||||||
|
API_RETRY_COUNT,
|
||||||
|
API_BACKOFF_FACTOR,
|
||||||
|
LOGGER_NAME,
|
||||||
|
STATE_ERROR,
|
||||||
|
STATE_READY,
|
||||||
|
STATE_PROCESSING,
|
||||||
|
STATE_RATE_LIMITED,
|
||||||
|
STATE_MAINTENANCE,
|
||||||
|
STATE_DISCONNECTED,
|
||||||
|
STATE_RETRYING,
|
||||||
|
STATE_QUEUED,
|
||||||
|
STATE_UPDATING,
|
||||||
|
SUPPORTED_MODELS,
|
||||||
|
EVENT_RESPONSE_RECEIVED,
|
||||||
|
EVENT_ERROR_OCCURRED,
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
)
|
)
|
||||||
from .coordinator import HATextAICoordinator
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(LOGGER_NAME)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
# Service validation schemas
|
||||||
|
SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||||
|
vol.Required("question"): cv.string,
|
||||||
|
vol.Optional("system_prompt"): cv.string,
|
||||||
|
vol.Optional("model"): vol.In(SUPPORTED_MODELS),
|
||||||
|
vol.Optional("temperature"): vol.All(
|
||||||
|
vol.Coerce(float), vol.Range(min=0, max=2)
|
||||||
|
),
|
||||||
|
vol.Optional("max_tokens"): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=4096)
|
||||||
|
),
|
||||||
|
vol.Optional("priority"): vol.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||||
|
vol.Optional("limit", default=10): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=100)
|
||||||
|
),
|
||||||
|
vol.Optional("filter_model"): vol.In(SUPPORTED_MODELS),
|
||||||
|
vol.Optional("start_date"): cv.datetime,
|
||||||
|
vol.Optional("include_metadata"): vol.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||||
|
vol.Required("prompt"): cv.string,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def async_check_api(session, endpoint: str, headers: dict, is_anthropic: bool = False) -> bool:
|
||||||
|
"""Check API availability for different providers."""
|
||||||
|
try:
|
||||||
|
if is_anthropic:
|
||||||
|
check_url = f"{endpoint}/v1/models"
|
||||||
|
else:
|
||||||
|
check_url = f"{endpoint}/{API_VERSION}/{API_MODELS_PATH}"
|
||||||
|
|
||||||
|
async with timeout(API_TIMEOUT):
|
||||||
|
async with session.get(check_url, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return True
|
||||||
|
elif response.status == 401:
|
||||||
|
raise ConfigEntryNotReady("Invalid API key")
|
||||||
|
elif response.status == 429:
|
||||||
|
_LOGGER.warning("Rate limit exceeded during API check")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
_LOGGER.error("API check failed with status: %d", response.status)
|
||||||
|
return False
|
||||||
|
except Exception as ex:
|
||||||
|
_LOGGER.error("API check error: %s", str(ex))
|
||||||
|
return False
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
"""Set up the HA text AI component from configuration.yaml."""
|
"""Set up the HA Text AI component."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {
|
||||||
|
"coordinators": {},
|
||||||
async def async_ask_question(call: ServiceCall) -> None:
|
"metrics": {
|
||||||
"""Handle the ask_question service call.
|
"total_requests": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
Args:
|
"errors": {},
|
||||||
call: Service call containing question and optional parameters.
|
"model_usage": {},
|
||||||
"""
|
}
|
||||||
try:
|
})
|
||||||
# Get the coordinator from the first config entry
|
|
||||||
if not hass.data[DOMAIN]:
|
|
||||||
raise HomeAssistantError("No AI Text integration configured")
|
|
||||||
|
|
||||||
coordinator = next(iter(hass.data[DOMAIN].values()))
|
|
||||||
|
|
||||||
question = call.data["question"]
|
|
||||||
model = call.data.get("model", coordinator.model)
|
|
||||||
temperature = call.data.get("temperature", coordinator.temperature)
|
|
||||||
max_tokens = call.data.get("max_tokens", coordinator.max_tokens)
|
|
||||||
|
|
||||||
# Temporarily update parameters if they were overridden
|
|
||||||
original_model = coordinator.model
|
|
||||||
original_temperature = coordinator.temperature
|
|
||||||
original_max_tokens = coordinator.max_tokens
|
|
||||||
|
|
||||||
try:
|
|
||||||
coordinator.model = model
|
|
||||||
coordinator.temperature = temperature
|
|
||||||
coordinator.max_tokens = max_tokens
|
|
||||||
await coordinator.async_ask_question(question)
|
|
||||||
finally:
|
|
||||||
# Restore original parameters
|
|
||||||
coordinator.model = original_model
|
|
||||||
coordinator.temperature = original_temperature
|
|
||||||
coordinator.max_tokens = original_max_tokens
|
|
||||||
except Exception as ex:
|
|
||||||
_LOGGER.error("Error asking question: %s", str(ex))
|
|
||||||
raise HomeAssistantError(f"Failed to ask question: {str(ex)}")
|
|
||||||
|
|
||||||
async def async_clear_history(call: ServiceCall) -> None:
|
|
||||||
"""Handle the clear_history service call."""
|
|
||||||
try:
|
|
||||||
if not hass.data[DOMAIN]:
|
|
||||||
raise HomeAssistantError("No AI Text integration configured")
|
|
||||||
|
|
||||||
coordinator = next(iter(hass.data[DOMAIN].values()))
|
|
||||||
coordinator._responses.clear()
|
|
||||||
await coordinator.async_refresh()
|
|
||||||
except Exception as ex:
|
|
||||||
_LOGGER.error("Error clearing history: %s", str(ex))
|
|
||||||
raise HomeAssistantError(f"Failed to clear history: {str(ex)}")
|
|
||||||
|
|
||||||
async def async_get_history(call: ServiceCall) -> dict[str, list]:
|
|
||||||
"""Handle the get_history service call.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing chat history.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not hass.data[DOMAIN]:
|
|
||||||
raise HomeAssistantError("No AI Text integration configured")
|
|
||||||
|
|
||||||
coordinator = next(iter(hass.data[DOMAIN].values()))
|
|
||||||
limit = call.data.get("limit", 10)
|
|
||||||
history = list(coordinator._responses.items())[-limit:]
|
|
||||||
return {
|
|
||||||
"history": [
|
|
||||||
{"question": q, "response": r} for q, r in history
|
|
||||||
]
|
|
||||||
}
|
|
||||||
except Exception as ex:
|
|
||||||
_LOGGER.error("Error getting history: %s", str(ex))
|
|
||||||
raise HomeAssistantError(f"Failed to get history: {str(ex)}")
|
|
||||||
|
|
||||||
async def async_set_system_prompt(call: ServiceCall) -> None:
|
|
||||||
"""Handle the set_system_prompt service call."""
|
|
||||||
try:
|
|
||||||
if not hass.data[DOMAIN]:
|
|
||||||
raise HomeAssistantError("No AI Text integration configured")
|
|
||||||
|
|
||||||
coordinator = next(iter(hass.data[DOMAIN].values()))
|
|
||||||
prompt = call.data["prompt"]
|
|
||||||
coordinator.system_prompt = prompt
|
|
||||||
except Exception as ex:
|
|
||||||
_LOGGER.error("Error setting system prompt: %s", str(ex))
|
|
||||||
raise HomeAssistantError(f"Failed to set system prompt: {str(ex)}")
|
|
||||||
|
|
||||||
# Register services
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_ASK_QUESTION,
|
|
||||||
async_ask_question,
|
|
||||||
schema=vol.Schema({
|
|
||||||
vol.Required("question"): cv.string,
|
|
||||||
vol.Optional("model"): cv.string,
|
|
||||||
vol.Optional("temperature"): vol.All(
|
|
||||||
vol.Coerce(float), vol.Range(min=0, max=2)
|
|
||||||
),
|
|
||||||
vol.Optional("max_tokens"): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=1, max=4096)
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CLEAR_HISTORY,
|
|
||||||
async_clear_history,
|
|
||||||
schema=vol.Schema({})
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_GET_HISTORY,
|
|
||||||
async_get_history,
|
|
||||||
schema=vol.Schema({
|
|
||||||
vol.Optional("limit", default=10): vol.All(
|
|
||||||
vol.Coerce(int), vol.Range(min=1)
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_SET_SYSTEM_PROMPT,
|
|
||||||
async_set_system_prompt,
|
|
||||||
schema=vol.Schema({
|
|
||||||
vol.Required("prompt"): cv.string,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up HA text AI from a config entry."""
|
"""Set up HA Text AI from a config entry."""
|
||||||
try:
|
try:
|
||||||
|
session = aiohttp_client.async_get_clientsession(hass)
|
||||||
|
|
||||||
|
# Determine API type based on model
|
||||||
|
model = entry.data.get(CONF_MODEL, DEFAULT_MODEL)
|
||||||
|
is_anthropic = any(model.startswith("claude") for model in SUPPORTED_MODELS)
|
||||||
|
|
||||||
|
api_key = entry.data[CONF_API_KEY]
|
||||||
|
endpoint = entry.data.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT).rstrip('/')
|
||||||
|
|
||||||
|
# Configure headers based on API type
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_anthropic:
|
||||||
|
headers["x-api-key"] = api_key
|
||||||
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
# Check API with retries
|
||||||
|
for attempt in range(API_RETRY_COUNT):
|
||||||
|
if await async_check_api(session, endpoint, headers, is_anthropic):
|
||||||
|
break
|
||||||
|
if attempt < API_RETRY_COUNT - 1:
|
||||||
|
delay = API_BACKOFF_FACTOR * (2 ** attempt)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
raise ConfigEntryNotReady("Failed to connect to API")
|
||||||
|
|
||||||
|
# Create and initialize coordinator
|
||||||
coordinator = HATextAICoordinator(
|
coordinator = HATextAICoordinator(
|
||||||
hass,
|
hass,
|
||||||
api_key=entry.data[CONF_API_KEY],
|
api_key=api_key,
|
||||||
endpoint=entry.data.get(CONF_API_ENDPOINT),
|
endpoint=endpoint,
|
||||||
model=entry.data.get(CONF_MODEL),
|
model=model,
|
||||||
temperature=entry.data.get(CONF_TEMPERATURE),
|
temperature=entry.data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE),
|
||||||
max_tokens=entry.data.get(CONF_MAX_TOKENS),
|
max_tokens=entry.data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS),
|
||||||
request_interval=entry.data.get(CONF_REQUEST_INTERVAL),
|
request_interval=entry.data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL),
|
||||||
|
session=session,
|
||||||
|
is_anthropic=is_anthropic
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Initialize coordinator
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
# Check coordinator status
|
||||||
|
if coordinator.endpoint_status == "auth_error":
|
||||||
|
raise ConfigEntryNotReady("Authentication failed")
|
||||||
|
elif coordinator.endpoint_status == "rate_limited":
|
||||||
|
_LOGGER.warning("API rate limited during setup")
|
||||||
|
elif coordinator.endpoint_status == "maintenance":
|
||||||
|
raise ConfigEntryNotReady("API is in maintenance mode")
|
||||||
|
elif coordinator.endpoint_status == "error":
|
||||||
|
raise ConfigEntryNotReady("API error during setup")
|
||||||
|
elif not coordinator.last_update_success:
|
||||||
|
raise ConfigEntryNotReady("Failed to initialize coordinator")
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
return await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
# Register event handlers
|
||||||
|
@callback
|
||||||
|
def handle_state_change(event):
|
||||||
|
"""Handle state changes."""
|
||||||
|
if event.data.get("entity_id").startswith(f"{DOMAIN}."):
|
||||||
|
_LOGGER.debug("State changed: %s", event.data)
|
||||||
|
|
||||||
|
hass.bus.async_listen(EVENT_STATE_CHANGED, handle_state_change)
|
||||||
|
|
||||||
|
# Register services
|
||||||
|
async def async_ask_question(call: ServiceCall) -> None:
|
||||||
|
"""Handle the ask_question service call."""
|
||||||
|
question = call.data.get("question", "")
|
||||||
|
if not question:
|
||||||
|
_LOGGER.error("No question provided in service call")
|
||||||
|
return
|
||||||
|
|
||||||
|
request_params = {}
|
||||||
|
for param in ["system_prompt", "model", "temperature", "max_tokens"]:
|
||||||
|
if param in call.data:
|
||||||
|
request_params[param] = call.data[param]
|
||||||
|
|
||||||
|
try:
|
||||||
|
await coordinator.async_ask_question(question, **request_params)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error asking question: %s", str(err))
|
||||||
|
|
||||||
|
async def async_clear_history(call: ServiceCall) -> None:
|
||||||
|
"""Handle the clear_history service call."""
|
||||||
|
try:
|
||||||
|
coordinator._responses.clear()
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
_LOGGER.info("History cleared successfully")
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error clearing history: %s", str(err))
|
||||||
|
|
||||||
|
async def async_get_history(call: ServiceCall) -> dict:
|
||||||
|
"""Handle the get_history service call."""
|
||||||
|
try:
|
||||||
|
limit = min(int(call.data.get("limit", 10)), 100)
|
||||||
|
filter_model = str(call.data.get("filter_model", ""))
|
||||||
|
start_date = call.data.get("start_date")
|
||||||
|
include_metadata = call.data.get("include_metadata", False)
|
||||||
|
|
||||||
|
responses = coordinator._responses
|
||||||
|
metrics = {
|
||||||
|
"total_requests": coordinator.request_count,
|
||||||
|
"total_tokens": coordinator.tokens_used,
|
||||||
|
"api_version": coordinator.api_version,
|
||||||
|
"endpoint_status": coordinator.endpoint_status,
|
||||||
|
"error_count": coordinator.error_count
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered_responses = responses.copy()
|
||||||
|
|
||||||
|
if filter_model:
|
||||||
|
filtered_responses = {
|
||||||
|
k: v for k, v in filtered_responses.items()
|
||||||
|
if v.get("model") == filter_model
|
||||||
|
}
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
filtered_responses = {
|
||||||
|
k: v for k, v in filtered_responses.items()
|
||||||
|
if v.get("timestamp") >= start_date
|
||||||
|
}
|
||||||
|
|
||||||
|
if not include_metadata:
|
||||||
|
filtered_responses = {
|
||||||
|
k: {
|
||||||
|
"question": v["question"],
|
||||||
|
"response": v["response"],
|
||||||
|
"timestamp": v["timestamp"]
|
||||||
|
} for k, v in filtered_responses.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted_responses = dict(
|
||||||
|
sorted(
|
||||||
|
filtered_responses.items(),
|
||||||
|
key=lambda x: x[1]["timestamp"],
|
||||||
|
reverse=True
|
||||||
|
)[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metrics": metrics,
|
||||||
|
"responses": sorted_responses
|
||||||
|
}
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error getting history: %s", str(err))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||||
|
"""Handle the set_system_prompt service call."""
|
||||||
|
try:
|
||||||
|
prompt = str(call.data.get("prompt", "")).strip()
|
||||||
|
if prompt:
|
||||||
|
coordinator.system_prompt = prompt
|
||||||
|
_LOGGER.info("System prompt updated successfully")
|
||||||
|
else:
|
||||||
|
_LOGGER.error("Empty prompt provided")
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error setting system prompt: %s", str(err))
|
||||||
|
|
||||||
|
# Register services with validation
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"ask_question",
|
||||||
|
async_ask_question,
|
||||||
|
schema=SERVICE_SCHEMA_ASK_QUESTION
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"clear_history",
|
||||||
|
async_clear_history
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"get_history",
|
||||||
|
async_get_history,
|
||||||
|
schema=SERVICE_SCHEMA_GET_HISTORY
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"set_system_prompt",
|
||||||
|
async_set_system_prompt,
|
||||||
|
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"Successfully set up HA Text AI with model: %s",
|
||||||
|
entry.data.get(CONF_MODEL, DEFAULT_MODEL)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_LOGGER.error("Error setting up entry: %s", str(ex))
|
_LOGGER.exception("Setup error: %s", str(ex))
|
||||||
raise ConfigEntryNotReady from ex
|
raise ConfigEntryNotReady from ex
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
try:
|
try:
|
||||||
|
coordinator = hass.data[DOMAIN].get(entry.entry_id)
|
||||||
|
if coordinator:
|
||||||
|
# Clear queue and history
|
||||||
|
coordinator._responses.clear()
|
||||||
|
while not coordinator._question_queue.empty():
|
||||||
|
try:
|
||||||
|
coordinator._question_queue.get_nowait()
|
||||||
|
coordinator._question_queue.task_done()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Close connection
|
||||||
|
await coordinator.async_shutdown()
|
||||||
|
|
||||||
|
# Remove services
|
||||||
|
for service in ["ask_question", "clear_history", "get_history", "set_system_prompt"]:
|
||||||
|
hass.services.async_remove(DOMAIN, service)
|
||||||
|
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
# Only remove services if this is the last entry
|
|
||||||
if not hass.data[DOMAIN]:
|
|
||||||
for service in [
|
|
||||||
SERVICE_ASK_QUESTION,
|
|
||||||
SERVICE_CLEAR_HISTORY,
|
|
||||||
SERVICE_GET_HISTORY,
|
|
||||||
SERVICE_SET_SYSTEM_PROMPT
|
|
||||||
]:
|
|
||||||
hass.services.async_remove(DOMAIN, service)
|
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_LOGGER.error("Error unloading entry: %s", str(ex))
|
_LOGGER.exception("Error unloading entry: %s", str(ex))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
_LOGGER.debug("Migrating from version %s", entry.version)
|
||||||
|
|
||||||
|
if entry.version == 1:
|
||||||
|
new = {**entry.data}
|
||||||
|
|
||||||
|
# Migrate settings
|
||||||
|
if CONF_MODEL in new and new[CONF_MODEL] not in SUPPORTED_MODELS:
|
||||||
|
new[CONF_MODEL] = DEFAULT_MODEL
|
||||||
|
|
||||||
|
entry.version = 2
|
||||||
|
hass.config_entries.async_update_entry(entry, data=new)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
"""Config flow for HA text AI integration."""
|
"""Config flow for HA text AI integration."""
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from async_timeout import timeout
|
||||||
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_API_KEY
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -16,72 +25,264 @@ from .const import (
|
|||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
DEFAULT_API_ENDPOINT,
|
DEFAULT_API_ENDPOINT,
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
DEFAULT_REQUEST_INTERVAL,
|
||||||
|
MIN_TEMPERATURE,
|
||||||
|
MAX_TEMPERATURE,
|
||||||
|
MIN_MAX_TOKENS,
|
||||||
|
MAX_MAX_TOKENS,
|
||||||
|
MIN_REQUEST_INTERVAL,
|
||||||
|
API_VERSION,
|
||||||
|
API_MODELS_PATH,
|
||||||
|
ERROR_INVALID_API_KEY,
|
||||||
|
ERROR_CANNOT_CONNECT,
|
||||||
|
ERROR_UNKNOWN,
|
||||||
|
ERROR_INVALID_MODEL,
|
||||||
|
ERROR_RATE_LIMIT,
|
||||||
|
ERROR_API_ERROR,
|
||||||
|
ERROR_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
class HATextAIConfigFlow(config_entries.ConfigFlow):
|
import logging
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_API_KEY): cv.string,
|
||||||
|
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.string,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_TEMPERATURE,
|
||||||
|
default=DEFAULT_TEMPERATURE
|
||||||
|
): vol.All(
|
||||||
|
vol.Coerce(float),
|
||||||
|
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||||
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_MAX_TOKENS,
|
||||||
|
default=DEFAULT_MAX_TOKENS
|
||||||
|
): vol.All(
|
||||||
|
vol.Coerce(int),
|
||||||
|
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||||
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_API_ENDPOINT,
|
||||||
|
default=DEFAULT_API_ENDPOINT
|
||||||
|
): cv.string,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_REQUEST_INTERVAL,
|
||||||
|
default=DEFAULT_REQUEST_INTERVAL
|
||||||
|
): vol.All(
|
||||||
|
vol.Coerce(float),
|
||||||
|
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_api_connection(
|
||||||
|
hass,
|
||||||
|
api_key: str,
|
||||||
|
endpoint: str,
|
||||||
|
model: str,
|
||||||
|
retry_count: int = 3,
|
||||||
|
retry_delay: float = 1.0
|
||||||
|
) -> Tuple[bool, str, list]:
|
||||||
|
"""Validate API connection with retry logic."""
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Accept": "application/json; charset=utf-8",
|
||||||
|
"Accept-Charset": "utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
base_url = endpoint.rstrip('/')
|
||||||
|
if not base_url.endswith(f"/{API_VERSION}"):
|
||||||
|
base_url = f"{base_url}/{API_VERSION}"
|
||||||
|
|
||||||
|
models_url = f"{base_url}/{API_MODELS_PATH}"
|
||||||
|
_LOGGER.debug("Attempting to connect to: %s", models_url)
|
||||||
|
|
||||||
|
for attempt in range(retry_count):
|
||||||
|
try:
|
||||||
|
async with timeout(10):
|
||||||
|
async with session.get(models_url, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
model_ids = [m["id"] for m in data.get("data", [])]
|
||||||
|
|
||||||
|
_LOGGER.debug("Available models: %s", ", ".join(model_ids))
|
||||||
|
|
||||||
|
if model not in model_ids:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Model %s not found in available models: %s",
|
||||||
|
model,
|
||||||
|
", ".join(model_ids)
|
||||||
|
)
|
||||||
|
return False, ERROR_INVALID_MODEL, model_ids
|
||||||
|
return True, "", model_ids
|
||||||
|
|
||||||
|
elif response.status == 401:
|
||||||
|
_LOGGER.error("Authentication failed")
|
||||||
|
return False, ERROR_INVALID_API_KEY, []
|
||||||
|
|
||||||
|
elif response.status == 429:
|
||||||
|
_LOGGER.error("Rate limit exceeded")
|
||||||
|
return False, ERROR_RATE_LIMIT, []
|
||||||
|
|
||||||
|
else:
|
||||||
|
response_text = await response.text()
|
||||||
|
_LOGGER.error(
|
||||||
|
"API error: %s - %s",
|
||||||
|
response.status,
|
||||||
|
response_text
|
||||||
|
)
|
||||||
|
return False, ERROR_API_ERROR, []
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Timeout during API validation (attempt %d/%d)",
|
||||||
|
attempt + 1,
|
||||||
|
retry_count
|
||||||
|
)
|
||||||
|
if attempt == retry_count - 1:
|
||||||
|
return False, ERROR_TIMEOUT, []
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("Connection error: %s", str(err))
|
||||||
|
return False, ERROR_CANNOT_CONNECT, []
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.exception("Unexpected error during validation: %s", str(err))
|
||||||
|
return False, ERROR_UNKNOWN, []
|
||||||
|
|
||||||
|
return False, ERROR_UNKNOWN, []
|
||||||
|
|
||||||
|
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for HA text AI."""
|
"""Handle a config flow for HA text AI."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
DOMAIN = DOMAIN # Define the domain as a class variable
|
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(
|
||||||
|
self,
|
||||||
|
user_input: Optional[Dict[str, Any]] = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors = {}
|
errors: Dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return self.async_create_entry(title="HA text AI", data=user_input)
|
try:
|
||||||
|
endpoint = user_input.get(CONF_API_ENDPOINT, DEFAULT_API_ENDPOINT)
|
||||||
|
try:
|
||||||
|
result = urlparse(endpoint)
|
||||||
|
if not all([result.scheme, result.netloc]):
|
||||||
|
errors["base"] = "invalid_url_format"
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
|
errors=errors
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error("URL parsing error: %s", str(e))
|
||||||
|
errors["base"] = "invalid_url_format"
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
|
errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
validated_input = STEP_USER_DATA_SCHEMA(user_input)
|
||||||
|
|
||||||
|
is_valid, error_code, available_models = await validate_api_connection(
|
||||||
|
self.hass,
|
||||||
|
validated_input[CONF_API_KEY],
|
||||||
|
endpoint,
|
||||||
|
validated_input[CONF_MODEL]
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
await self.async_set_unique_id(validated_input[CONF_API_KEY])
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="HA Text AI",
|
||||||
|
data=validated_input
|
||||||
|
)
|
||||||
|
|
||||||
|
errors["base"] = error_code
|
||||||
|
if error_code == ERROR_INVALID_MODEL:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Selected model %s not found in available models: %s",
|
||||||
|
validated_input[CONF_MODEL],
|
||||||
|
", ".join(available_models)
|
||||||
|
)
|
||||||
|
|
||||||
|
except vol.Invalid as err:
|
||||||
|
_LOGGER.error("Validation error: %s", str(err))
|
||||||
|
errors["base"] = "invalid_input"
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=vol.Schema({
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
vol.Required("api_key"): str,
|
|
||||||
vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): str,
|
|
||||||
vol.Optional(CONF_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.Coerce(float),
|
|
||||||
vol.Optional(CONF_MAX_TOKENS, default=DEFAULT_MAX_TOKENS): vol.Coerce(int),
|
|
||||||
vol.Optional(CONF_API_ENDPOINT, default=DEFAULT_API_ENDPOINT): str,
|
|
||||||
vol.Optional(CONF_REQUEST_INTERVAL, default=DEFAULT_REQUEST_INTERVAL): vol.Coerce(float),
|
|
||||||
}),
|
|
||||||
errors=errors,
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
"default_model": DEFAULT_MODEL,
|
||||||
|
"default_endpoint": DEFAULT_API_ENDPOINT,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry):
|
def async_get_options_flow(
|
||||||
|
config_entry: config_entries.ConfigEntry,
|
||||||
|
) -> config_entries.OptionsFlow:
|
||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return OptionsFlowHandler(config_entry)
|
return OptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
"""Handle options flow for HA text AI."""
|
"""Handle options flow for HA text AI."""
|
||||||
|
|
||||||
def __init__(self, config_entry):
|
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||||
"""Initialize options flow."""
|
"""Initialize options flow."""
|
||||||
self.config_entry = config_entry
|
self.config_entry = config_entry
|
||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
async def async_step_init(
|
||||||
|
self,
|
||||||
|
user_input: Optional[Dict[str, Any]] = None
|
||||||
|
) -> FlowResult:
|
||||||
"""Handle options flow."""
|
"""Handle options flow."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return self.async_create_entry(title="", data=user_input)
|
return self.async_create_entry(title="", data=user_input)
|
||||||
|
|
||||||
|
options_schema = vol.Schema({
|
||||||
|
vol.Optional(
|
||||||
|
CONF_TEMPERATURE,
|
||||||
|
default=self.config_entry.options.get(
|
||||||
|
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
|
||||||
|
),
|
||||||
|
): vol.All(
|
||||||
|
vol.Coerce(float),
|
||||||
|
vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||||
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_MAX_TOKENS,
|
||||||
|
default=self.config_entry.options.get(
|
||||||
|
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
|
||||||
|
),
|
||||||
|
): vol.All(
|
||||||
|
vol.Coerce(int),
|
||||||
|
vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS)
|
||||||
|
),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_REQUEST_INTERVAL,
|
||||||
|
default=self.config_entry.options.get(
|
||||||
|
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
|
||||||
|
),
|
||||||
|
): vol.All(
|
||||||
|
vol.Coerce(float),
|
||||||
|
vol.Range(min=MIN_REQUEST_INTERVAL)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=vol.Schema({
|
data_schema=options_schema,
|
||||||
vol.Optional(
|
|
||||||
CONF_TEMPERATURE,
|
|
||||||
default=self.config_entry.options.get(
|
|
||||||
CONF_TEMPERATURE, DEFAULT_TEMPERATURE
|
|
||||||
),
|
|
||||||
): vol.Coerce(float),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_MAX_TOKENS,
|
|
||||||
default=self.config_entry.options.get(
|
|
||||||
CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS
|
|
||||||
),
|
|
||||||
): vol.Coerce(int),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_REQUEST_INTERVAL,
|
|
||||||
default=self.config_entry.options.get(
|
|
||||||
CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL
|
|
||||||
),
|
|
||||||
): vol.Coerce(float),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,30 +1,184 @@
|
|||||||
"""Constants for the HA text AI integration."""
|
"""Constants for the HA text AI integration."""
|
||||||
|
from typing import Final
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
DOMAIN = "ha-text-ai"
|
# Domain and platforms
|
||||||
PLATFORMS = [Platform.SENSOR]
|
DOMAIN: Final = "ha_text_ai"
|
||||||
|
PLATFORMS: Final = [Platform.SENSOR]
|
||||||
|
|
||||||
# Configuration
|
# Configuration constants
|
||||||
CONF_MODEL = "model"
|
CONF_MODEL: Final = "model"
|
||||||
CONF_TEMPERATURE = "temperature"
|
CONF_TEMPERATURE: Final = "temperature"
|
||||||
CONF_MAX_TOKENS = "max_tokens"
|
CONF_MAX_TOKENS: Final = "max_tokens"
|
||||||
CONF_API_ENDPOINT = "api_endpoint"
|
CONF_API_ENDPOINT: Final = "api_endpoint"
|
||||||
CONF_REQUEST_INTERVAL = "request_interval"
|
CONF_REQUEST_INTERVAL: Final = "request_interval"
|
||||||
|
|
||||||
# Defaults
|
# Model constants
|
||||||
DEFAULT_MODEL = "gpt-3.5-turbo"
|
SUPPORTED_MODELS: Final = [
|
||||||
DEFAULT_TEMPERATURE = 0.7
|
"gpt-3.5-turbo",
|
||||||
DEFAULT_MAX_TOKENS = 1000
|
"gpt-3.5-turbo-16k",
|
||||||
DEFAULT_API_ENDPOINT = "https://api.openai.com/v1"
|
"gpt-4",
|
||||||
DEFAULT_REQUEST_INTERVAL = 1.0
|
"gpt-4-32k",
|
||||||
|
"gpt-4-1106-preview",
|
||||||
|
"claude-3-sonnet",
|
||||||
|
"claude-3-opus"
|
||||||
|
]
|
||||||
|
|
||||||
# Services
|
# Default values
|
||||||
SERVICE_ASK_QUESTION = "ask_question"
|
DEFAULT_MODEL: Final = "gpt-3.5-turbo"
|
||||||
SERVICE_CLEAR_HISTORY = "clear_history"
|
DEFAULT_TEMPERATURE: Final = 0.1
|
||||||
SERVICE_GET_HISTORY = "get_history"
|
DEFAULT_MAX_TOKENS: Final = 1000
|
||||||
SERVICE_SET_SYSTEM_PROMPT = "set_system_prompt"
|
DEFAULT_API_ENDPOINT: Final = "https://api.openai.com"
|
||||||
|
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
||||||
|
DEFAULT_TIMEOUT: Final = 30
|
||||||
|
DEFAULT_QUEUE_SIZE: Final = 100
|
||||||
|
DEFAULT_HISTORY_LIMIT: Final = 50
|
||||||
|
DEFAULT_RETRY_COUNT: Final = 3
|
||||||
|
|
||||||
# Attributes
|
# Parameter constraints
|
||||||
ATTR_QUESTION = "question"
|
MIN_TEMPERATURE: Final = 0.0
|
||||||
ATTR_RESPONSE = "response"
|
MAX_TEMPERATURE: Final = 2.0
|
||||||
ATTR_LAST_UPDATED = "last_updated"
|
MIN_MAX_TOKENS: Final = 1
|
||||||
|
MAX_MAX_TOKENS: Final = 4096
|
||||||
|
MIN_REQUEST_INTERVAL: Final = 0.1
|
||||||
|
MIN_TIMEOUT: Final = 5
|
||||||
|
MAX_TIMEOUT: Final = 120
|
||||||
|
MAX_PROMPT_LENGTH: Final = 1000
|
||||||
|
MAX_HISTORY_LIMIT: Final = 100
|
||||||
|
|
||||||
|
# API constants
|
||||||
|
API_VERSION: Final = "v1"
|
||||||
|
API_MODELS_PATH: Final = "models"
|
||||||
|
API_CHAT_PATH: Final = "chat/completions"
|
||||||
|
API_TIMEOUT: Final = 30
|
||||||
|
API_RETRY_COUNT: Final = 3
|
||||||
|
API_BACKOFF_FACTOR: Final = 1.5
|
||||||
|
API_MAX_RETRIES: Final = 3
|
||||||
|
|
||||||
|
# History constants
|
||||||
|
HISTORY_FILTER_MODEL: Final = "filter_model"
|
||||||
|
HISTORY_FILTER_DATE: Final = "start_date"
|
||||||
|
HISTORY_SORT_ORDER: Final = "sort_order"
|
||||||
|
HISTORY_INCLUDE_METADATA: Final = "include_metadata"
|
||||||
|
|
||||||
|
# Service names
|
||||||
|
SERVICE_ASK_QUESTION: Final = "ask_question"
|
||||||
|
SERVICE_CLEAR_HISTORY: Final = "clear_history"
|
||||||
|
SERVICE_GET_HISTORY: Final = "get_history"
|
||||||
|
SERVICE_SET_SYSTEM_PROMPT: Final = "set_system_prompt"
|
||||||
|
|
||||||
|
# Service descriptions
|
||||||
|
SERVICE_ASK_QUESTION_DESCRIPTION: Final = "Ask a question to the AI model"
|
||||||
|
SERVICE_CLEAR_HISTORY_DESCRIPTION: Final = "Clear conversation history"
|
||||||
|
SERVICE_GET_HISTORY_DESCRIPTION: Final = "Get conversation history"
|
||||||
|
SERVICE_SET_SYSTEM_PROMPT_DESCRIPTION: Final = "Set system prompt for AI model"
|
||||||
|
|
||||||
|
# Attribute keys
|
||||||
|
ATTR_QUESTION: Final = "question"
|
||||||
|
ATTR_RESPONSE: Final = "response"
|
||||||
|
ATTR_LAST_UPDATED: Final = "last_updated"
|
||||||
|
ATTR_MODEL: Final = "model"
|
||||||
|
ATTR_TEMPERATURE: Final = "temperature"
|
||||||
|
ATTR_MAX_TOKENS: Final = "max_tokens"
|
||||||
|
ATTR_TOTAL_RESPONSES: Final = "total_responses"
|
||||||
|
ATTR_SYSTEM_PROMPT: Final = "system_prompt"
|
||||||
|
ATTR_RESPONSE_TIME: Final = "response_time"
|
||||||
|
ATTR_QUEUE_SIZE: Final = "queue_size"
|
||||||
|
ATTR_API_STATUS: Final = "api_status"
|
||||||
|
ATTR_ERROR_COUNT: Final = "error_count"
|
||||||
|
ATTR_LAST_ERROR: Final = "last_error"
|
||||||
|
ATTR_API_VERSION: Final = "api_version"
|
||||||
|
ATTR_ENDPOINT_STATUS: Final = "endpoint_status"
|
||||||
|
ATTR_REQUEST_COUNT: Final = "request_count"
|
||||||
|
ATTR_TOKENS_USED: Final = "tokens_used"
|
||||||
|
ATTR_RETRY_COUNT: Final = "retry_count"
|
||||||
|
ATTR_QUEUE_POSITION: Final = "queue_position"
|
||||||
|
ATTR_ESTIMATED_WAIT: Final = "estimated_wait"
|
||||||
|
|
||||||
|
# Error messages
|
||||||
|
ERROR_INVALID_API_KEY: Final = "invalid_api_key"
|
||||||
|
ERROR_CANNOT_CONNECT: Final = "cannot_connect"
|
||||||
|
ERROR_UNKNOWN: Final = "unknown_error"
|
||||||
|
ERROR_INVALID_MODEL: Final = "invalid_model"
|
||||||
|
ERROR_RATE_LIMIT: Final = "rate_limit_exceeded"
|
||||||
|
ERROR_CONTEXT_LENGTH: Final = "context_length_exceeded"
|
||||||
|
ERROR_API_ERROR: Final = "api_error"
|
||||||
|
ERROR_TIMEOUT: Final = "timeout_error"
|
||||||
|
ERROR_QUEUE_FULL: Final = "queue_full"
|
||||||
|
ERROR_INVALID_PROMPT: Final = "invalid_prompt"
|
||||||
|
ERROR_INVALID_PARAMETERS: Final = "invalid_parameters"
|
||||||
|
ERROR_SERVICE_UNAVAILABLE: Final = "service_unavailable"
|
||||||
|
|
||||||
|
# Configuration descriptions
|
||||||
|
CONF_MODEL_DESCRIPTION: Final = "AI model to use for responses"
|
||||||
|
CONF_TEMPERATURE_DESCRIPTION: Final = "Temperature for response generation (0-2)"
|
||||||
|
CONF_MAX_TOKENS_DESCRIPTION: Final = "Maximum tokens in response (1-4096)"
|
||||||
|
CONF_API_ENDPOINT_DESCRIPTION: Final = "API endpoint URL"
|
||||||
|
CONF_REQUEST_INTERVAL_DESCRIPTION: Final = "Minimum time between API requests (seconds)"
|
||||||
|
|
||||||
|
# Entity attributes descriptions
|
||||||
|
ATTR_QUESTION_DESCRIPTION: Final = "Last question asked"
|
||||||
|
ATTR_RESPONSE_DESCRIPTION: Final = "Last response received"
|
||||||
|
ATTR_LAST_UPDATED_DESCRIPTION: Final = "Time of last update"
|
||||||
|
ATTR_MODEL_DESCRIPTION: Final = "Current AI model in use"
|
||||||
|
ATTR_TEMPERATURE_DESCRIPTION: Final = "Current temperature setting"
|
||||||
|
ATTR_MAX_TOKENS_DESCRIPTION: Final = "Current max tokens setting"
|
||||||
|
ATTR_TOTAL_RESPONSES_DESCRIPTION: Final = "Total number of responses"
|
||||||
|
ATTR_SYSTEM_PROMPT_DESCRIPTION: Final = "Current system prompt"
|
||||||
|
ATTR_RESPONSE_TIME_DESCRIPTION: Final = "Time taken for last response"
|
||||||
|
ATTR_QUEUE_SIZE_DESCRIPTION: Final = "Current size of question queue"
|
||||||
|
ATTR_API_STATUS_DESCRIPTION: Final = "Current API connection status"
|
||||||
|
ATTR_ERROR_COUNT_DESCRIPTION: Final = "Total number of errors"
|
||||||
|
ATTR_LAST_ERROR_DESCRIPTION: Final = "Last error message"
|
||||||
|
ATTR_API_VERSION_DESCRIPTION: Final = "Current API version"
|
||||||
|
ATTR_ENDPOINT_STATUS_DESCRIPTION: Final = "Current endpoint status"
|
||||||
|
ATTR_REQUEST_COUNT_DESCRIPTION: Final = "Total number of API requests"
|
||||||
|
ATTR_TOKENS_USED_DESCRIPTION: Final = "Total tokens used"
|
||||||
|
|
||||||
|
# Entity attributes
|
||||||
|
ENTITY_NAME: Final = "HA Text AI"
|
||||||
|
ENTITY_ICON: Final = "mdi:robot"
|
||||||
|
ENTITY_ICON_ERROR: Final = "mdi:robot-dead"
|
||||||
|
ENTITY_ICON_PROCESSING: Final = "mdi:robot-excited"
|
||||||
|
ENTITY_ICON_OFFLINE: Final = "mdi:robot-off"
|
||||||
|
ENTITY_ICON_QUEUE: Final = "mdi:robot-confused"
|
||||||
|
|
||||||
|
# Translation keys
|
||||||
|
TRANSLATION_KEY_CONFIG: Final = "config"
|
||||||
|
TRANSLATION_KEY_OPTIONS: Final = "options"
|
||||||
|
TRANSLATION_KEY_ERROR: Final = "error"
|
||||||
|
TRANSLATION_KEY_STATE: Final = "state"
|
||||||
|
TRANSLATION_KEY_SERVICES: Final = "services"
|
||||||
|
|
||||||
|
# State attributes
|
||||||
|
STATE_READY: Final = "ready"
|
||||||
|
STATE_PROCESSING: Final = "processing"
|
||||||
|
STATE_ERROR: Final = "error"
|
||||||
|
STATE_DISCONNECTED: Final = "disconnected"
|
||||||
|
STATE_RATE_LIMITED: Final = "rate_limited"
|
||||||
|
STATE_INITIALIZING: Final = "initializing"
|
||||||
|
STATE_MAINTENANCE: Final = "maintenance"
|
||||||
|
STATE_RETRYING: Final = "retrying"
|
||||||
|
STATE_QUEUED: Final = "queued"
|
||||||
|
STATE_UPDATING: Final = "updating"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOGGER_NAME: Final = "custom_components.ha_text_ai"
|
||||||
|
LOG_LEVEL_DEFAULT: Final = "INFO"
|
||||||
|
|
||||||
|
# Queue constants
|
||||||
|
QUEUE_TIMEOUT: Final = 5
|
||||||
|
QUEUE_MAX_SIZE: Final = 100
|
||||||
|
|
||||||
|
# Service schema constants
|
||||||
|
SCHEMA_QUESTION: Final = "question"
|
||||||
|
SCHEMA_MODEL: Final = "model"
|
||||||
|
SCHEMA_TEMPERATURE: Final = "temperature"
|
||||||
|
SCHEMA_MAX_TOKENS: Final = "max_tokens"
|
||||||
|
SCHEMA_PROMPT: Final = "prompt"
|
||||||
|
SCHEMA_LIMIT: Final = "limit"
|
||||||
|
|
||||||
|
# Event names
|
||||||
|
EVENT_RESPONSE_RECEIVED: Final = f"{DOMAIN}_response_received"
|
||||||
|
EVENT_ERROR_OCCURRED: Final = f"{DOMAIN}_error_occurred"
|
||||||
|
EVENT_STATE_CHANGED: Final = f"{DOMAIN}_state_changed"
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional, List
|
||||||
|
import time
|
||||||
|
|
||||||
import openai
|
from openai import AsyncOpenAI, APIError, AuthenticationError, RateLimitError
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.util import dt as dt_util
|
||||||
|
import async_timeout
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DEFAULT_REQUEST_INTERVAL,
|
DEFAULT_TIMEOUT,
|
||||||
CONF_MODEL,
|
MAX_QUEUE_SIZE,
|
||||||
CONF_TEMPERATURE,
|
MAX_RETRIES,
|
||||||
CONF_MAX_TOKENS,
|
RETRY_DELAY,
|
||||||
|
SUPPORTED_MODELS,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -31,8 +35,22 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
request_interval: float,
|
request_interval: float,
|
||||||
|
session: Optional[Any] = None,
|
||||||
|
is_anthropic: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize coordinator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hass: HomeAssistant instance
|
||||||
|
api_key: API key for the service
|
||||||
|
endpoint: API endpoint URL
|
||||||
|
model: Model name to use
|
||||||
|
temperature: Temperature parameter for generation
|
||||||
|
max_tokens: Maximum tokens to generate
|
||||||
|
request_interval: Interval between requests
|
||||||
|
session: Optional session object
|
||||||
|
is_anthropic: Whether to use Anthropic API
|
||||||
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
@@ -40,75 +58,561 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
update_interval=timedelta(seconds=request_interval),
|
update_interval=timedelta(seconds=request_interval),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._validate_params(api_key, temperature, max_tokens)
|
||||||
|
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
self.model = model
|
self.model = model
|
||||||
self.temperature = temperature
|
self.temperature = float(temperature)
|
||||||
self.max_tokens = max_tokens
|
self.max_tokens = int(max_tokens)
|
||||||
self._question_queue = asyncio.Queue()
|
self._question_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
|
||||||
self._responses: Dict[str, Any] = {}
|
self._responses: Dict[str, Any] = {}
|
||||||
self.system_prompt: Optional[str] = None
|
self.system_prompt: Optional[str] = None
|
||||||
|
self._is_ready = False
|
||||||
|
self._is_processing = False
|
||||||
|
self._is_rate_limited = False
|
||||||
|
self._is_maintenance = False
|
||||||
|
self._error_count = 0
|
||||||
|
self._MAX_ERRORS = 3
|
||||||
|
self._request_count = 0
|
||||||
|
self._tokens_used = 0
|
||||||
|
self._api_version = "v1"
|
||||||
|
self._endpoint_status = "disconnected"
|
||||||
|
self._performance_metrics: Dict[str, Any] = {
|
||||||
|
"avg_response_time": 0,
|
||||||
|
"total_errors": 0,
|
||||||
|
"success_rate": 100,
|
||||||
|
"requests_per_minute": 0,
|
||||||
|
}
|
||||||
|
self._last_request_time = 0
|
||||||
|
self._is_anthropic = is_anthropic
|
||||||
|
|
||||||
openai.api_key = self.api_key
|
if is_anthropic:
|
||||||
if endpoint != "https://api.openai.com/v1":
|
self.client = AsyncAnthropic(api_key=self.api_key)
|
||||||
openai.api_base = endpoint
|
else:
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=self.api_key,
|
||||||
|
base_url=self.endpoint,
|
||||||
|
http_client=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_params(self, api_key: str, temperature: float, max_tokens: int) -> None:
|
||||||
|
"""Validate initialization parameters."""
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("API key is required")
|
||||||
|
if not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2:
|
||||||
|
raise ValueError("Temperature must be between 0 and 2")
|
||||||
|
if not isinstance(max_tokens, int) or max_tokens < 1:
|
||||||
|
raise ValueError("Max tokens must be a positive integer")
|
||||||
|
|
||||||
async def _async_update_data(self) -> Dict[str, Any]:
|
async def _async_update_data(self) -> Dict[str, Any]:
|
||||||
"""Update data via OpenAI API."""
|
"""Update data via API."""
|
||||||
if self._question_queue.empty():
|
if self._question_queue.empty():
|
||||||
return self._responses
|
return self._responses
|
||||||
|
|
||||||
try:
|
try:
|
||||||
question = await self._question_queue.get()
|
async with async_timeout.timeout(DEFAULT_TIMEOUT):
|
||||||
response_content = await self.hass.async_add_executor_job(
|
self._is_processing = True
|
||||||
self._make_api_call, question
|
priority, question_data = await self._question_queue.get()
|
||||||
|
question = question_data["question"]
|
||||||
|
params = question_data["params"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = await self._make_api_call(
|
||||||
|
question,
|
||||||
|
model=params.get("model"),
|
||||||
|
temperature=params.get("temperature"),
|
||||||
|
max_tokens=params.get("max_tokens"),
|
||||||
|
system_prompt=params.get("system_prompt")
|
||||||
|
)
|
||||||
|
|
||||||
|
self._update_metrics(response_data)
|
||||||
|
self._responses[question] = {
|
||||||
|
"question": question,
|
||||||
|
"response": response_data["response"],
|
||||||
|
"error": None,
|
||||||
|
"timestamp": dt_util.utcnow(),
|
||||||
|
"model": response_data["model"],
|
||||||
|
"temperature": params.get("temperature", self.temperature),
|
||||||
|
"max_tokens": params.get("max_tokens", self.max_tokens),
|
||||||
|
"response_time": response_data.get("response_time"),
|
||||||
|
"tokens": response_data.get("tokens", 0),
|
||||||
|
"priority": priority
|
||||||
|
}
|
||||||
|
self._error_count = 0
|
||||||
|
self._is_ready = True
|
||||||
|
self._endpoint_status = "connected"
|
||||||
|
self._request_count += 1
|
||||||
|
self._tokens_used += response_data.get("tokens", 0)
|
||||||
|
self._last_request_time = time.time()
|
||||||
|
|
||||||
|
# Fire event for successful response
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_response_received", {
|
||||||
|
"question": question,
|
||||||
|
"model": response_data["model"],
|
||||||
|
"tokens": response_data.get("tokens", 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
_LOGGER.debug("Response received for question: %s", question)
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
await self._handle_api_error(question, err)
|
||||||
|
finally:
|
||||||
|
self._is_processing = False
|
||||||
|
self._question_queue.task_done()
|
||||||
|
|
||||||
|
return self._responses
|
||||||
|
|
||||||
|
except asyncio.TimeoutError as err:
|
||||||
|
_LOGGER.error("Timeout while processing question")
|
||||||
|
await self._handle_timeout_error()
|
||||||
|
return self._responses
|
||||||
|
|
||||||
|
async def _handle_api_error(self, question: str, error: Exception) -> None:
|
||||||
|
"""Handle API errors with retry logic."""
|
||||||
|
self._error_count += 1
|
||||||
|
self._performance_metrics["total_errors"] += 1
|
||||||
|
error_msg = str(error)
|
||||||
|
|
||||||
|
if isinstance(error, AuthenticationError):
|
||||||
|
error_msg = "Authentication failed - invalid API key"
|
||||||
|
self._is_ready = False
|
||||||
|
self._endpoint_status = "auth_error"
|
||||||
|
elif isinstance(error, RateLimitError):
|
||||||
|
error_msg = "Rate limit exceeded"
|
||||||
|
self._is_rate_limited = True
|
||||||
|
self._endpoint_status = "rate_limited"
|
||||||
|
# Implement exponential backoff
|
||||||
|
await asyncio.sleep(RETRY_DELAY * (2 ** (self._error_count - 1)))
|
||||||
|
elif isinstance(error, APIError):
|
||||||
|
if "maintenance" in str(error).lower():
|
||||||
|
self._is_maintenance = True
|
||||||
|
self._endpoint_status = "maintenance"
|
||||||
|
error_msg = f"API error: {error}"
|
||||||
|
else:
|
||||||
|
self._endpoint_status = "error"
|
||||||
|
|
||||||
|
self._responses[question] = {
|
||||||
|
"question": question,
|
||||||
|
"response": None,
|
||||||
|
"error": error_msg,
|
||||||
|
"timestamp": dt_util.utcnow(),
|
||||||
|
"model": self.model,
|
||||||
|
"temperature": self.temperature,
|
||||||
|
"max_tokens": self.max_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fire error event
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_error_occurred", {
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"error_message": error_msg,
|
||||||
|
"question": question
|
||||||
|
})
|
||||||
|
|
||||||
|
_LOGGER.error("API error (%s): %s", type(error).__name__, error_msg)
|
||||||
|
|
||||||
|
if self._error_count >= self._MAX_ERRORS:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Multiple errors occurred (%d). Coordinator needs attention.",
|
||||||
|
self._error_count
|
||||||
)
|
)
|
||||||
response = {
|
|
||||||
"question": question,
|
|
||||||
"response": response_content
|
|
||||||
}
|
|
||||||
self._responses[question] = response
|
|
||||||
_LOGGER.debug(f"Response from API: {response}")
|
|
||||||
return self._responses
|
|
||||||
|
|
||||||
except openai.error.AuthenticationError as err:
|
def _update_metrics(self, response_data: Dict[str, Any]) -> None:
|
||||||
raise ConfigEntryAuthFailed from err
|
"""Update performance metrics."""
|
||||||
except Exception as err:
|
response_time = response_data.get("response_time", 0)
|
||||||
_LOGGER.error("Error communicating with API: %s", err)
|
current_avg = self._performance_metrics["avg_response_time"]
|
||||||
return self._responses
|
self._performance_metrics["avg_response_time"] = (
|
||||||
|
(current_avg * self._request_count + response_time) /
|
||||||
|
(self._request_count + 1)
|
||||||
|
)
|
||||||
|
|
||||||
def _make_api_call(self, question: str) -> str:
|
total_requests = self._request_count + 1
|
||||||
"""Make API call to OpenAI."""
|
self._performance_metrics["success_rate"] = (
|
||||||
|
(total_requests - self._performance_metrics["total_errors"]) /
|
||||||
|
total_requests * 100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate requests per minute
|
||||||
|
if self._last_request_time:
|
||||||
|
time_diff = time.time() - self._last_request_time
|
||||||
|
if time_diff > 0:
|
||||||
|
self._performance_metrics["requests_per_minute"] = 60 / time_diff
|
||||||
|
|
||||||
|
async def _make_api_call(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to the selected service."""
|
||||||
try:
|
try:
|
||||||
messages = [{"role": "system", "content": self.system_prompt}] if self.system_prompt else []
|
start_time = dt_util.utcnow()
|
||||||
messages.append({"role": "user", "content": question})
|
|
||||||
completion = openai.chat.completions.create(
|
if self._is_anthropic:
|
||||||
model=self.model,
|
response = await self._make_anthropic_call(
|
||||||
messages=messages,
|
question, model, temperature, max_tokens, system_prompt
|
||||||
temperature=self.temperature,
|
)
|
||||||
max_tokens=self.max_tokens,
|
else:
|
||||||
)
|
response = await self._make_openai_call(
|
||||||
return completion.choices[0].message.content
|
question, model, temperature, max_tokens, system_prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
response_time = (dt_util.utcnow() - start_time).total_seconds()
|
||||||
|
|
||||||
|
return {
|
||||||
|
**response,
|
||||||
|
"response_time": response_time
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error in API call: %s", err)
|
_LOGGER.error("Error in API call: %s", err)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def async_ask_question(self, question: str) -> None:
|
async def _make_anthropic_call(
|
||||||
"""Add question to queue."""
|
self,
|
||||||
await self._question_queue.put(question)
|
question: str,
|
||||||
_LOGGER.debug(f"Question added to queue: {question}")
|
model: Optional[str],
|
||||||
|
temperature: Optional[float],
|
||||||
|
max_tokens: Optional[int],
|
||||||
|
system_prompt: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to Anthropic."""
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
completion = await self.client.messages.create(
|
||||||
|
model=model or self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature if temperature is not None else self.temperature,
|
||||||
|
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": completion.content[0].text,
|
||||||
|
"model": completion.model,
|
||||||
|
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _make_openai_call(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
model: Optional[str],
|
||||||
|
temperature: Optional[float],
|
||||||
|
max_tokens: Optional[int],
|
||||||
|
system_prompt: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make API call to OpenAI."""
|
||||||
|
messages = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
completion = await self.client.chat.completions.create(
|
||||||
|
model=model or self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature if temperature is not None else self.temperature,
|
||||||
|
max_tokens=max_tokens if max_tokens is not None else self.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": completion.choices[0].message.content,
|
||||||
|
"model": completion.model,
|
||||||
|
"tokens": completion.usage.total_tokens if hasattr(completion, 'usage') else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_ask_question(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
priority: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Add question to queue with priority support."""
|
||||||
|
if not self._is_ready and self._error_count >= self._MAX_ERRORS:
|
||||||
|
_LOGGER.warning("Coordinator is not ready due to previous errors")
|
||||||
|
return
|
||||||
|
|
||||||
|
question_data = {
|
||||||
|
"question": question,
|
||||||
|
"params": {
|
||||||
|
"system_prompt": system_prompt,
|
||||||
|
"model": model,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Priority: 0 for high priority, 1 for normal
|
||||||
|
priority_level = 0 if priority else 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._question_queue.put((priority_level, question_data))
|
||||||
|
await self.async_refresh()
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
_LOGGER.error("Question queue is full. Try again later.")
|
||||||
|
raise RuntimeError("Queue is full")
|
||||||
|
|
||||||
|
async def async_shutdown(self) -> None:
|
||||||
|
"""Shutdown the coordinator."""
|
||||||
|
try:
|
||||||
|
while not self._question_queue.empty():
|
||||||
|
try:
|
||||||
|
self._question_queue.get_nowait()
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
if hasattr(self.client, 'close'):
|
||||||
|
await self.client.close()
|
||||||
|
|
||||||
|
self._is_ready = False
|
||||||
|
self._endpoint_status = "disconnected"
|
||||||
|
|
||||||
|
# Final metrics update
|
||||||
|
self._update_final_metrics()
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error during shutdown: %s", err)
|
||||||
|
|
||||||
|
def _update_final_metrics(self) -> None:
|
||||||
|
"""Update final metrics before shutdown."""
|
||||||
|
if self._request_count > 0:
|
||||||
|
self._performance_metrics["final_success_rate"] = (
|
||||||
|
(self._request_count - self._performance_metrics["total_errors"]) /
|
||||||
|
self._request_count * 100
|
||||||
|
)
|
||||||
|
self._performance_metrics["total_requests"] = self._request_count
|
||||||
|
self._performance_metrics["total_tokens"] = self._tokens_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def performance_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Return current performance metrics."""
|
||||||
|
return self._performance_metrics
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_size(self) -> int:
|
||||||
|
"""Return current queue size."""
|
||||||
|
return self._question_queue.qsize()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_queue_full(self) -> bool:
|
||||||
|
"""Return whether queue is full."""
|
||||||
|
return self._question_queue.full()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
"""Return if coordinator is ready."""
|
||||||
|
return self._is_ready and self._error_count < self._MAX_ERRORS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_processing(self) -> bool:
|
||||||
|
"""Return if coordinator is processing."""
|
||||||
|
return self._is_processing
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_rate_limited(self) -> bool:
|
||||||
|
"""Return if coordinator is rate limited."""
|
||||||
|
return self._is_rate_limited
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_maintenance(self) -> bool:
|
||||||
|
"""Return if API is in maintenance."""
|
||||||
|
return self._is_maintenance
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_count(self) -> int:
|
||||||
|
"""Return current error count."""
|
||||||
|
return self._error_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_count(self) -> int:
|
||||||
|
"""Return total request count."""
|
||||||
|
return self._request_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tokens_used(self) -> int:
|
||||||
|
"""Return total tokens used."""
|
||||||
|
return self._tokens_used
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_version(self) -> str:
|
||||||
|
"""Return API version."""
|
||||||
|
return self._api_version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoint_status(self) -> str:
|
||||||
|
"""Return endpoint status."""
|
||||||
|
return self._endpoint_status
|
||||||
|
|
||||||
|
@property
|
||||||
|
def responses(self) -> Dict[str, Any]:
|
||||||
|
"""Return all responses."""
|
||||||
|
return self._responses
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_response(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the last response."""
|
||||||
|
if not self._responses:
|
||||||
|
return None
|
||||||
|
return next(iter(self._responses.values()))
|
||||||
|
|
||||||
|
def reset_error_count(self) -> None:
|
||||||
|
"""Reset error counter."""
|
||||||
|
self._error_count = 0
|
||||||
|
self._is_rate_limited = False
|
||||||
|
self._is_maintenance = False
|
||||||
|
if not self._is_ready:
|
||||||
|
self._is_ready = True
|
||||||
|
self._endpoint_status = "connected"
|
||||||
|
|
||||||
|
async def clear_queue(self) -> None:
|
||||||
|
"""Clear the question queue."""
|
||||||
|
try:
|
||||||
|
while not self._question_queue.empty():
|
||||||
|
try:
|
||||||
|
self._question_queue.get_nowait()
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error clearing queue: %s", err)
|
||||||
|
|
||||||
|
async def clear_history(self) -> None:
|
||||||
|
"""Clear response history."""
|
||||||
|
self._responses.clear()
|
||||||
await self.async_refresh()
|
await self.async_refresh()
|
||||||
|
|
||||||
def clear_history(self) -> None:
|
def get_response(self, question: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Clear the stored question and response history."""
|
"""Get specific response by question."""
|
||||||
self._responses.clear()
|
return self._responses.get(question)
|
||||||
_LOGGER.info("History cleared.")
|
|
||||||
|
|
||||||
def get_history(self, limit: int = 10) -> Dict[str, Any]:
|
def get_recent_responses(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
"""Get the history of questions and responses."""
|
"""Get most recent responses."""
|
||||||
return {"history": list(self._responses.values())[-limit:]}
|
return list(sorted(
|
||||||
|
self._responses.values(),
|
||||||
|
key=lambda x: x["timestamp"],
|
||||||
|
reverse=True
|
||||||
|
))[:limit]
|
||||||
|
|
||||||
def set_system_prompt(self, prompt: str) -> None:
|
async def retry_failed_requests(self) -> None:
|
||||||
"""Set a system prompt that will be used for all future questions."""
|
"""Retry failed requests."""
|
||||||
self.system_prompt = prompt
|
failed_requests = [
|
||||||
_LOGGER.info(f"System prompt set: {prompt}")
|
(q, r) for q, r in self._responses.items()
|
||||||
|
if r.get("error") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
for question, response in failed_requests:
|
||||||
|
await self.async_ask_question(
|
||||||
|
question,
|
||||||
|
system_prompt=response.get("system_prompt"),
|
||||||
|
model=response.get("model"),
|
||||||
|
temperature=response.get("temperature"),
|
||||||
|
max_tokens=response.get("max_tokens"),
|
||||||
|
priority=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_system_prompt(self, new_prompt: str) -> None:
|
||||||
|
"""Update system prompt."""
|
||||||
|
self.system_prompt = new_prompt
|
||||||
|
_LOGGER.info("System prompt updated")
|
||||||
|
|
||||||
|
async def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""Perform health check."""
|
||||||
|
health_status = {
|
||||||
|
"is_ready": self.is_ready,
|
||||||
|
"is_processing": self.is_processing,
|
||||||
|
"is_rate_limited": self.is_rate_limited,
|
||||||
|
"is_maintenance": self.is_maintenance,
|
||||||
|
"error_count": self.error_count,
|
||||||
|
"endpoint_status": self.endpoint_status,
|
||||||
|
"queue_size": self.queue_size,
|
||||||
|
"request_count": self.request_count,
|
||||||
|
"tokens_used": self.tokens_used,
|
||||||
|
"performance_metrics": self.performance_metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
return health_status
|
||||||
|
|
||||||
|
async def _handle_timeout_error(self) -> None:
|
||||||
|
"""Handle timeout errors."""
|
||||||
|
self._error_count += 1
|
||||||
|
self._endpoint_status = "timeout"
|
||||||
|
self._performance_metrics["total_errors"] += 1
|
||||||
|
|
||||||
|
if not self._question_queue.empty():
|
||||||
|
await self.clear_queue()
|
||||||
|
|
||||||
|
# Fire timeout event
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_timeout_error", {
|
||||||
|
"error_count": self._error_count,
|
||||||
|
"endpoint_status": self._endpoint_status
|
||||||
|
})
|
||||||
|
|
||||||
|
def export_metrics(self) -> Dict[str, Any]:
|
||||||
|
"""Export all metrics and statistics."""
|
||||||
|
return {
|
||||||
|
"performance": self._performance_metrics,
|
||||||
|
"requests": {
|
||||||
|
"total": self._request_count,
|
||||||
|
"successful": self._request_count - self._performance_metrics["total_errors"],
|
||||||
|
"failed": self._performance_metrics["total_errors"]
|
||||||
|
},
|
||||||
|
"tokens": {
|
||||||
|
"total_used": self._tokens_used,
|
||||||
|
"average_per_request": self._tokens_used / self._request_count if self._request_count > 0 else 0
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"is_ready": self.is_ready,
|
||||||
|
"endpoint_status": self._endpoint_status,
|
||||||
|
"error_count": self._error_count
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"size": self.queue_size,
|
||||||
|
"is_full": self.is_queue_full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def validate_model(self, model: str) -> bool:
|
||||||
|
"""Validate if model is supported."""
|
||||||
|
return model in SUPPORTED_MODELS
|
||||||
|
|
||||||
|
async def estimate_tokens(self, text: str) -> int:
|
||||||
|
"""Estimate token count for text."""
|
||||||
|
# Простая оценка: примерно 4 символа на токен
|
||||||
|
return len(text) // 4
|
||||||
|
|
||||||
|
def get_rate_limit_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get rate limit information."""
|
||||||
|
return {
|
||||||
|
"is_rate_limited": self._is_rate_limited,
|
||||||
|
"retry_after": RETRY_DELAY * (2 ** (self._error_count - 1)) if self._is_rate_limited else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def optimize_queue(self) -> None:
|
||||||
|
"""Optimize queue by removing duplicate requests."""
|
||||||
|
if self._question_queue.empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
seen_questions = set()
|
||||||
|
optimized_queue = asyncio.PriorityQueue(maxsize=MAX_QUEUE_SIZE)
|
||||||
|
|
||||||
|
while not self._question_queue.empty():
|
||||||
|
try:
|
||||||
|
priority, question_data = self._question_queue.get_nowait()
|
||||||
|
question = question_data["question"]
|
||||||
|
|
||||||
|
if question not in seen_questions:
|
||||||
|
seen_questions.add(question)
|
||||||
|
await optimized_queue.put((priority, question_data))
|
||||||
|
|
||||||
|
self._question_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._question_queue = optimized_queue
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
{
|
{
|
||||||
"domain": "ha-text-ai",
|
"domain": "ha_text_ai",
|
||||||
"name": "HA text AI",
|
"name": "HA Text AI",
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://github.com/smkrv/ha-text-ai",
|
|
||||||
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
|
||||||
"requirements": ["openai>=1.0.0"],
|
|
||||||
"ssdp": [],
|
|
||||||
"zeroconf": [],
|
|
||||||
"homekit": {},
|
|
||||||
"dependencies": [],
|
|
||||||
"codeowners": ["@smkrv"],
|
"codeowners": ["@smkrv"],
|
||||||
"version": "1.0.0",
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling"
|
"dependencies": [],
|
||||||
|
"documentation": "https://github.com/smkrv/ha-text-ai",
|
||||||
|
"homepage": "https://github.com/smkrv/ha-text-ai",
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
||||||
|
"requirements": [
|
||||||
|
"openai>=1.12.0",
|
||||||
|
"anthropic>=0.8.0",
|
||||||
|
"aiohttp>=3.8.0",
|
||||||
|
"async-timeout>=4.0.0"
|
||||||
|
],
|
||||||
|
"version": "2.0.0",
|
||||||
|
"homeassistant": "2024.1.0",
|
||||||
|
"quality_scale": "silver",
|
||||||
|
"integration_type": "service",
|
||||||
|
"translations": [
|
||||||
|
"en"
|
||||||
|
],
|
||||||
|
"zeroconf": [],
|
||||||
|
"ssdp": [],
|
||||||
|
"usb": [],
|
||||||
|
"bluetooth": [],
|
||||||
|
"mqtt": [],
|
||||||
|
"loggers": ["custom_components.ha_text_ai"],
|
||||||
|
"after_dependencies": ["http"],
|
||||||
|
"title": "Text AI Assistant",
|
||||||
|
"description": "AI-powered text assistant supporting multiple models including GPT and Claude",
|
||||||
|
"render_readme": true,
|
||||||
|
"tags": [
|
||||||
|
"ai",
|
||||||
|
"automation",
|
||||||
|
"chat",
|
||||||
|
"assistant",
|
||||||
|
"openai",
|
||||||
|
"claude"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,54 @@
|
|||||||
"""Sensor platform for HA text AI."""
|
"""Sensor platform for HA text AI."""
|
||||||
from typing import Any, Callable, Dict, Optional
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
from homeassistant.components.sensor import (
|
||||||
|
SensorEntity,
|
||||||
|
SensorStateClass,
|
||||||
|
SensorDeviceClass,
|
||||||
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DOMAIN, ATTR_QUESTION, ATTR_RESPONSE, ATTR_LAST_UPDATED
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
ATTR_QUESTION,
|
||||||
|
ATTR_RESPONSE,
|
||||||
|
ATTR_LAST_UPDATED,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_TEMPERATURE,
|
||||||
|
ATTR_MAX_TOKENS,
|
||||||
|
ATTR_TOTAL_RESPONSES,
|
||||||
|
ATTR_SYSTEM_PROMPT,
|
||||||
|
ATTR_QUEUE_SIZE,
|
||||||
|
ATTR_API_STATUS,
|
||||||
|
ATTR_ERROR_COUNT,
|
||||||
|
ATTR_LAST_ERROR,
|
||||||
|
ATTR_RESPONSE_TIME,
|
||||||
|
ATTR_API_VERSION,
|
||||||
|
ATTR_ENDPOINT_STATUS,
|
||||||
|
ATTR_REQUEST_COUNT,
|
||||||
|
ATTR_TOKENS_USED,
|
||||||
|
ENTITY_ICON,
|
||||||
|
ENTITY_ICON_ERROR,
|
||||||
|
ENTITY_ICON_PROCESSING,
|
||||||
|
STATE_READY,
|
||||||
|
STATE_PROCESSING,
|
||||||
|
STATE_ERROR,
|
||||||
|
STATE_DISCONNECTED,
|
||||||
|
STATE_RATE_LIMITED,
|
||||||
|
STATE_INITIALIZING,
|
||||||
|
STATE_MAINTENANCE,
|
||||||
|
)
|
||||||
from .coordinator import HATextAICoordinator
|
from .coordinator import HATextAICoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
@@ -20,9 +58,14 @@ async def async_setup_entry(
|
|||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities([HATextAISensor(coordinator, entry)], True)
|
async_add_entities([HATextAISensor(coordinator, entry)], True)
|
||||||
|
|
||||||
|
|
||||||
class HATextAISensor(CoordinatorEntity, SensorEntity):
|
class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||||
"""HA text AI Sensor."""
|
"""HA text AI Sensor."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: HATextAICoordinator,
|
coordinator: HATextAICoordinator,
|
||||||
@@ -32,27 +75,161 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._config_entry = config_entry
|
self._config_entry = config_entry
|
||||||
self._attr_unique_id = f"{config_entry.entry_id}"
|
self._attr_unique_id = f"{config_entry.entry_id}"
|
||||||
self._attr_name = "HA text AI"
|
self._attr_name = "Last Response"
|
||||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
self._attr_suggested_display_precision = 0
|
||||||
|
self._error_count = 0
|
||||||
|
self._last_error = None
|
||||||
|
self._state = STATE_INITIALIZING
|
||||||
|
self._attr_device_info = {
|
||||||
|
"identifiers": {(DOMAIN, self._attr_unique_id)},
|
||||||
|
"name": "HA Text AI",
|
||||||
|
"manufacturer": "Community",
|
||||||
|
"model": coordinator.model,
|
||||||
|
"sw_version": coordinator.api_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str:
|
||||||
|
"""Return the icon based on the current state."""
|
||||||
|
if self._state == STATE_PROCESSING:
|
||||||
|
return ENTITY_ICON_PROCESSING
|
||||||
|
elif self._state in [STATE_ERROR, STATE_DISCONNECTED, STATE_RATE_LIMITED]:
|
||||||
|
return ENTITY_ICON_ERROR
|
||||||
|
return ENTITY_ICON
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> StateType:
|
def state(self) -> StateType:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
if self.coordinator.data:
|
if not self.coordinator.data or not self.coordinator.last_update_success:
|
||||||
return "Ready" # Assuming "Ready" is a valid state, you might want to return something meaningful, like the last response time.
|
return None
|
||||||
return "Not Ready"
|
|
||||||
|
try:
|
||||||
|
if self.coordinator.data and isinstance(self.coordinator.data, dict):
|
||||||
|
last_update = self.coordinator.data.get("last_update")
|
||||||
|
if isinstance(last_update, datetime):
|
||||||
|
return dt_util.as_local(last_update)
|
||||||
|
return last_update
|
||||||
|
return None
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error getting state: %s", err, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> Optional[Dict[str, Any]]:
|
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||||
"""Return entity specific state attributes."""
|
"""Return entity specific state attributes."""
|
||||||
if not self.coordinator.data:
|
attributes = {
|
||||||
return None
|
ATTR_TOTAL_RESPONSES: 0,
|
||||||
keys = list(self.coordinator.data.keys())
|
ATTR_MODEL: self.coordinator.model,
|
||||||
values = list(self.coordinator.data.values())
|
ATTR_TEMPERATURE: self.coordinator.temperature,
|
||||||
last_question = keys[-1]
|
ATTR_MAX_TOKENS: self.coordinator.max_tokens,
|
||||||
last_response = values[-1]
|
ATTR_SYSTEM_PROMPT: self.coordinator.system_prompt,
|
||||||
return {
|
ATTR_QUEUE_SIZE: self.coordinator._question_queue.qsize(),
|
||||||
ATTR_QUESTION: last_question,
|
ATTR_API_STATUS: self._state,
|
||||||
ATTR_RESPONSE: last_response,
|
ATTR_ERROR_COUNT: self._error_count,
|
||||||
ATTR_LAST_UPDATED: self.coordinator.last_update_success_time,
|
ATTR_LAST_ERROR: self._last_error,
|
||||||
|
ATTR_API_VERSION: self.coordinator.api_version,
|
||||||
|
ATTR_ENDPOINT_STATUS: self.coordinator.endpoint_status,
|
||||||
|
ATTR_REQUEST_COUNT: self.coordinator.request_count,
|
||||||
|
ATTR_TOKENS_USED: self.coordinator.tokens_used,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not self.coordinator.data:
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
try:
|
||||||
|
history = list(self.coordinator._responses.items())
|
||||||
|
if history:
|
||||||
|
last_question, last_data = history[-1]
|
||||||
|
|
||||||
|
if isinstance(last_data, dict):
|
||||||
|
last_response = last_data.get("response", "")
|
||||||
|
last_updated = last_data.get("timestamp") or self.coordinator.data.get("last_update")
|
||||||
|
response_time = last_data.get("response_time")
|
||||||
|
|
||||||
|
model = last_data.get("model", self.coordinator.model)
|
||||||
|
temperature = last_data.get("temperature", self.coordinator.temperature)
|
||||||
|
max_tokens = last_data.get("max_tokens", self.coordinator.max_tokens)
|
||||||
|
error = last_data.get("error")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
self._last_error = error
|
||||||
|
self._state = STATE_ERROR
|
||||||
|
else:
|
||||||
|
last_response = str(last_data)
|
||||||
|
last_updated = self.coordinator.data.get("last_update")
|
||||||
|
response_time = None
|
||||||
|
model = self.coordinator.model
|
||||||
|
temperature = self.coordinator.temperature
|
||||||
|
max_tokens = self.coordinator.max_tokens
|
||||||
|
|
||||||
|
if isinstance(last_updated, datetime):
|
||||||
|
last_updated = dt_util.as_local(last_updated)
|
||||||
|
|
||||||
|
attributes.update({
|
||||||
|
ATTR_QUESTION: last_question,
|
||||||
|
ATTR_RESPONSE: last_response,
|
||||||
|
ATTR_LAST_UPDATED: last_updated,
|
||||||
|
ATTR_TOTAL_RESPONSES: len(history),
|
||||||
|
ATTR_MODEL: model,
|
||||||
|
ATTR_TEMPERATURE: temperature,
|
||||||
|
ATTR_MAX_TOKENS: max_tokens,
|
||||||
|
})
|
||||||
|
|
||||||
|
if response_time is not None:
|
||||||
|
attributes[ATTR_RESPONSE_TIME] = response_time
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error getting attributes: %s", err, exc_info=True)
|
||||||
|
self._error_count += 1
|
||||||
|
self._last_error = str(err)
|
||||||
|
self._state = STATE_ERROR
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return if entity is available."""
|
||||||
|
return self.coordinator.last_update_success
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""When entity is added to hass."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
self._handle_coordinator_update()
|
||||||
|
self._state = STATE_READY
|
||||||
|
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
"""Handle updated data from the coordinator."""
|
||||||
|
try:
|
||||||
|
if self.coordinator.data:
|
||||||
|
if self.coordinator._is_ready:
|
||||||
|
if self.coordinator._is_processing:
|
||||||
|
self._state = STATE_PROCESSING
|
||||||
|
elif self.coordinator._is_rate_limited:
|
||||||
|
self._state = STATE_RATE_LIMITED
|
||||||
|
elif self.coordinator._is_maintenance:
|
||||||
|
self._state = STATE_MAINTENANCE
|
||||||
|
else:
|
||||||
|
self._state = STATE_READY
|
||||||
|
else:
|
||||||
|
self._state = STATE_DISCONNECTED
|
||||||
|
else:
|
||||||
|
self._state = STATE_DISCONNECTED
|
||||||
|
|
||||||
|
# Обновляем счетчик ошибок только если статус изменился на ошибку
|
||||||
|
if self._state == STATE_ERROR:
|
||||||
|
self._error_count += 1
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.error("Error handling update: %s", err, exc_info=True)
|
||||||
|
self._error_count += 1
|
||||||
|
self._last_error = str(err)
|
||||||
|
self._state = STATE_ERROR
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_reset_error_count(self) -> None:
|
||||||
|
"""Reset the error counter."""
|
||||||
|
self._error_count = 0
|
||||||
|
self._last_error = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -1,66 +1,135 @@
|
|||||||
# Service to ask a question
|
|
||||||
ask_question:
|
ask_question:
|
||||||
name: Ask Question
|
name: Ask Question
|
||||||
description: Send a question to the AI and get a response
|
description: >-
|
||||||
|
Send a question to the AI model and receive a detailed response.
|
||||||
|
The response will be stored in the conversation history and can be retrieved later.
|
||||||
|
Response time may vary based on model selection and server load.
|
||||||
|
Supports various AI models with different capabilities and pricing.
|
||||||
fields:
|
fields:
|
||||||
question:
|
question:
|
||||||
name: Question
|
name: Question
|
||||||
description: The question or prompt to send to the AI
|
description: >-
|
||||||
|
Your question or prompt for the AI assistant. Be specific and clear for better results.
|
||||||
|
You can ask about home automation, technical advice, or general questions.
|
||||||
|
For complex queries, consider breaking them into smaller parts.
|
||||||
|
The system will maintain conversation context for follow-up questions.
|
||||||
required: true
|
required: true
|
||||||
example: "What is the weather like today?"
|
example: |
|
||||||
|
What automations would you recommend for a smart kitchen?
|
||||||
|
Consider energy efficiency, convenience, and integration with:
|
||||||
|
- Smart lighting
|
||||||
|
- Appliance control
|
||||||
|
- Temperature monitoring
|
||||||
|
- Voice commands
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
multiline: true
|
multiline: true
|
||||||
|
type: text
|
||||||
|
|
||||||
model:
|
model:
|
||||||
name: Model
|
name: Model
|
||||||
description: Override the default model for this question (optional)
|
description: >-
|
||||||
|
Select an AI model to use (optional, overrides default setting).
|
||||||
|
Different models have different capabilities, context limits, and response characteristics:
|
||||||
|
- GPT-3.5 Turbo: Fast, efficient, good for most tasks
|
||||||
|
- GPT-4: More capable, better reasoning, slower
|
||||||
|
- Claude-3: Advanced capabilities, longer context
|
||||||
|
Note: More capable models may have longer response times and higher API costs.
|
||||||
required: false
|
required: false
|
||||||
example: "gpt-4"
|
example: "gpt-3.5-turbo"
|
||||||
default: "gpt-3.5-turbo"
|
default: "gpt-3.5-turbo"
|
||||||
selector:
|
selector:
|
||||||
select:
|
select:
|
||||||
|
custom_value: true
|
||||||
options:
|
options:
|
||||||
- "gpt-3.5-turbo"
|
- label: "GPT-3.5 Turbo (Fast & Efficient)"
|
||||||
- "gpt-4"
|
value: "gpt-3.5-turbo"
|
||||||
- "gpt-4-32k"
|
- label: "GPT-3.5 Turbo 16K (Extended)"
|
||||||
|
value: "gpt-3.5-turbo-16k"
|
||||||
|
- label: "GPT-4 (Most Capable)"
|
||||||
|
value: "gpt-4"
|
||||||
|
- label: "GPT-4 32K (Extended Context)"
|
||||||
|
value: "gpt-4-32k"
|
||||||
|
- label: "GPT-4 Turbo (Latest)"
|
||||||
|
value: "gpt-4-1106-preview"
|
||||||
|
- label: "Claude-3 Sonnet (Balanced)"
|
||||||
|
value: "claude-3-sonnet"
|
||||||
|
- label: "Claude-3 Opus (Most Advanced)"
|
||||||
|
value: "claude-3-opus"
|
||||||
|
mode: dropdown
|
||||||
|
|
||||||
temperature:
|
temperature:
|
||||||
name: Temperature
|
name: Temperature
|
||||||
description: Control randomness in the response (0.0-1.0, lower is more focused)
|
description: >-
|
||||||
|
Controls response creativity (0.0-2.0):
|
||||||
|
0.0-0.3: Focused, consistent responses (best for technical/factual queries)
|
||||||
|
0.4-0.7: Balanced responses (recommended for most uses)
|
||||||
|
0.8-2.0: More creative, varied responses (best for brainstorming)
|
||||||
|
Note: Higher values may produce less predictable results.
|
||||||
required: false
|
required: false
|
||||||
default: 0.7
|
default: 0.7
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 0.0
|
min: 0.0
|
||||||
max: 1.0
|
max: 2.0
|
||||||
step: 0.1
|
step: 0.1
|
||||||
|
mode: slider
|
||||||
|
unit_of_measurement: ""
|
||||||
|
|
||||||
max_tokens:
|
max_tokens:
|
||||||
name: Max Tokens
|
name: Max Tokens
|
||||||
description: Maximum length of the response
|
description: >-
|
||||||
|
Maximum length of the response (1-4096 tokens).
|
||||||
|
Recommended ranges:
|
||||||
|
- Short (256-512): Quick answers, status updates
|
||||||
|
- Medium (512-1024): Detailed explanations
|
||||||
|
- Long (1024-4096): Complex analysis
|
||||||
|
Note: Token limits vary by model. Actual length may be shorter.
|
||||||
required: false
|
required: false
|
||||||
default: 1000
|
default: 1000
|
||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 1
|
min: 1
|
||||||
max: 4000
|
max: 4096
|
||||||
step: 1
|
step: 1
|
||||||
|
mode: box
|
||||||
|
|
||||||
|
system_prompt:
|
||||||
|
name: System Prompt
|
||||||
|
description: >-
|
||||||
|
Optional system prompt to set context for this specific question.
|
||||||
|
This will temporarily override the default system prompt.
|
||||||
|
Use this to specify expertise areas, response format, or special instructions.
|
||||||
|
required: false
|
||||||
|
example: "You are a home automation expert focused on energy efficiency and security"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
multiline: true
|
||||||
|
|
||||||
# Service to clear response history
|
|
||||||
clear_history:
|
clear_history:
|
||||||
name: Clear History
|
name: Clear History
|
||||||
description: Clear the stored question and response history
|
description: >-
|
||||||
|
Delete all stored questions and responses from the conversation history.
|
||||||
|
This action cannot be undone. Consider using 'get_history' first if you need to backup the data.
|
||||||
|
System prompt settings and configuration will be preserved.
|
||||||
fields: {}
|
fields: {}
|
||||||
|
|
||||||
# Service to get response history
|
|
||||||
get_history:
|
get_history:
|
||||||
name: Get History
|
name: Get History
|
||||||
description: Get the history of questions and responses
|
description: >-
|
||||||
|
Retrieve recent conversation history, including:
|
||||||
|
- Questions and responses
|
||||||
|
- Timestamps and response times
|
||||||
|
- Models used and token counts
|
||||||
|
- Temperature and other settings
|
||||||
|
Results are ordered from newest to oldest.
|
||||||
fields:
|
fields:
|
||||||
limit:
|
limit:
|
||||||
name: Limit
|
name: Limit
|
||||||
description: Maximum number of history items to return
|
description: >-
|
||||||
|
Number of most recent conversations to return (1-100).
|
||||||
|
Higher values return more history but may take longer to process.
|
||||||
|
Default: 10 conversations
|
||||||
required: false
|
required: false
|
||||||
default: 10
|
default: 10
|
||||||
selector:
|
selector:
|
||||||
@@ -68,17 +137,69 @@ get_history:
|
|||||||
min: 1
|
min: 1
|
||||||
max: 100
|
max: 100
|
||||||
step: 1
|
step: 1
|
||||||
|
mode: box
|
||||||
|
|
||||||
|
filter_model:
|
||||||
|
name: Filter by Model
|
||||||
|
description: >-
|
||||||
|
Only return conversations using a specific AI model.
|
||||||
|
Leave empty to show all models.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
custom_value: true
|
||||||
|
options:
|
||||||
|
- label: "All Models"
|
||||||
|
value: ""
|
||||||
|
- label: "GPT-3.5 Turbo"
|
||||||
|
value: "gpt-3.5-turbo"
|
||||||
|
- label: "GPT-4"
|
||||||
|
value: "gpt-4"
|
||||||
|
- label: "GPT-4 Turbo"
|
||||||
|
value: "gpt-4-1106-preview"
|
||||||
|
- label: "Claude-3 Sonnet"
|
||||||
|
value: "claude-3-sonnet"
|
||||||
|
- label: "Claude-3 Opus"
|
||||||
|
value: "claude-3-opus"
|
||||||
|
mode: dropdown
|
||||||
|
|
||||||
|
start_date:
|
||||||
|
name: Start Date
|
||||||
|
description: >-
|
||||||
|
Optional start date for filtering history.
|
||||||
|
Format: YYYY-MM-DD
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
datetime:
|
||||||
|
type: date
|
||||||
|
|
||||||
# Service to set system prompt
|
|
||||||
set_system_prompt:
|
set_system_prompt:
|
||||||
name: Set System Prompt
|
name: Set System Prompt
|
||||||
description: Set a system prompt that will be used for all future questions
|
description: >-
|
||||||
|
Set default system behavior instructions for all future conversations.
|
||||||
|
This defines how the AI should behave and respond to questions.
|
||||||
|
The prompt will be used for all models unless overridden per question.
|
||||||
fields:
|
fields:
|
||||||
prompt:
|
prompt:
|
||||||
name: System Prompt
|
name: System Prompt
|
||||||
description: The system prompt to set
|
description: >-
|
||||||
|
Instructions that define how the AI should behave and respond.
|
||||||
|
Be specific about:
|
||||||
|
- Desired expertise and knowledge areas
|
||||||
|
- Response tone and style
|
||||||
|
- Output format preferences
|
||||||
|
- Special handling instructions
|
||||||
|
Maximum length: 1000 characters
|
||||||
required: true
|
required: true
|
||||||
example: "You are a helpful assistant specializing in home automation"
|
example: |
|
||||||
|
You are a home automation expert assistant. Focus on:
|
||||||
|
1. Practical and efficient solutions
|
||||||
|
2. Energy-saving recommendations
|
||||||
|
3. Integration with popular smart home platforms
|
||||||
|
4. Security and privacy considerations
|
||||||
|
Provide detailed but concise responses with clear steps when applicable.
|
||||||
|
Format complex responses with bullet points or numbered lists.
|
||||||
|
Include warnings about potential risks or limitations.
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
multiline: true
|
multiline: true
|
||||||
|
|||||||
@@ -1,38 +1,147 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Set up HA text AI",
|
"title": "Set up HA Text AI",
|
||||||
"description": "Set up your OpenAI integration",
|
"description": "Configure your AI integration for various providers (OpenAI, Anthropic, etc.). Different models have different capabilities and pricing.",
|
||||||
"data": {
|
"data": {
|
||||||
"api_key": "API Key",
|
"api_key": "API key for authentication (required)",
|
||||||
"model": "Model",
|
"model": "AI model to use (e.g., gpt-3.5-turbo, gpt-4, claude-3-sonnet, claude-3-opus)",
|
||||||
"temperature": "Temperature",
|
"temperature": "Response creativity (0-2, lower = more focused and consistent)",
|
||||||
"max_tokens": "Max Tokens",
|
"max_tokens": "Maximum response length (1-4096 tokens)",
|
||||||
"api_endpoint": "API Endpoint",
|
"api_endpoint": "Custom API endpoint URL (default varies by provider)",
|
||||||
"request_interval": "Request Interval (seconds)"
|
"request_interval": "Minimum time between requests in seconds (min: 0.1)",
|
||||||
|
"system_prompt": "Default instructions for AI behavior and expertise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_auth": "Authentication failed - check your API key",
|
||||||
|
"invalid_api_key": "Invalid API key - please verify your credentials",
|
||||||
|
"cannot_connect": "Connection failed - check endpoint and network status",
|
||||||
|
"invalid_model": "Model unavailable or not supported by the API",
|
||||||
|
"rate_limit": "Rate limit exceeded - please reduce request frequency",
|
||||||
|
"context_length": "Input exceeds maximum context length for model",
|
||||||
|
"api_error": "API service error - check provider status",
|
||||||
|
"timeout": "Request timeout - server not responding",
|
||||||
|
"queue_full": "Request queue full - try again later",
|
||||||
|
"invalid_prompt": "Invalid system prompt format or length",
|
||||||
|
"invalid_url_format": "Invalid API endpoint URL format",
|
||||||
|
"invalid_input": "Invalid configuration parameters",
|
||||||
|
"unknown": "Unexpected error - check logs for details"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"error": {
|
"options": {
|
||||||
"auth": "API key is invalid.",
|
"step": {
|
||||||
"cannot_connect": "Failed to connect to API.",
|
"init": {
|
||||||
"unknown": "Unexpected error occurred."
|
"title": "HA Text AI Settings",
|
||||||
|
"description": "Adjust your AI integration parameters and behavior",
|
||||||
|
"data": {
|
||||||
|
"model": "Select AI model for responses (capabilities vary)",
|
||||||
|
"temperature": "Response creativity (0-2, affects variation)",
|
||||||
|
"max_tokens": "Maximum response length in tokens",
|
||||||
|
"request_interval": "Minimum seconds between requests (rate limiting)",
|
||||||
|
"system_prompt": "Default AI behavior and expertise instructions",
|
||||||
|
"timeout": "Request timeout in seconds (default: 30)",
|
||||||
|
"retry_count": "Number of retry attempts for failed requests",
|
||||||
|
"queue_size": "Maximum pending requests in queue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"abort": {
|
"services": {
|
||||||
"already_configured": "Device is already configured"
|
"ask_question": {
|
||||||
}
|
"name": "Ask Question",
|
||||||
},
|
"description": "Send a question to the AI model and get a detailed response. Supports context awareness for follow-up questions.",
|
||||||
"options": {
|
"fields": {
|
||||||
"step": {
|
"question": {
|
||||||
"init": {
|
"name": "Question",
|
||||||
"title": "HA text AI Options",
|
"description": "Your question or prompt for the AI model. Be specific for better results."
|
||||||
"data": {
|
},
|
||||||
"temperature": "Temperature",
|
"system_prompt": {
|
||||||
"max_tokens": "Max Tokens",
|
"name": "System Prompt",
|
||||||
"request_interval": "Request Interval (seconds)"
|
"description": "Optional behavior instructions for this specific question"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "Model",
|
||||||
|
"description": "Optional specific AI model for this request (overrides default)"
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"name": "Temperature",
|
||||||
|
"description": "Optional creativity setting (0-2, affects response variation)"
|
||||||
|
},
|
||||||
|
"max_tokens": {
|
||||||
|
"name": "Max Tokens",
|
||||||
|
"description": "Optional maximum response length (1-4096 tokens)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clear_history": {
|
||||||
|
"name": "Clear History",
|
||||||
|
"description": "Delete all stored conversation history, responses, and metadata"
|
||||||
|
},
|
||||||
|
"get_history": {
|
||||||
|
"name": "Get History",
|
||||||
|
"description": "Retrieve conversation history with detailed metadata including timestamps",
|
||||||
|
"fields": {
|
||||||
|
"limit": {
|
||||||
|
"name": "Limit",
|
||||||
|
"description": "Maximum number of conversations to return (1-100)"
|
||||||
|
},
|
||||||
|
"filter_model": {
|
||||||
|
"name": "Filter Model",
|
||||||
|
"description": "Show only responses from a specific model"
|
||||||
|
},
|
||||||
|
"start_date": {
|
||||||
|
"name": "Start Date",
|
||||||
|
"description": "Filter conversations from this date (YYYY-MM-DD)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"set_system_prompt": {
|
||||||
|
"name": "Set System Prompt",
|
||||||
|
"description": "Update default AI behavior and expertise instructions",
|
||||||
|
"fields": {
|
||||||
|
"prompt": {
|
||||||
|
"name": "System Prompt",
|
||||||
|
"description": "Instructions for AI behavior, expertise, and response style"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready for requests",
|
||||||
|
"processing": "Processing request",
|
||||||
|
"error": "Error occurred",
|
||||||
|
"disconnected": "API disconnected",
|
||||||
|
"rate_limited": "Rate limit reached",
|
||||||
|
"initializing": "Starting up",
|
||||||
|
"retrying": "Retrying request",
|
||||||
|
"queued": "Request queued"
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"status": {
|
||||||
|
"name": "AI Status",
|
||||||
|
"state": {
|
||||||
|
"ready": "Ready",
|
||||||
|
"processing": "Processing",
|
||||||
|
"error": "Error",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"rate_limited": "Rate Limited",
|
||||||
|
"initializing": "Initializing",
|
||||||
|
"retrying": "Retrying",
|
||||||
|
"queued": "Queued"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_response": {
|
||||||
|
"name": "Last Response",
|
||||||
|
"state": {
|
||||||
|
"success": "Success",
|
||||||
|
"error": "Error",
|
||||||
|
"timeout": "Timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -2,6 +2,8 @@
|
|||||||
"name": "HA text AI",
|
"name": "HA text AI",
|
||||||
"render_readme": true,
|
"render_readme": true,
|
||||||
"domains": ["sensor"],
|
"domains": ["sensor"],
|
||||||
"homeassistant": "2023.8.0",
|
"homeassistant": "2024.11.0",
|
||||||
"icon": "mdi:brain"
|
"icon": "mdi:brain",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"documentation": "https://github.com/smkrv/ha-text-ai"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
```
|
||||||
|
ha-text-ai/
|
||||||
|
│
|
||||||
|
├── custom_components/
|
||||||
|
│ └── ha_text_ai/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config_flow.py
|
||||||
|
│ ├── coordinator.py
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ ├── sensor.py
|
||||||
|
│ ├── services.yaml
|
||||||
|
│ └── const.py
|
||||||
|
│
|
||||||
|
└── strings/
|
||||||
|
├── en.json
|
||||||
|
└── ru.json
|
||||||
|
```
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Common fixtures for tests."""
|
|
||||||
import pytest
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def hass() -> HomeAssistant:
|
|
||||||
"""Return a Home Assistant instance for testing."""
|
|
||||||
return HomeAssistant()
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Tests for the HA text AI integration."""
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from custom_components.ha_text_ai.const import DOMAIN
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.setup import async_setup_component
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_setup_entry() -> AsyncMock:
|
|
||||||
"""Override async_setup_entry."""
|
|
||||||
with patch(
|
|
||||||
"custom_components.ha_text_ai.async_setup_entry",
|
|
||||||
return_value=True,
|
|
||||||
) as mock_setup_entry:
|
|
||||||
yield mock_setup_entry
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_coordinator() -> AsyncMock:
|
|
||||||
"""Override coordinator."""
|
|
||||||
with patch(
|
|
||||||
"custom_components.ha_text_ai.coordinator.HATextAICoordinator",
|
|
||||||
return_value=AsyncMock(),
|
|
||||||
) as mock_coordinator:
|
|
||||||
yield mock_coordinator
|
|
||||||
|
|
||||||
async def test_async_setup(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
|
||||||
"""Test the initial setup."""
|
|
||||||
assert await async_setup_component(hass, DOMAIN, {
|
|
||||||
DOMAIN: {
|
|
||||||
"api_key": "test_key",
|
|
||||||
"model": "gpt-3.5-turbo",
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tokens": 1000,
|
|
||||||
"api_endpoint": "https://api.openai.com/v1",
|
|
||||||
"request_interval": 1.0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert DOMAIN in hass.data
|
|
||||||
|
|
||||||
async def test_async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_coordinator: AsyncMock
|
|
||||||
) -> None:
|
|
||||||
"""Test setup entry."""
|
|
||||||
entry = MockConfigEntry(
|
|
||||||
domain=DOMAIN,
|
|
||||||
data={
|
|
||||||
"api_key": "test_key",
|
|
||||||
"model": "gpt-3.5-turbo",
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tokens": 1000,
|
|
||||||
"api_endpoint": "https://api.openai.com/v1",
|
|
||||||
"request_interval": 1.0
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entry.add_to_hass(hass)
|
|
||||||
|
|
||||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert len(mock_coordinator.mock_calls) == 1
|
|
||||||
assert DOMAIN in hass.data
|
|
||||||
assert entry.entry_id in hass.data[DOMAIN]
|
|
||||||
Reference in New Issue
Block a user