mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-21 22:54:00 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03cd40de29 | ||
|
|
33d4a190b9 | ||
|
|
990134bde5 | ||
|
|
3cd8d3076a | ||
|
|
65dbb5dfc0 | ||
|
|
fc59f584a3 | ||
|
|
bcfe69b5f7 | ||
|
|
9d4d2d5cd1 | ||
|
|
6e635f7e2f | ||
|
|
e8b9b911ef | ||
|
|
5af733b1b0 |
@@ -0,0 +1,15 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -26,19 +26,15 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: ⤵️ Check out code from GitHub
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 🚀 Run hassfest validation
|
||||
- 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
|
||||
|
||||
@@ -14,9 +14,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create zip archive
|
||||
run: |
|
||||
@@ -29,7 +30,7 @@ jobs:
|
||||
-x "*.DS_Store"
|
||||
|
||||
- name: Upload release asset
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: ha_text_ai.zip
|
||||
|
||||
@@ -27,6 +27,14 @@ Thumbs.db
|
||||
.storage/
|
||||
|
||||
# Claude Code working files
|
||||
handoff.*.md
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.windsurfrules
|
||||
docs/specs/
|
||||
docs/superpowers/
|
||||
docs/plans/
|
||||
|
||||
@@ -1,131 +1,21 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
MIT License
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
Copyright (c) 2024-2026 SMKRV
|
||||
|
||||
## Acceptance
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the
|
||||
software to do everything you might do with the software
|
||||
that would otherwise infringe the licensor's copyright
|
||||
in it for any permitted purpose. However, you may
|
||||
only distribute the software according to [Distribution
|
||||
License](#distribution-license) and make changes or new works
|
||||
based on the software according to [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to distribute copies of the software. Your license
|
||||
to distribute covers distributing the software with
|
||||
changes and new works permitted by [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of
|
||||
the software from you also gets a copy of these terms or the
|
||||
URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for
|
||||
the benefit of public knowledge, personal study, private
|
||||
entertainment, hobby projects, amateur pursuits, or religious
|
||||
observance, without any anticipated commercial application,
|
||||
is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution,
|
||||
public research organization, public safety or health
|
||||
organization, environmental protection organization,
|
||||
or government institution is use for a permitted purpose
|
||||
regardless of the source of funding or obligations resulting
|
||||
from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have
|
||||
violated any of these terms, or done anything with the software
|
||||
not covered by your licenses, your licenses can nonetheless
|
||||
continue if you come into full compliance with these terms,
|
||||
and take practical steps to correct past violations, within
|
||||
32 days of receiving notice. Otherwise, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,183 +1,118 @@
|
||||
# 🤖 HA Text AI for Home Assistant
|
||||
# HA Text AI for Home Assistant
|
||||
|
||||
<div align="center">
|
||||
|
||||
  [](https://polyformproject.org/licenses/noncommercial/1.0.0) [](https://github.com/hacs/integration)
|
||||
  [](https://opensource.org/licenses/MIT) [](https://github.com/hacs/integration)
|
||||
       
|
||||
|
||||
|
||||
<img src="https://github.com/smkrv/ha-text-ai/blob/main/custom_components/ha_text_ai/icons/logo%402x.png" alt="HA Text AI" style="width: 50%; max-width: 256px; max-height: 128px; aspect-ratio: 2/1; object-fit: contain;"/>
|
||||
|
||||
### Advanced AI Integration for [Home Assistant](https://www.home-assistant.io/) with LLM multi-provider support
|
||||
### Multi-provider LLM integration for [Home Assistant](https://www.home-assistant.io/)
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
Transform your smart home experience with powerful AI assistance powered by multiple AI providers including OpenAI GPT, Anthropic Claude, DeepSeek and Google Gemini models. Get intelligent responses, automate complex scenarios, and enhance your home automation with advanced natural language processing.
|
||||
|
||||
Ask OpenAI, Anthropic Claude, DeepSeek and Google Gemini models questions from your automations and scripts. The integration keeps per-instance conversation history, returns full-length responses through response variables, supports structured JSON output, and exposes token, latency and error metrics as sensor attributes.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 🤝 Community Driven: for more details on the integration,
|
||||
> Community driven: for more details on the integration,
|
||||
> check out the discussion on the **[Home Assistant Community forum](https://community.home-assistant.io/t/ha-text-ai-transforming-home-automation-through-multi-llm-integration/799741)**
|
||||
>
|
||||
> <a href="https://my.home-assistant.io/redirect/hacs_repository/?owner=smkrv&repository=ha-text-ai&category=Integration"><img src="https://my.home-assistant.io/badges/hacs_repository.svg" width="210" height="auto"></a>
|
||||
>
|
||||
> [Screenshots](assets/images/screenshots/screenshot.jpg)
|
||||
|
||||
## 🌟 Features
|
||||
## Features
|
||||
|
||||
- 🧠 **Multi-Provider AI Integration**: Support for OpenAI GPT, Anthropic Claude, DeepSeek and Google Gemini models
|
||||
- 💬 **Advanced Language Processing**: Context-aware, multi-turn conversations
|
||||
- 📝 **Enhanced Memory Management**: Secure file-based history storage
|
||||
- ⚡ **Performance Optimization**: Efficient token usage and smart rate limiting
|
||||
- 🎯 **Advanced Customization**: Per-request model and parameter selection
|
||||
- 🔒 **Enhanced Security**: Secure API key management and usage monitoring
|
||||
- 🎨 **Improved User Experience**: Intuitive configuration and rich interfaces
|
||||
- 🔄 **Automation Integration**: Event-driven responses and template compatibility
|
||||
- **Multi-provider support**: OpenAI, Anthropic Claude, DeepSeek, Google Gemini, plus any OpenAI-compatible endpoint
|
||||
- **Conversation context**: the model sees previous messages; depth is configurable per request (1-20)
|
||||
- **Response variables**: `ask_question` returns the full response directly to the calling automation, bypassing the 255-character state limit
|
||||
- **Structured output**: JSON responses matching a schema you provide
|
||||
- **Per-request overrides**: model, temperature, max_tokens, system prompt, thinking mode
|
||||
- **Usage metrics**: token counters, latency and success/error statistics as sensor attributes
|
||||
- **File-based history**: per-instance JSON storage with automatic rotation at 1 MB
|
||||
|
||||
<details>
|
||||
<summary>📦 Detailed Feature Breakdown</summary>
|
||||
|
||||
### 🧠 **Multi-Provider AI Integration**
|
||||
- Support for OpenAI GPT models
|
||||
- Anthropic Claude integration
|
||||
- DeepSeek integration
|
||||
- Google Gemini 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**
|
||||
- File-based conversation history storage
|
||||
- Automatic history rotation
|
||||
- Configurable history size limits
|
||||
- Secure storage in Home Assistant
|
||||
|
||||
### ⚡ **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
|
||||
|
||||
</details>
|
||||
|
||||
#### 🌐 Translations
|
||||
#### Translations
|
||||
|
||||
| Code | Language | Status |
|
||||
|------|----------|--------|
|
||||
| 🇩🇪 de | Deutsch | Full |
|
||||
| 🇬🇧 en | English | Primary |
|
||||
| 🇪🇸 es | Español | Full |
|
||||
| 🇮🇳 hi | हिन्दी | Full |
|
||||
| 🇮🇹 it | Italiano | Full |
|
||||
| 🇷🇺 ru | Русский | Full |
|
||||
| 🇷🇸 sr | Српски | Full |
|
||||
| 🇨🇳 zh | 中文 | Full |
|
||||
| de | Deutsch | Full |
|
||||
| en | English | Primary |
|
||||
| es | Español | Full |
|
||||
| hi | हिन्दी | Full |
|
||||
| it | Italiano | Full |
|
||||
| ru | Русский | Full |
|
||||
| sr | Српски | Full |
|
||||
| zh | 中文 | Full |
|
||||
|
||||
## 📋 Prerequisites
|
||||
## Prerequisites
|
||||
|
||||
- Home Assistant 2024.12.0 or later (recommended for best compatibility)
|
||||
- Active API key from:
|
||||
- Home Assistant 2024.12.0 or later
|
||||
- An API key from one of:
|
||||
- OpenAI ([Get key](https://platform.openai.com/account/api-keys))
|
||||
- Anthropic ([Get key](https://console.anthropic.com/))
|
||||
- DeepSeek ([Get key](https://platform.deepseek.com/api_keys))
|
||||
- OpenRouter ([Get key](https://openrouter.ai/keys))
|
||||
- Google Gemini 🆕 ([Get key](https://ai.google.dev/gemini-api/docs/api-key)) thanks to ([@Azzedde](https://github.com/Azzedde))
|
||||
- Google Gemini ([Get key](https://ai.google.dev/gemini-api/docs/api-key)) thanks to ([@Azzedde](https://github.com/Azzedde))
|
||||
- Any OpenAI-compatible API provider
|
||||
- Python 3.9 or newer
|
||||
- Stable internet connection
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### 🔧 **Core Configuration Settings**
|
||||
- 🌐 **API Provider**: OpenAI/Anthropic/DeepSeek/Gemini
|
||||
- 🔑 **API Key**: Provider-specific authentication
|
||||
- 🤖 **Model Selection**: Flexible, provider-specific models
|
||||
- 🌡️ **Temperature**: Creativity control (0.0-2.0)
|
||||
- 📏 **Max Tokens**: Response length limit (passed directly to the LLM API to control the maximum length of the response)
|
||||
- ⏱️ **Request Interval**: API call throttling
|
||||
- 💾 **History Size**: Number of messages to retain
|
||||
- 🌍 **Custom API Endpoint**: Optional advanced configuration
|
||||
### Core Configuration Settings
|
||||
- **API Provider**: OpenAI / Anthropic / DeepSeek / Gemini
|
||||
- **API Key**: provider-specific authentication
|
||||
- **Model**: any model your provider offers
|
||||
- **Temperature**: sampling temperature (0.0-2.0)
|
||||
- **Max Tokens**: response length cap, passed to the LLM API
|
||||
- **Request Interval**: minimum delay between API calls (seconds)
|
||||
- **History Size**: number of conversations to retain
|
||||
- **Custom API Endpoint**: for OpenRouter, proxies and self-hosted servers
|
||||
- **Disable Thinking**: turn off model reasoning where the provider supports it
|
||||
- **Allow Local Network**: permit endpoints on private addresses (needed for local servers like Ollama)
|
||||
|
||||
### 🤖 **Recommended Models**
|
||||
### Recommended Models
|
||||
|
||||
#### OpenAI Models
|
||||
- **GPT-5** - The latest flagship model, best for complex reasoning
|
||||
- **GPT-5 mini** - A cost-effective and fast model, suitable for most tasks
|
||||
- **GPT-5.6 Sol** - flagship tier for the hardest tasks
|
||||
- **GPT-5.6 Terra** - mid-tier for high-volume tasks
|
||||
- **GPT-5.6 Luna** - fastest and cheapest, enough for most home automation queries
|
||||
|
||||
#### Anthropic Claude Models
|
||||
- **Claude Opus 4.6** - The most capable model for handling complex tasks
|
||||
- **Claude Sonnet 4.6** - Offers a balance between performance and cost
|
||||
- **Claude Haiku 4.5** - The fastest and most economical option in the series
|
||||
- **Claude Fable 5** - the most capable model for complex tasks
|
||||
- **Claude Sonnet 5** - balance between quality and cost
|
||||
- **Claude Haiku 4.5** - the fastest and cheapest option in the lineup
|
||||
|
||||
#### DeepSeek Models
|
||||
- **DeepSeek-V3** - A general-purpose model for a wide range of tasks
|
||||
- **DeepSeek-R1** - A specialized model focused on reasoning and coding
|
||||
- **deepseek-v4-flash** - fast general-purpose model (default)
|
||||
- **deepseek-v4-pro** - stronger at reasoning and coding
|
||||
|
||||
> The legacy model names `deepseek-chat` and `deepseek-reasoner` stop working on 2026-07-24. If your instance still uses one of them, switch the model in the integration options.
|
||||
|
||||
#### Google Gemini Models
|
||||
- **Gemini 3.1 Pro** - The newest and most advanced model available
|
||||
- **Gemini 3.1 Flash Lite** - Fastest and most cost-efficient model for high-volume workloads
|
||||
- **gemini-3.5-flash** - default; Google's strongest currently available model
|
||||
- **gemini-3.1-pro** - previous flagship, still supported
|
||||
|
||||
> Google shut down `gemini-2.0-flash` on 2026-06-01 and retires the 2.5 family on 2026-10-16. If your instance uses one of those, switch the model in the integration options.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Potentially Compatible Providers</summary>
|
||||
<summary>Potentially Compatible Providers</summary>
|
||||
|
||||
#### Flexible Provider Ecosystem
|
||||
The integration is designed to be flexible and may work with other providers offering OpenAI-compatible APIs:
|
||||
Other providers with OpenAI-compatible APIs may work through the custom endpoint option:
|
||||
- Groq
|
||||
- Together AI
|
||||
- Perplexity AI
|
||||
- Mistral AI
|
||||
- Google AI
|
||||
- Local AI servers (like Ollama)
|
||||
- Local AI servers (like Ollama - enable **Allow Local Network** in the options)
|
||||
- Custom OpenAI-compatible endpoints
|
||||
|
||||
#### 🚨 Compatibility Notes
|
||||
- Not all providers guarantee full compatibility
|
||||
- Performance may vary between providers
|
||||
- Check individual provider's documentation
|
||||
- Ensure your API key has sufficient credits/quota
|
||||
|
||||
#### 🔍 Provider Compatibility Requirements
|
||||
To be compatible, a provider should support:
|
||||
- OpenAI-like REST API structure
|
||||
- JSON request/response format
|
||||
- Standard authentication method
|
||||
- Similar model parameter handling
|
||||
Compatibility is not guaranteed. A provider needs an OpenAI-like REST API with JSON request/response format, standard bearer authentication and similar parameter handling. Check the provider's documentation and make sure your API key has sufficient quota.
|
||||
|
||||
</details>
|
||||
|
||||
## ⚡ Installation
|
||||
## Installation
|
||||
|
||||
### HACS Installation (Recommended)
|
||||
>[!TIP]
|
||||
@@ -185,10 +120,9 @@ To be compatible, a provider should support:
|
||||
|
||||
<a href="https://my.home-assistant.io/redirect/hacs_repository/?owner=smkrv&repository=ha-text-ai&category=Integration"><img src="https://my.home-assistant.io/badges/hacs_repository.svg" width="170" height="auto"></a>
|
||||
1. Open HACS in Home Assistant
|
||||
2. Click on "Integrations"
|
||||
3. Search for "HA Text AI"
|
||||
4. Click "Download"
|
||||
5. Restart Home Assistant
|
||||
2. Search for "HA Text AI"
|
||||
3. Click "Download"
|
||||
4. Restart Home Assistant
|
||||
|
||||
**Alternative Method (Custom Repository):**
|
||||
If the integration is not found in the default repository:
|
||||
@@ -199,60 +133,102 @@ If the integration is not found in the default repository:
|
||||
5. Click "Download"
|
||||
|
||||
### Manual Installation
|
||||
1. Download the latest release
|
||||
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory
|
||||
1. Download `ha_text_ai.zip` from the latest release
|
||||
2. Extract the archive and copy the `ha_text_ai` folder into your `custom_components` directory
|
||||
3. Restart Home Assistant
|
||||
4. Add configuration via UI (Settings → Devices & Services → Add Integration)
|
||||
4. Add configuration via UI (Settings > Devices & Services > Add Integration)
|
||||
|
||||
## ⚙️ Configuration
|
||||
## Configuration
|
||||
|
||||
### Via UI (Recommended)
|
||||
1. Go to Settings → Devices & Services
|
||||
1. Go to Settings > Devices & Services
|
||||
2. Click "Add Integration"
|
||||
3. Search for "HA Text AI"
|
||||
4. Follow the configuration steps
|
||||
|
||||
> **Note:** This integration is configured exclusively through the UI (config entries). YAML configuration is not supported.
|
||||
|
||||
## 🛠️ Available Services
|
||||
## Quick Start
|
||||
|
||||
### 🔄 Response Variables (New!)
|
||||
After configuration you get one entity per instance, named `sensor.ha_text_ai_<name>`. It is a status sensor: it shows the last response and usage metrics, but you don't type questions into it. Questions go through the `ha_text_ai.ask_question` action, called from Developer Tools, automations, or scripts.
|
||||
|
||||
**HA Text AI now supports response variables** - a powerful feature that returns AI responses directly from service calls, eliminating the need for separate text sensors and the 255-character limitation!
|
||||
### First question, no YAML
|
||||
|
||||
#### ✨ Key Benefits:
|
||||
- **Unlimited response length** - No more 255-character truncation
|
||||
- **Direct data access** - Get responses immediately in automations
|
||||
- **Race condition prevention** - Eliminates conflicts in parallel automations
|
||||
- **Simplified workflows** - No need to read from sensors
|
||||
1. Open Developer Tools > Actions (called "Services" in older HA versions).
|
||||
2. Search for "HA Text AI: Ask Question".
|
||||
3. Pick your instance, type a question, press "Perform action".
|
||||
4. The response appears below the form.
|
||||
|
||||
### In an automation
|
||||
|
||||
1. Go to Settings > Automations & scenes > Create automation.
|
||||
2. Add a trigger: a button press, a time, a state change.
|
||||
3. Add action > search "HA Text AI: Ask Question" > fill in the question and pick your instance.
|
||||
4. To use the reply in a follow-up step, the action needs `response_variable: ai_response`. If the visual editor doesn't show a field for it, open the three-dot menu on that action, choose "Edit in YAML", and add the line at the end.
|
||||
5. In the next action, `{{ ai_response.response_text }}` holds the full answer, for example as a notification message.
|
||||
|
||||
Complete working automations: [Automation Examples](#automation-examples-with-response-variables).
|
||||
|
||||
### On a dashboard
|
||||
|
||||
The sensor keeps the last question and answer as attributes, so a Markdown card can show them:
|
||||
|
||||
```yaml
|
||||
type: markdown
|
||||
content: >-
|
||||
**Q:** {{ state_attr('sensor.ha_text_ai_my_assistant', 'question') }}
|
||||
|
||||
**A:** {{ state_attr('sensor.ha_text_ai_my_assistant', 'response') }}
|
||||
```
|
||||
|
||||
The attributes fill in after the first question. They are capped at 2048 characters, so long answers come back complete only via `response_variable`.
|
||||
|
||||
## Available Services
|
||||
|
||||
### Response Variables
|
||||
|
||||
`ask_question` returns its result directly to the calling automation via `response_variable`. The full response text comes back regardless of length (no 255-character truncation), it is available immediately without polling sensor state, and each service call gets its own result, so parallel automations don't overwrite each other.
|
||||
|
||||
### ask_question
|
||||
```yaml
|
||||
service: ha_text_ai.ask_question
|
||||
data:
|
||||
question: "What's the optimal temperature for sleeping?"
|
||||
model: "claude-sonnet-4-6-20260217" # optional
|
||||
instance: sensor.ha_text_ai_claude
|
||||
model: "claude-sonnet-5" # optional, overrides the configured model
|
||||
temperature: 0.5 # optional
|
||||
max_tokens: 500 # optional
|
||||
context_messages: 10 #optional, number of previous messages to include in context, default: 5
|
||||
context_messages: 10 # optional, previous messages to include (1-20, default 5)
|
||||
system_prompt: "You are a sleep optimization expert" # optional
|
||||
instance: sensor.ha_text_ai_gpt
|
||||
response_variable: ai_response # NEW! Store response data directly
|
||||
disable_thinking: true # optional, disable model reasoning for this request
|
||||
response_variable: ai_response
|
||||
```
|
||||
|
||||
#### 📊 Response Data Structure:
|
||||
For structured JSON output, add `structured_output` with a schema:
|
||||
```yaml
|
||||
service: ha_text_ai.ask_question
|
||||
data:
|
||||
question: "Suggest three energy-saving actions for tonight"
|
||||
instance: sensor.ha_text_ai_gpt
|
||||
structured_output: true
|
||||
json_schema: >-
|
||||
{"type": "object", "properties": {"actions": {"type": "array", "items": {"type": "string"}}}}
|
||||
response_variable: ai_response
|
||||
```
|
||||
|
||||
#### Response Data Structure
|
||||
```yaml
|
||||
# The service returns structured data:
|
||||
response_text: "The optimal sleeping temperature is 65-68°F (18-20°C)..."
|
||||
tokens_used: 150
|
||||
prompt_tokens: 50
|
||||
completion_tokens: 100
|
||||
model_used: "claude-sonnet-4-6-20260217"
|
||||
instance: "sensor.ha_text_ai_gpt"
|
||||
model_used: "claude-sonnet-5"
|
||||
instance: "sensor.ha_text_ai_claude"
|
||||
question: "What's the optimal temperature for sleeping?"
|
||||
timestamp: "2025-02-09T16:57:00.000Z"
|
||||
timestamp: "2026-07-09T16:57:00.000Z"
|
||||
success: true
|
||||
# error: "Error message" (only present if success: false)
|
||||
# error and error_type are present only when success is false
|
||||
```
|
||||
|
||||
### set_system_prompt
|
||||
@@ -279,15 +255,16 @@ data:
|
||||
```yaml
|
||||
service: ha_text_ai.get_history
|
||||
data:
|
||||
limit: 5 # optional, number of conversations to return (1-100)
|
||||
limit: 5 # optional, number of conversations to return (values above 200 are clamped); omit to get the full stored history
|
||||
filter_model: "gpt-4o" # optional, filter by specific AI model
|
||||
start_date: "2025-02-01" # optional, filter conversations from this date
|
||||
start_date: "2026-02-01" # optional, filter conversations from this date
|
||||
include_metadata: false # optional, include tokens, response time, etc.
|
||||
sort_order: "newest" # optional, sort order: "newest" or "oldest"
|
||||
instance: sensor.ha_text_ai_gpt
|
||||
response_variable: history_result # entries are in history_result.history
|
||||
```
|
||||
|
||||
## 🚀 Advanced Automation Examples with Response Variables
|
||||
## Automation Examples with Response Variables
|
||||
|
||||
### Example 1: Smart Home Advice with Direct Response
|
||||
```yaml
|
||||
@@ -304,12 +281,12 @@ automation:
|
||||
response_variable: ai_advice
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "🏠 Smart Home Tip"
|
||||
title: "Smart Home Tip"
|
||||
message: |
|
||||
{{ ai_advice.response_text }}
|
||||
|
||||
📊 Tokens used: {{ ai_advice.tokens_used }}
|
||||
🤖 Model: {{ ai_advice.model_used }}
|
||||
Tokens used: {{ ai_advice.tokens_used }}
|
||||
Model: {{ ai_advice.model_used }}
|
||||
```
|
||||
|
||||
### Example 2: Weather-Based AI Recommendations
|
||||
@@ -335,7 +312,7 @@ automation:
|
||||
then:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "❄️ Winter Preparation Advice"
|
||||
title: "Winter Preparation Advice"
|
||||
message: |
|
||||
{{ winter_advice.response_text }}
|
||||
|
||||
@@ -343,7 +320,7 @@ automation:
|
||||
else:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "⚠️ AI Service Error"
|
||||
title: "AI Service Error"
|
||||
message: "Failed to get winter advice: {{ winter_advice.error }}"
|
||||
```
|
||||
|
||||
@@ -379,10 +356,10 @@ automation:
|
||||
instance: sensor.ha_text_ai_gpt
|
||||
response_variable: recommendations
|
||||
|
||||
# Step 3: Send comprehensive report
|
||||
# Step 3: Send the combined report
|
||||
- service: notify.telegram
|
||||
data:
|
||||
title: "🏠 Home Analysis Report"
|
||||
title: "Home Analysis Report"
|
||||
message: |
|
||||
**Analysis:**
|
||||
{{ status_analysis.response_text }}
|
||||
@@ -396,11 +373,11 @@ automation:
|
||||
- Generated: {{ recommendations.timestamp }}
|
||||
```
|
||||
|
||||
### 💡 Migration from Sensors to Response Variables
|
||||
### Migration from Sensors to Response Variables
|
||||
|
||||
#### Old Method (Limited):
|
||||
#### Old Method:
|
||||
```yaml
|
||||
# ❌ Old way - limited to 255 characters, race conditions
|
||||
# Old way: delay-based polling, response truncated by the 255-character state limit
|
||||
automation:
|
||||
- alias: "Old AI Response Method"
|
||||
action:
|
||||
@@ -411,12 +388,12 @@ automation:
|
||||
- delay: "00:00:05" # Wait for sensor update
|
||||
- service: notify.mobile
|
||||
data:
|
||||
message: "{{ state_attr('sensor.ha_text_ai_gpt', 'response')[:255] }}..." # Truncated!
|
||||
message: "{{ state_attr('sensor.ha_text_ai_gpt', 'response')[:255] }}..." # Truncated
|
||||
```
|
||||
|
||||
#### New Method (Unlimited):
|
||||
#### New Method:
|
||||
```yaml
|
||||
# ✅ New way - unlimited length, immediate access, no race conditions
|
||||
# New way: full response, available immediately
|
||||
automation:
|
||||
- alias: "New AI Response Method"
|
||||
action:
|
||||
@@ -424,24 +401,23 @@ automation:
|
||||
data:
|
||||
question: "Long question here..."
|
||||
instance: sensor.ha_text_ai_gpt
|
||||
response_variable: ai_response # Direct access!
|
||||
response_variable: ai_response
|
||||
- service: notify.mobile
|
||||
data:
|
||||
message: "{{ ai_response.response_text }}" # Full response, no truncation!
|
||||
message: "{{ ai_response.response_text }}" # Full response, no truncation
|
||||
```
|
||||
|
||||
### 🏷️ HA Text AI Sensor Naming Convention
|
||||
### HA Text AI Sensor Naming Convention
|
||||
|
||||
#### Character Restrictions
|
||||
- Only lowercase letters (a-z)
|
||||
- Numbers (0-9)
|
||||
- Underscore (_)
|
||||
- Maximum length: 50 characters (including `ha_text_ai_`)
|
||||
#### Naming Rules
|
||||
- Only lowercase letters (a-z), numbers (0-9) and underscore (_)
|
||||
- The part after the `sensor.ha_text_ai_` prefix is limited to 50 characters
|
||||
- No spaces; keep it descriptive but short
|
||||
|
||||
#### Sensor Name Structure
|
||||
```yaml
|
||||
# Always starts with 'sensor.ha_text_ai_'
|
||||
# You define only the part after the underscore
|
||||
# You define only the part after the prefix
|
||||
sensor.ha_text_ai_YOUR_UNIQUE_SUFFIX
|
||||
|
||||
# Examples:
|
||||
@@ -472,107 +448,99 @@ automation:
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'response') }}
|
||||
```
|
||||
|
||||
### 💡 Naming Rules
|
||||
- Prefix is always `sensor.ha_text_ai_`
|
||||
- Add your unique identifier after the underscore
|
||||
- Use lowercase
|
||||
- No spaces allowed
|
||||
- Keep it descriptive but concise
|
||||
### HA Text AI Sensor Attributes
|
||||
|
||||
### 🔍 HA Text AI Sensor Attributes
|
||||
- **Model and provider**: current model, API provider, model used for the last response
|
||||
- **System status**: processing, rate-limit and endpoint state
|
||||
- **Performance metrics**: request success/failure counters and latency statistics
|
||||
- **Token usage**: total, prompt and completion token counters as reported by the provider's API
|
||||
- **Last interaction**: most recent question, response and timestamp
|
||||
- **System health**: error counter, maintenance flag, uptime
|
||||
|
||||
- 🤖 **Model and Provider Information**: Tracking current AI model and service provider
|
||||
- 🚦 **System Status**: Real-time API and processing readiness
|
||||
- 📊 **Performance Metrics**: Request success rates and response times
|
||||
- 💬 **Conversation Tracking**: Token usage and interaction history are estimated using a heuristic method based on word count and specific word characteristics, which may differ from actual token usage.
|
||||
- 🕒 **Last Interaction Details**: Recent query and response tracking
|
||||
- ❤️ **System Health**: Error monitoring and service uptime
|
||||
Attributes may be 0 or empty until the first request completes.
|
||||
|
||||
<details>
|
||||
<summary>📦 Detailed Sensor Attributes</summary>
|
||||
<summary>Detailed Sensor Attributes</summary>
|
||||
|
||||
#### Model and Provider Information
|
||||
```yaml
|
||||
# Name of the AI model currently in use (e.g., latest version of GPT)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Model') }} # gpt-4o
|
||||
# Model currently configured for this instance
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'model') }} # gpt-4o-mini
|
||||
|
||||
# Service provider for the AI model (determines API endpoint and authentication)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Api provider') }} # openai
|
||||
# Service provider (determines API endpoint and authentication)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'api_provider') }} # openai
|
||||
|
||||
# Previous or alternative model configuration
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Last model') }} # gpt-4o
|
||||
# Model that produced the last response (may differ after a per-request override)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'last_model') }} # gpt-4o-mini
|
||||
```
|
||||
|
||||
#### System Status
|
||||
```yaml
|
||||
# Indicates if a request is currently being processed
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Is processing') }} # false
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'is_processing') }} # false
|
||||
|
||||
# Shows if the API has hit its request rate limit
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Is rate limited') }} # false
|
||||
# Shows if the API has hit its request rate limit
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'is_rate_limited') }} # false
|
||||
|
||||
# Status of the specific API endpoint being used
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Endpoint status') }} # ready
|
||||
# Status of the API endpoint being used
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'endpoint_status') }} # ready
|
||||
```
|
||||
|
||||
#### Performance Metrics
|
||||
```yaml
|
||||
# Total number of successfully completed API requests
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Successful requests') }} # 0
|
||||
# Number of successfully completed API requests
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'successful_requests') }} # 42
|
||||
|
||||
# Number of API requests that encountered errors
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Failed requests') }} # 0
|
||||
# Number of API requests that encountered errors
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'failed_requests') }} # 0
|
||||
|
||||
# Mean time taken to receive a response from the AI service
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Average latency') }} # 0
|
||||
|
||||
# Maximum time taken for a single request-response cycle
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Max latency') }} # 0
|
||||
# Average / max / min response time, in seconds
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'average_latency') }} # 1.85
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'max_latency') }} # 4.2
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'min_latency') }} # 0.9
|
||||
```
|
||||
|
||||
#### Conversation and Token Usage
|
||||
```yaml
|
||||
# Number of previous interactions stored in conversation context
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'History size') }} # 0
|
||||
# Number of entries in the current history file
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'history_size') }} # 12
|
||||
|
||||
# Total number of tokens used across all interactions
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Total tokens') }} # 0
|
||||
# Token counters as reported by the provider's API
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'total_tokens') }} # 4520
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'prompt_tokens') }} # 3100
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'completion_tokens') }} # 1420
|
||||
|
||||
# Tokens used in the input prompts
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Prompt tokens') }} # 0
|
||||
|
||||
# Tokens used in the AI's generated responses
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Completion tokens') }} # 0
|
||||
|
||||
# Number of entries in current history file
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'History size') }} # 0
|
||||
|
||||
# Last few conversation entries (last 5 for performance)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'conversation_history') }} # [...]
|
||||
# Last 3 conversation entries, each truncated to 256 characters
|
||||
# (full history is available via the get_history service)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'conversation_history') }} # [...]
|
||||
```
|
||||
|
||||
#### Last Interaction Details
|
||||
```yaml
|
||||
# Most recent complete response generated by the AI service
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Response') }} # Last AI response
|
||||
# Most recent response, truncated to 2048 characters in the attribute
|
||||
# (the response_variable path returns the full text)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'response') }} # Last AI response
|
||||
|
||||
# The most recently processed user query or prompt
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Question') }} # Last asked question
|
||||
# The most recently processed question
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'question') }} # Last asked question
|
||||
|
||||
# Precise moment when the last interaction occurred (useful for tracking and logging)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Last timestamp') }} # Timestamp
|
||||
# When the last interaction occurred
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'last_timestamp') }} # Timestamp
|
||||
```
|
||||
|
||||
#### System Health
|
||||
```yaml
|
||||
# Cumulative count of all errors encountered during AI service interactions
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Total errors') }} # 0
|
||||
# Cumulative count of errors across all requests
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'total_errors') }} # 0
|
||||
|
||||
# Indicates if the AI service is currently undergoing scheduled or emergency maintenance
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Is maintenance') }} # false
|
||||
# Error message of the last failed request (null after a success)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'last_error') }} # null
|
||||
|
||||
# Total continuous operational time of the AI service (in hours or days)
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'Uptime') }} # 547,58
|
||||
# Maintenance flag
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'is_maintenance') }} # false
|
||||
|
||||
# Seconds since the integration instance was set up
|
||||
{{ state_attr('sensor.ha_text_ai_gpt', 'uptime') }} # 547.58
|
||||
```
|
||||
|
||||
### History Storage
|
||||
@@ -582,41 +550,36 @@ Conversation history stored in `.storage/ha_text_ai_history/` directory:
|
||||
- Archived history files are timestamped
|
||||
- Default maximum file size: 1MB
|
||||
|
||||
### 💡 Pro Tips
|
||||
- Always check attribute existence
|
||||
- Use these attributes for monitoring and automation
|
||||
- Some values might be 0 or empty initially
|
||||
|
||||
</details>
|
||||
|
||||
## 📘 FAQ
|
||||
## FAQ
|
||||
|
||||
**Q: Which AI providers are supported?**
|
||||
A: OpenAI (GPT models), Anthropic (Claude models), DeepSeek, Google Gemini, and OpenRouter are officially supported, with many other OpenAI-compatible providers working as well.
|
||||
A: OpenAI, Anthropic, DeepSeek and Google Gemini are built-in providers. OpenRouter and other OpenAI-compatible services work through the OpenAI provider with a custom endpoint.
|
||||
|
||||
**Q: How can I reduce API costs?**
|
||||
A: Use gpt-5-mini or claude-haiku-4-5 for most queries, implement caching, and optimize token usage.
|
||||
A: Use a cheap fast model (GPT-5.6 Luna, Claude Haiku 4.5, deepseek-v4-flash, gemini-3.5-flash) for routine queries, lower `context_messages`, and cap `max_tokens`.
|
||||
|
||||
**Q: Are there limitations on the number of requests?**
|
||||
A: Depends on your API provider's plan. We recommend monitoring usage and implementing request throttling via `request_interval` configuration.
|
||||
A: Depends on your API provider's plan. Monitor usage via the sensor attributes and throttle calls with the `request_interval` option.
|
||||
|
||||
**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.
|
||||
A: Each integration instance is bound to one provider. Add a separate instance per provider and pick the instance in your service calls; within an instance you can override the model per request.
|
||||
|
||||
**Q: What are the token limits for different models?**
|
||||
A: Token limits vary by provider and model. OpenAI's GPT-5 supports up to 1M context tokens, Claude Opus 4.6 supports up to 1M tokens, Gemini 3.1 Pro supports up to 1M tokens, while smaller models typically have 128K-200K limits. Check your provider's documentation for specific limits.
|
||||
A: Context window sizes vary by provider and model - check your provider's documentation. The `max_tokens` option caps only the response length, not the context window.
|
||||
|
||||
**Q: How do I monitor token usage?**
|
||||
A: Use the sensor attributes like `Total tokens`, `Prompt tokens`, and `Completion tokens` to track usage. You can also create automations to alert you when usage exceeds certain thresholds.
|
||||
A: Use the sensor attributes `total_tokens`, `prompt_tokens` and `completion_tokens`. You can also create automations to alert you when usage exceeds a threshold.
|
||||
|
||||
**Q: Is my data secure?**
|
||||
A: Yes, your data is secure. The system operates entirely on your local machine, keeping your data under your control. API keys are stored securely and all external communications use encrypted connections.
|
||||
A: Conversation history and API keys are stored locally in your Home Assistant instance. Questions and context are sent to the provider you configure over HTTPS; nothing is shared with third parties beyond that provider.
|
||||
|
||||
**Q: How do context messages work?**
|
||||
A: Context messages allow the AI to remember and reference previous conversation history. By default, 5 previous messages are included, but you can customize this from 1 to 20 messages to control the conversation depth and token usage.
|
||||
A: Context messages let the AI reference previous conversation history. By default 5 previous messages are included; you can set 1 to 20 per request to balance conversation depth against token usage.
|
||||
|
||||
**Q: Where is conversation history stored?**
|
||||
A: History is stored in files under the `.storage/ha_text_ai_history/` directory, with automatic rotation and size management.
|
||||
@@ -625,9 +588,9 @@ A: History is stored in files under the `.storage/ha_text_ai_history/` directory
|
||||
A: Yes, archived history files are stored with timestamps and can be accessed manually if needed.
|
||||
|
||||
**Q: How much history is kept?**
|
||||
A: By default, up to 50 conversations are stored (max 200), configurable via UI. Files are automatically rotated when they reach 1MB.
|
||||
A: 50 conversations by default, configurable up to 100 in the UI. Files are automatically rotated when they reach 1MB.
|
||||
|
||||
## 🤝 Contributing
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please read our [Contributing Guide](CONTRIBUTING.md).
|
||||
|
||||
@@ -650,12 +613,12 @@ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## 📝 License
|
||||
## License
|
||||
|
||||
Author: SMKRV
|
||||
[PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0) - see [LICENSE](LICENSE) for details.
|
||||
[MIT License](https://opensource.org/licenses/MIT) - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## 💡 Support the Project
|
||||
## Support the Project
|
||||
|
||||
The best support is:
|
||||
- Sharing feedback
|
||||
@@ -669,16 +632,13 @@ If you want to say thanks financially, you can send a small token of appreciatio
|
||||
**USDT Wallet (TRC10/TRC20):**
|
||||
`TXC9zYHYPfWUGi4Sv4R1ctTBGScXXQk5HZ`
|
||||
|
||||
*Open-source is built by community passion!* 🚀
|
||||
|
||||
---
|
||||
|
||||
<div align="center"><img src="https://github.com/smkrv/ha-text-ai/blob/2aaf3405759eb2d97624834594e24ace896131df/assets/images/icons/footer_icon.png" alt="HA Text AI" style="width: 128px; height: auto;"/></div>
|
||||
<div align="center">
|
||||
|
||||
Made with ❤️ for the Home Assistant Community
|
||||
Made 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>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The HA Text AI integration.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -9,23 +9,22 @@ The HA Text AI integration.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
import asyncio
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME, EVENT_HOMEASSISTANT_CLOSE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import HATextAICoordinator
|
||||
from .api_client import APIClient
|
||||
from .utils import normalize_name, safe_log_data, validate_endpoint
|
||||
from .utils import create_pinned_session, normalize_name, safe_log_data, validate_endpoint
|
||||
from .providers import get_default_endpoint, get_default_model, build_auth_headers
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@@ -51,6 +50,8 @@ from .const import (
|
||||
CONF_MAX_HISTORY_SIZE,
|
||||
CONF_ALLOW_LOCAL_NETWORK,
|
||||
DEFAULT_ALLOW_LOCAL_NETWORK,
|
||||
CONF_DISABLE_THINKING,
|
||||
DEFAULT_DISABLE_THINKING,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -69,6 +70,7 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||
vol.Optional("context_messages"): cv.positive_int,
|
||||
vol.Optional("structured_output", default=False): cv.boolean,
|
||||
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
|
||||
vol.Optional("disable_thinking"): cv.boolean,
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||
@@ -78,7 +80,11 @@ SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||
|
||||
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||
vol.Required("instance"): cv.string,
|
||||
vol.Optional("limit"): cv.positive_int,
|
||||
# No default and no schema max: omitting limit returns the full history
|
||||
# (pre-2.5.0 behavior) and oversized values are clamped to
|
||||
# ABSOLUTE_MAX_HISTORY_SIZE in history.async_get_history instead of
|
||||
# failing the whole service call.
|
||||
vol.Optional("limit"): vol.All(cv.positive_int, vol.Range(min=1)),
|
||||
vol.Optional("filter_model"): cv.string,
|
||||
vol.Optional("start_date"): cv.string,
|
||||
vol.Optional("include_metadata"): cv.boolean,
|
||||
@@ -106,10 +112,23 @@ def get_coordinator_by_instance(hass: HomeAssistant, instance: str) -> HATextAIC
|
||||
|
||||
raise HomeAssistantError(f"Instance {instance} not found")
|
||||
|
||||
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 Home Assistant Text AI component."""
|
||||
# Initialize domain data storage
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
_async_register_services(hass)
|
||||
return True
|
||||
|
||||
def _async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register domain services; safe to call again after unload.
|
||||
|
||||
Unloading the last config entry unregisters the services, and a config
|
||||
entry reload (every options change does one) runs unload + setup_entry
|
||||
without re-running async_setup — so setup_entry must be able to bring
|
||||
the services back.
|
||||
"""
|
||||
if hass.services.has_service(DOMAIN, SERVICE_ASK_QUESTION):
|
||||
return
|
||||
|
||||
async def async_ask_question(call: ServiceCall) -> dict:
|
||||
"""Handle ask_question service with response data."""
|
||||
@@ -124,6 +143,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
context_messages=call.data.get("context_messages"),
|
||||
structured_output=call.data.get("structured_output", False),
|
||||
json_schema=call.data.get("json_schema"),
|
||||
disable_thinking=call.data.get("disable_thinking"),
|
||||
)
|
||||
|
||||
# Return structured response data
|
||||
@@ -162,22 +182,26 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
await coordinator.async_clear_history()
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error clearing history: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to clear history: {str(err)}")
|
||||
raise HomeAssistantError(f"Failed to clear history: {str(err)}") from err
|
||||
|
||||
async def async_get_history(call: ServiceCall) -> list:
|
||||
async def async_get_history(call: ServiceCall) -> dict:
|
||||
"""Handle get_history service."""
|
||||
try:
|
||||
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
|
||||
return await coordinator.async_get_history(
|
||||
history = await coordinator.async_get_history(
|
||||
limit=call.data.get("limit"),
|
||||
filter_model=call.data.get("filter_model"),
|
||||
start_date=call.data.get("start_date"),
|
||||
include_metadata=call.data.get("include_metadata", False),
|
||||
sort_order=call.data.get("sort_order", "newest")
|
||||
)
|
||||
# HA requires action responses to be dicts. The bare list made
|
||||
# every return_response call fail with a server error, so this
|
||||
# path never worked before and the wrapper breaks no consumer.
|
||||
return {"history": history}
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error getting history: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to get history: {str(err)}")
|
||||
raise HomeAssistantError(f"Failed to get history: {str(err)}") from err
|
||||
|
||||
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||
"""Handle set_system_prompt service."""
|
||||
@@ -186,7 +210,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
await coordinator.async_set_system_prompt(call.data["prompt"])
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error setting system prompt: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}")
|
||||
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}") from err
|
||||
|
||||
# Register services
|
||||
hass.services.async_register(
|
||||
@@ -219,8 +243,6 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def async_check_api(session, endpoint: str, headers: dict, provider: str, api_timeout: int = DEFAULT_API_TIMEOUT) -> bool:
|
||||
"""Check API availability using provider registry configuration."""
|
||||
try:
|
||||
@@ -240,7 +262,9 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
|
||||
check_url = f"{endpoint}{check_path}"
|
||||
|
||||
async with asyncio.timeout(api_timeout):
|
||||
async with session.get(check_url, headers=headers) as response:
|
||||
async with session.get(
|
||||
check_url, headers=headers, allow_redirects=False
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True
|
||||
elif response.status == 401:
|
||||
@@ -260,6 +284,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up HA Text AI from a config entry."""
|
||||
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
|
||||
|
||||
session = None
|
||||
try:
|
||||
# Get provider from data or options (options takes precedence)
|
||||
config = {**entry.data, **entry.options}
|
||||
@@ -269,22 +294,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
_LOGGER.error("API provider not specified")
|
||||
raise ConfigEntryNotReady("API provider is required")
|
||||
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
|
||||
model = config.get(CONF_MODEL, get_default_model(api_provider))
|
||||
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider))
|
||||
allow_local = config.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
||||
if allow_local:
|
||||
_LOGGER.warning(
|
||||
_LOGGER.info(
|
||||
"Local network mode enabled for endpoint %s — "
|
||||
"SSRF protection disabled, API credentials may be sent without TLS",
|
||||
"SSRF protection relaxed for self-hosted proxies",
|
||||
raw_endpoint,
|
||||
)
|
||||
try:
|
||||
endpoint = await validate_endpoint(hass, raw_endpoint, allow_local=allow_local)
|
||||
endpoint, resolved_ips = await validate_endpoint(
|
||||
hass, raw_endpoint, allow_local=allow_local
|
||||
)
|
||||
except ValueError as err:
|
||||
_LOGGER.error("Invalid API endpoint: %s", err)
|
||||
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from err
|
||||
|
||||
# Pinned session closes DNS-rebinding TOCTOU and isolates cookies
|
||||
# from other integrations sharing the same endpoint hostname.
|
||||
# The integration owns this session: APIClient.shutdown() closes it
|
||||
# on unload, the except handler below closes it on failed setup.
|
||||
session = create_pinned_session(endpoint, resolved_ips)
|
||||
# API key can now be updated via options
|
||||
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
|
||||
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
|
||||
@@ -294,6 +325,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
|
||||
max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
|
||||
context_messages = config.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
|
||||
disable_thinking = config.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING)
|
||||
|
||||
headers = build_auth_headers(api_provider, api_key)
|
||||
|
||||
@@ -324,6 +356,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
max_history_size=max_history_size,
|
||||
context_messages=context_messages,
|
||||
api_timeout=api_timeout,
|
||||
disable_thinking=disable_thinking,
|
||||
)
|
||||
|
||||
# Initialize coordinator (directories, history, metrics)
|
||||
@@ -335,6 +368,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# A reload after the last entry was unloaded needs the services back.
|
||||
_async_register_services(hass)
|
||||
|
||||
_LOGGER.debug("Stored coordinator in hass.data[%s][%s]", DOMAIN, entry.entry_id)
|
||||
|
||||
# Set up platforms
|
||||
@@ -343,12 +379,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Register update listener for options changes
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
# HA Core stop does not unload entries, so close the dedicated
|
||||
# session on the CLOSE event too; unload removes this listener
|
||||
# and closes the session via APIClient.shutdown() instead.
|
||||
async def _async_close_session_on_stop(_event) -> None:
|
||||
if not session.closed:
|
||||
await session.close()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_CLOSE, _async_close_session_on_stop
|
||||
)
|
||||
)
|
||||
|
||||
_LOGGER.debug("Setup completed for %s", instance_name)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Error setting up HA Text AI: %s", err)
|
||||
if session is not None and not session.closed:
|
||||
await session.close()
|
||||
raise
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
@@ -356,7 +407,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
_LOGGER.info("Options updated for %s, reloading integration", entry.title)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
try:
|
||||
@@ -369,8 +419,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await coordinator.async_shutdown()
|
||||
|
||||
# When removing the last config entry, also unregister services and
|
||||
# clear the domain bucket so HA doesn't show stale services in the UI.
|
||||
if not hass.data.get(DOMAIN):
|
||||
hass.data.pop(DOMAIN, None)
|
||||
for service in (
|
||||
SERVICE_ASK_QUESTION,
|
||||
SERVICE_CLEAR_HISTORY,
|
||||
SERVICE_GET_HISTORY,
|
||||
SERVICE_SET_SYSTEM_PROMPT,
|
||||
):
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""
|
||||
API Client for HA Text AI.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -29,7 +31,6 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""API Client for OpenAI and Anthropic."""
|
||||
|
||||
@@ -37,11 +38,11 @@ class APIClient:
|
||||
self,
|
||||
session: ClientSession,
|
||||
endpoint: str,
|
||||
headers: Dict[str, str],
|
||||
headers: dict[str, str],
|
||||
api_provider: str,
|
||||
model: str,
|
||||
api_timeout: int = DEFAULT_API_TIMEOUT,
|
||||
api_key: Optional[str] = None,
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize API client."""
|
||||
self.session = session
|
||||
@@ -89,12 +90,22 @@ class APIClient:
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
payload: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Make API request with retry logic for transient errors only."""
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Make API request with retry logic for transient errors only.
|
||||
|
||||
Retries on:
|
||||
- asyncio.TimeoutError
|
||||
- HTTP 429 (rate limit) — honors Retry-After header when present
|
||||
- HTTP 502/503/504 (upstream transient errors)
|
||||
|
||||
4xx (other than 429) return immediately — they are not retryable.
|
||||
"""
|
||||
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
|
||||
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
|
||||
|
||||
retryable_5xx = {502, 503, 504}
|
||||
|
||||
for attempt in range(API_RETRY_COUNT):
|
||||
try:
|
||||
async with self.session.post(
|
||||
@@ -102,6 +113,9 @@ class APIClient:
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
# The session pins DNS to validated IPs; following a
|
||||
# redirect would resolve a new host past that pin.
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
_LOGGER.debug("Response status: %s", response.status)
|
||||
if response.status == 200:
|
||||
@@ -114,25 +128,41 @@ class APIClient:
|
||||
except Exception:
|
||||
error_data = {"raw": await response.text()}
|
||||
|
||||
# Rate limit — retry with backoff
|
||||
# Rate limit — retry with backoff, prefer Retry-After header
|
||||
if response.status == 429:
|
||||
_LOGGER.warning(
|
||||
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
|
||||
)
|
||||
if attempt < API_RETRY_COUNT - 1:
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
retry_after = self._parse_retry_after(
|
||||
response.headers.get("Retry-After")
|
||||
)
|
||||
await asyncio.sleep(retry_after or (2 ** attempt))
|
||||
continue
|
||||
raise HomeAssistantError("API rate limit exceeded")
|
||||
|
||||
# Client/server errors — don't retry
|
||||
# Upstream transient errors — retry with backoff
|
||||
if response.status in retryable_5xx:
|
||||
_LOGGER.warning(
|
||||
"Upstream %d on attempt %d/%d",
|
||||
response.status, attempt + 1, API_RETRY_COUNT,
|
||||
)
|
||||
if attempt < API_RETRY_COUNT - 1:
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
continue
|
||||
raise HomeAssistantError(
|
||||
f"Upstream error after retries: status {response.status}"
|
||||
)
|
||||
|
||||
# Other client/server errors — don't retry
|
||||
truncated_error = str(error_data)[:512]
|
||||
_LOGGER.error("API error (status %d): %s", response.status, truncated_error)
|
||||
raise HomeAssistantError(f"API error: status {response.status}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except asyncio.TimeoutError as err:
|
||||
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
|
||||
if attempt == API_RETRY_COUNT - 1:
|
||||
raise HomeAssistantError("API request timed out")
|
||||
raise HomeAssistantError("API request timed out") from err
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
except HomeAssistantError:
|
||||
raise
|
||||
@@ -147,15 +177,29 @@ class APIClient:
|
||||
|
||||
raise HomeAssistantError("API request failed after all retries")
|
||||
|
||||
@staticmethod
|
||||
def _parse_retry_after(value: str | None) -> float | None:
|
||||
"""Parse Retry-After header (seconds). Caps at 60s to avoid long stalls."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
seconds = float(value.strip())
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
if seconds <= 0:
|
||||
return None
|
||||
return min(seconds, 60.0)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
structured_output: bool = False,
|
||||
json_schema: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
json_schema: str | None = None,
|
||||
disable_thinking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create completion using appropriate API."""
|
||||
try:
|
||||
self._validate_parameters(temperature, max_tokens)
|
||||
@@ -163,37 +207,129 @@ class APIClient:
|
||||
if self.api_provider == API_PROVIDER_ANTHROPIC:
|
||||
return await self._create_anthropic_completion(
|
||||
model, messages, temperature, max_tokens,
|
||||
structured_output, json_schema
|
||||
structured_output, json_schema, disable_thinking
|
||||
)
|
||||
elif self.api_provider == API_PROVIDER_DEEPSEEK:
|
||||
return await self._create_deepseek_completion(
|
||||
model, messages, temperature, max_tokens,
|
||||
structured_output, json_schema
|
||||
structured_output, json_schema, disable_thinking
|
||||
)
|
||||
elif self.api_provider == API_PROVIDER_GEMINI:
|
||||
return await self._create_gemini_completion(
|
||||
model, messages, temperature, max_tokens,
|
||||
structured_output, json_schema
|
||||
structured_output, json_schema, disable_thinking
|
||||
)
|
||||
else:
|
||||
return await self._create_openai_completion(
|
||||
model, messages, temperature, max_tokens,
|
||||
structured_output, json_schema
|
||||
structured_output, json_schema, disable_thinking
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.error("API request failed: %s", str(e))
|
||||
raise HomeAssistantError(f"API request failed: {str(e)}")
|
||||
raise HomeAssistantError(f"API request failed: {str(e)}") from e
|
||||
|
||||
# Non-reasoning variants whose names otherwise overlap with the
|
||||
# reasoning prefix set (e.g. "gpt-5-chat-latest" is classic chat).
|
||||
_OPENAI_NON_REASONING_PATTERNS: tuple[str, ...] = ("gpt-5-chat",)
|
||||
_OPENAI_REASONING_REGEX = re.compile(
|
||||
r"^(?:o\d+|gpt-[5-9](?:\.\d+)?)(?:[-_].*)?$"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_openai_reasoning_model(cls, model: str) -> bool:
|
||||
"""Detect OpenAI reasoning models (o-series and GPT-5+ family).
|
||||
|
||||
Reasoning models require max_completion_tokens (not max_tokens),
|
||||
do not accept custom temperature, and use "developer" role instead
|
||||
of "system". Uses a regex so future o5/gpt-6 releases are caught
|
||||
without code change. Explicitly excludes chat-variants
|
||||
(e.g. gpt-5-chat-latest) which are classic chat models.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
m = model.strip().lower()
|
||||
# OpenRouter-style "openai/o3" prefix — strip provider namespace.
|
||||
if "/" in m:
|
||||
m = m.rsplit("/", 1)[-1]
|
||||
for non_reasoning in cls._OPENAI_NON_REASONING_PATTERNS:
|
||||
if m.startswith(non_reasoning):
|
||||
return False
|
||||
return bool(cls._OPENAI_REASONING_REGEX.match(m))
|
||||
|
||||
@staticmethod
|
||||
def _convert_system_to_developer(
|
||||
messages: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Rename role "system" to "developer" for OpenAI reasoning models."""
|
||||
return [
|
||||
{**m, "role": "developer"} if m.get("role") == "system" else m
|
||||
for m in messages
|
||||
]
|
||||
|
||||
# Matches /no_think only as a standalone soft-switch token (word-bounded),
|
||||
# not when users discuss the concept ("discuss /no_think semantics").
|
||||
_NO_THINK_TOKEN_RE = re.compile(r"(?:^|\s)/no_think(?:\s|$)")
|
||||
|
||||
@classmethod
|
||||
def _apply_no_think_tag(
|
||||
cls,
|
||||
messages: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Append Qwen-style /no_think soft switch to the last user message.
|
||||
|
||||
Why: Qwen3 reasoning models treat "/no_think" in the last user turn as a
|
||||
request to skip thinking. Non-Qwen models ignore the trailing token
|
||||
harmlessly, so this is safe to apply to all OpenAI-compatible backends.
|
||||
Uses word-boundary regex for dedup so that user content mentioning
|
||||
"/no_think" mid-sentence isn't mistaken for an existing soft switch.
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
patched = [m.copy() for m in messages]
|
||||
for i in range(len(patched) - 1, -1, -1):
|
||||
if patched[i].get("role") == "user":
|
||||
content = patched[i].get("content", "")
|
||||
if not cls._NO_THINK_TOKEN_RE.search(content):
|
||||
patched[i]["content"] = f"{content.rstrip()} /no_think".lstrip()
|
||||
break
|
||||
return patched
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_blocks(text: str) -> str:
|
||||
"""Remove <think>...</think> reasoning blocks from model output.
|
||||
|
||||
Why: Some reasoning models (DeepSeek-R1, Qwen-Thinking) emit chain-of-thought
|
||||
wrapped in <think> tags even when thinking is nominally disabled. Strip them
|
||||
so the final answer stays clean. Handles nested blocks via iterative
|
||||
replacement, and drops dangling opening tags when a response is
|
||||
truncated mid-block.
|
||||
"""
|
||||
if not text or "<think>" not in text:
|
||||
return text
|
||||
pattern = re.compile(r"<think>.*?</think>", flags=re.DOTALL)
|
||||
cleaned = text
|
||||
# Iterative pass: each iteration peels one layer of nested tags.
|
||||
# Bounded to 10 iterations to avoid pathological inputs.
|
||||
for _ in range(10):
|
||||
new = pattern.sub("", cleaned)
|
||||
if new == cleaned:
|
||||
break
|
||||
cleaned = new
|
||||
# If a truncated response left a dangling <think> open, drop the rest
|
||||
# from that marker onward to avoid leaking partial reasoning.
|
||||
if "<think>" in cleaned:
|
||||
cleaned = cleaned.split("<think>", 1)[0]
|
||||
return cleaned.strip()
|
||||
|
||||
@staticmethod
|
||||
def _apply_structured_output(
|
||||
payload: Dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
structured_output: bool,
|
||||
json_schema: Optional[str],
|
||||
json_schema: str | None,
|
||||
) -> None:
|
||||
"""Apply OpenAI-compatible structured output to payload in-place."""
|
||||
if not (structured_output and json_schema):
|
||||
return
|
||||
import json
|
||||
try:
|
||||
schema = json.loads(json_schema)
|
||||
payload["response_format"] = {
|
||||
@@ -211,28 +347,58 @@ class APIClient:
|
||||
async def _create_deepseek_completion(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
structured_output: bool = False,
|
||||
json_schema: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create completion using DeepSeek API."""
|
||||
json_schema: str | None = None,
|
||||
disable_thinking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create completion using DeepSeek API.
|
||||
|
||||
DeepSeek-reasoner (R1) is a reasoning model: it ignores /no_think
|
||||
(thinking is always on by design) and emits reasoning_content as a
|
||||
separate field alongside content. We skip the no_think append for
|
||||
this model and preserve reasoning_content in the response payload
|
||||
so it's available for logging/debug.
|
||||
|
||||
DeepSeek V4+ (deepseek-v4-flash/-pro) selects thinking mode via a
|
||||
top-level "thinking" request parameter instead of the model name,
|
||||
so /no_think does not apply there.
|
||||
"""
|
||||
url = f"{self.endpoint}/chat/completions"
|
||||
m_lower = model.lower()
|
||||
is_reasoner = "reasoner" in m_lower
|
||||
is_v4plus = re.search(r"deepseek-v[4-9]", m_lower) is not None
|
||||
final_messages = (
|
||||
self._apply_no_think_tag(messages)
|
||||
if (disable_thinking and not is_reasoner and not is_v4plus)
|
||||
else messages
|
||||
)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"messages": final_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
if disable_thinking and is_v4plus:
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
self._apply_structured_output(payload, structured_output, json_schema)
|
||||
|
||||
data = await self._make_request(url, payload)
|
||||
message = data["choices"][0]["message"]
|
||||
content = message.get("content", "")
|
||||
reasoning = message.get("reasoning_content")
|
||||
if disable_thinking and not is_reasoner:
|
||||
content = self._strip_think_blocks(content)
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {"content": data["choices"][0]["message"]["content"]},
|
||||
"message": {
|
||||
"content": content,
|
||||
**({"reasoning_content": reasoning} if reasoning else {}),
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
@@ -245,27 +411,59 @@ class APIClient:
|
||||
async def _create_openai_completion(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
structured_output: bool = False,
|
||||
json_schema: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create completion using OpenAI API."""
|
||||
json_schema: str | None = None,
|
||||
disable_thinking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create completion using OpenAI API.
|
||||
|
||||
Reasoning models (o-series, gpt-5 family) require a different payload
|
||||
shape: max_completion_tokens instead of max_tokens, no custom
|
||||
temperature, and role "developer" instead of "system". When
|
||||
disable_thinking=True for a reasoning model we set reasoning_effort
|
||||
to "low" to minimize hidden CoT tokens. For classic chat models the
|
||||
Qwen-style /no_think soft switch is appended instead.
|
||||
"""
|
||||
url = f"{self.endpoint}/chat/completions"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
is_reasoning = self._is_openai_reasoning_model(model)
|
||||
|
||||
if is_reasoning:
|
||||
prepared_messages = self._convert_system_to_developer(messages)
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": prepared_messages,
|
||||
"max_completion_tokens": max_tokens,
|
||||
}
|
||||
if disable_thinking:
|
||||
# gpt-5+ supports "minimal" (cheapest, lowest-CoT). o-series
|
||||
# rejects "minimal" and accepts low/medium/high — fall back to "low".
|
||||
effort = "minimal" if model.lower().startswith(("gpt-5", "gpt5")) else "low"
|
||||
payload["reasoning_effort"] = effort
|
||||
else:
|
||||
prepared_messages = (
|
||||
self._apply_no_think_tag(messages) if disable_thinking else messages
|
||||
)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": prepared_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
self._apply_structured_output(payload, structured_output, json_schema)
|
||||
|
||||
data = await self._make_request(url, payload)
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
# Strip <think> blocks only for classic chat models. Reasoning models
|
||||
# never emit the tags in user-facing content.
|
||||
if disable_thinking and not is_reasoning:
|
||||
content = self._strip_think_blocks(content)
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {"content": data["choices"][0]["message"]["content"]},
|
||||
"message": {"content": content},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
@@ -278,12 +476,13 @@ class APIClient:
|
||||
async def _create_anthropic_completion(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
structured_output: bool = False,
|
||||
json_schema: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
json_schema: str | None = None,
|
||||
disable_thinking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create completion using Anthropic API."""
|
||||
url = f"{self.endpoint}/v1/messages"
|
||||
|
||||
@@ -298,35 +497,56 @@ class APIClient:
|
||||
else:
|
||||
filtered_messages.append(msg)
|
||||
|
||||
# For Anthropic, add structured output instruction to system prompt
|
||||
# For Anthropic, add structured output instruction to system prompt.
|
||||
# Validate schema is well-formed JSON before concatenation: untrusted
|
||||
# schema strings (built from templates/webhook data) could otherwise
|
||||
# break out of the JSON fence and rewrite the system instruction.
|
||||
if structured_output and json_schema:
|
||||
schema_instruction = (
|
||||
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
|
||||
f"this JSON Schema:\n{json_schema}\n"
|
||||
f"Do not include any text before or after the JSON. "
|
||||
f"Do not wrap the JSON in markdown code blocks."
|
||||
)
|
||||
if system_prompt:
|
||||
system_prompt += schema_instruction
|
||||
try:
|
||||
json.loads(json_schema)
|
||||
except json.JSONDecodeError as err:
|
||||
_LOGGER.warning(
|
||||
"Anthropic: invalid JSON schema, ignoring structured_output: %s", err
|
||||
)
|
||||
else:
|
||||
system_prompt = schema_instruction.strip()
|
||||
_LOGGER.debug("Anthropic structured output enabled via system prompt")
|
||||
schema_instruction = (
|
||||
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
|
||||
f"this JSON Schema:\n{json_schema}\n"
|
||||
f"Do not include any text before or after the JSON. "
|
||||
f"Do not wrap the JSON in markdown code blocks."
|
||||
)
|
||||
if system_prompt:
|
||||
system_prompt += schema_instruction
|
||||
else:
|
||||
system_prompt = schema_instruction.strip()
|
||||
_LOGGER.debug("Anthropic structured output enabled via system prompt")
|
||||
|
||||
# Anthropic accepts temperature in [0, 1], not [0, 2] like OpenAI.
|
||||
# Clip silently to avoid a 400 when a user-set config exceeds the cap.
|
||||
clipped_temp = min(1.0, max(0.0, float(temperature)))
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": filtered_messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"temperature": clipped_temp,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
payload["system"] = system_prompt
|
||||
|
||||
data = await self._make_request(url, payload)
|
||||
# Anthropic returns an array of content blocks; if extended thinking
|
||||
# is ever enabled the first block may be type="thinking". Find the
|
||||
# first text-type block instead of hardcoding index [0].
|
||||
content = ""
|
||||
for block in data.get("content", []):
|
||||
if block.get("type") == "text":
|
||||
content = block.get("text", "")
|
||||
break
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {"content": data["content"][0]["text"]},
|
||||
"message": {"content": content},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
@@ -339,12 +559,13 @@ class APIClient:
|
||||
async def _create_gemini_completion(
|
||||
self,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
structured_output: bool = False,
|
||||
json_schema: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
json_schema: str | None = None,
|
||||
disable_thinking: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create completion using Gemini API with google-genai library.
|
||||
|
||||
Args:
|
||||
@@ -395,7 +616,6 @@ class APIClient:
|
||||
parsed_schema = None
|
||||
if structured_output and json_schema:
|
||||
try:
|
||||
import json
|
||||
parsed_schema = json.loads(json_schema)
|
||||
_LOGGER.debug("Gemini structured output enabled with schema")
|
||||
except json.JSONDecodeError as e:
|
||||
@@ -418,6 +638,30 @@ class APIClient:
|
||||
config.response_mime_type = "application/json"
|
||||
config.response_schema = parsed_schema
|
||||
|
||||
# Disable thinking. Gemini 3.x+ replaced the numeric
|
||||
# thinking_budget with a semantic thinking_level; Pro
|
||||
# variants do not accept MINIMAL, their floor is LOW.
|
||||
# Gemini 2.5: Flash accepts thinking_budget=0 (fully off),
|
||||
# Pro rejects 0 and requires at least 128 tokens.
|
||||
# 2.0 and earlier ignore the field.
|
||||
if disable_thinking:
|
||||
m_lower = model.lower()
|
||||
try:
|
||||
if re.search(r"gemini-[3-9]", m_lower):
|
||||
level = "LOW" if "pro" in m_lower else "MINIMAL"
|
||||
config.thinking_config = types.ThinkingConfig(
|
||||
thinking_level=level
|
||||
)
|
||||
else:
|
||||
budget = 128 if "2.5-pro" in m_lower else 0
|
||||
config.thinking_config = types.ThinkingConfig(
|
||||
thinking_budget=budget
|
||||
)
|
||||
except (AttributeError, TypeError, ValueError) as err:
|
||||
_LOGGER.debug(
|
||||
"ThinkingConfig not supported by this google-genai version: %s", err
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
config = await asyncio.to_thread(create_config)
|
||||
@@ -491,6 +735,9 @@ class APIClient:
|
||||
|
||||
response_text, usage = await asyncio.to_thread(extract_response)
|
||||
|
||||
if disable_thinking:
|
||||
response_text = self._strip_think_blocks(response_text)
|
||||
|
||||
return {
|
||||
"choices": [{
|
||||
"message": {
|
||||
@@ -502,13 +749,19 @@ class APIClient:
|
||||
|
||||
except ImportError as e:
|
||||
_LOGGER.error("Google Gemini library not installed: %s", e)
|
||||
raise HomeAssistantError("Missing dependency: google-genai. Please install it.")
|
||||
raise HomeAssistantError(
|
||||
"Missing dependency: google-genai. Please install it."
|
||||
) from e
|
||||
except Exception as e:
|
||||
_LOGGER.error("Gemini API error: %s", e)
|
||||
raise HomeAssistantError("Gemini API request failed")
|
||||
raise HomeAssistantError(f"Gemini API request failed: {e}") from e
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Shutdown API client."""
|
||||
"""Shutdown API client and close its dedicated session."""
|
||||
_LOGGER.debug("Shutting down API client")
|
||||
self._closed = True
|
||||
# Do NOT close the shared Home Assistant session
|
||||
# The session is dedicated to this config entry (pinned resolver,
|
||||
# isolated cookie jar), so it must be closed here to release the
|
||||
# connector; nothing else owns it.
|
||||
if self.session is not None and not self.session.closed:
|
||||
await self.session.close()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Config flow for HA text AI integration.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -9,14 +9,13 @@ Config flow for HA text AI integration.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
@@ -56,16 +55,22 @@ from .const import (
|
||||
MAX_HISTORY_SIZE,
|
||||
CONF_ALLOW_LOCAL_NETWORK,
|
||||
DEFAULT_ALLOW_LOCAL_NETWORK,
|
||||
CONF_DISABLE_THINKING,
|
||||
DEFAULT_DISABLE_THINKING,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .utils import normalize_name, safe_log_data, validate_endpoint
|
||||
from .utils import (
|
||||
create_pinned_session,
|
||||
normalize_name,
|
||||
safe_log_data,
|
||||
validate_endpoint,
|
||||
)
|
||||
from .providers import get_default_endpoint, get_default_model, build_auth_headers
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_parameter_schema(data: Dict[str, Any]) -> dict:
|
||||
def _build_parameter_schema(data: dict[str, Any]) -> dict:
|
||||
"""Build shared parameter schema fields used by both ConfigFlow and OptionsFlow."""
|
||||
return {
|
||||
vol.Optional(
|
||||
@@ -92,9 +97,12 @@ def _build_parameter_schema(data: Dict[str, Any]) -> dict:
|
||||
CONF_MAX_HISTORY_SIZE,
|
||||
default=data.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=MIN_HISTORY_SIZE, max=MAX_HISTORY_SIZE)),
|
||||
vol.Optional(
|
||||
CONF_DISABLE_THINKING,
|
||||
default=data.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
|
||||
): bool,
|
||||
}
|
||||
|
||||
|
||||
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for HA text AI."""
|
||||
|
||||
@@ -106,7 +114,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._data = {}
|
||||
self._provider = None
|
||||
|
||||
async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
@@ -125,13 +133,15 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_provider()
|
||||
|
||||
def _build_provider_schema(
|
||||
self, data: Optional[Dict[str, Any]] = None
|
||||
self, data: dict[str, Any] | None = None
|
||||
) -> vol.Schema:
|
||||
"""Build provider configuration schema with optional defaults from data."""
|
||||
defaults = data or {}
|
||||
schema_dict = {
|
||||
vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, DEFAULT_INSTANCE_NAME)): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_API_KEY): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_MODEL, default=defaults.get(CONF_MODEL, get_default_model(self._provider))): str,
|
||||
vol.Required(CONF_API_ENDPOINT, default=defaults.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
|
||||
vol.Optional(
|
||||
@@ -142,7 +152,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
schema_dict.update(_build_parameter_schema(defaults))
|
||||
return vol.Schema(schema_dict)
|
||||
|
||||
async def async_step_provider(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
||||
async def async_step_provider(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""Handle provider configuration step."""
|
||||
self._errors = {}
|
||||
|
||||
@@ -221,7 +231,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return normalized
|
||||
|
||||
async def _async_validate_api(self, user_input: Dict[str, Any]) -> bool:
|
||||
async def _async_validate_api(self, user_input: dict[str, Any]) -> bool:
|
||||
"""Validate API connection using provider registry."""
|
||||
try:
|
||||
if CONF_API_KEY not in user_input:
|
||||
@@ -231,7 +241,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
||||
endpoint = await validate_endpoint(
|
||||
endpoint, resolved_ips = await validate_endpoint(
|
||||
self.hass, user_input[CONF_API_ENDPOINT], allow_local=allow_local
|
||||
)
|
||||
except ValueError as err:
|
||||
@@ -245,28 +255,35 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return False
|
||||
return True
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
headers = build_auth_headers(self._provider, user_input[CONF_API_KEY])
|
||||
|
||||
from .providers import get_provider_config
|
||||
check_path = get_provider_config(self._provider).get("check_path", "/models")
|
||||
check_url = f"{endpoint}{check_path}"
|
||||
|
||||
async with session.get(check_url, headers=headers) as response:
|
||||
if response.status == 401:
|
||||
self._errors["base"] = "invalid_auth"
|
||||
return False
|
||||
elif response.status != 200:
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
return True
|
||||
# Pinned session ensures the reachability check goes to the same
|
||||
# IP that will later be used by api_client (no DNS rebinding).
|
||||
session = create_pinned_session(endpoint, resolved_ips)
|
||||
try:
|
||||
async with session.get(
|
||||
check_url, headers=headers, allow_redirects=False
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
self._errors["base"] = "invalid_auth"
|
||||
return False
|
||||
elif response.status != 200:
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
return True
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error("API validation error: %s", str(err))
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
|
||||
async def _create_entry(self, user_input: Dict[str, Any]) -> ConfigFlowResult:
|
||||
async def _create_entry(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create the config entry with unique_id deduplication."""
|
||||
instance_name = user_input[CONF_NAME]
|
||||
normalized_name = normalize_name(instance_name)
|
||||
@@ -290,6 +307,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES),
|
||||
CONF_MAX_HISTORY_SIZE: user_input.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
|
||||
CONF_ALLOW_LOCAL_NETWORK: user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
|
||||
CONF_DISABLE_THINKING: user_input.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
|
||||
}
|
||||
|
||||
_LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
|
||||
@@ -305,7 +323,6 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow."""
|
||||
|
||||
@@ -317,7 +334,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
return False
|
||||
|
||||
try:
|
||||
endpoint = await validate_endpoint(self.hass, endpoint, allow_local=allow_local)
|
||||
endpoint, resolved_ips = await validate_endpoint(
|
||||
self.hass, endpoint, allow_local=allow_local
|
||||
)
|
||||
except ValueError as err:
|
||||
_LOGGER.error("Endpoint validation failed: %s", err)
|
||||
self._errors["base"] = "cannot_connect"
|
||||
@@ -326,32 +345,37 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
if provider == API_PROVIDER_GEMINI:
|
||||
return True
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
headers = build_auth_headers(provider, api_key)
|
||||
|
||||
from .providers import get_provider_config
|
||||
check_path = get_provider_config(provider).get("check_path", "/models")
|
||||
check_url = f"{endpoint}{check_path}"
|
||||
|
||||
async with session.get(check_url, headers=headers) as response:
|
||||
if response.status == 401:
|
||||
self._errors["base"] = "invalid_auth"
|
||||
return False
|
||||
elif response.status != 200:
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
return True
|
||||
session = create_pinned_session(endpoint, resolved_ips)
|
||||
try:
|
||||
async with session.get(
|
||||
check_url, headers=headers, allow_redirects=False
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
self._errors["base"] = "invalid_auth"
|
||||
return False
|
||||
elif response.status != 200:
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
return True
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.error("API validation error: %s", str(err))
|
||||
self._errors["base"] = "cannot_connect"
|
||||
return False
|
||||
|
||||
async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""Handle provider selection step."""
|
||||
if not hasattr(self, "_errors"):
|
||||
self._errors: dict[str, str] = {}
|
||||
self._selected_provider: Optional[str] = None
|
||||
self._selected_provider: str | None = None
|
||||
current_data = {**self.config_entry.data, **self.config_entry.options}
|
||||
current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI)
|
||||
|
||||
@@ -377,7 +401,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
}
|
||||
)
|
||||
|
||||
async def async_step_settings(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
||||
async def async_step_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""Handle settings configuration step."""
|
||||
self._errors = {}
|
||||
current_data = {**self.config_entry.data, **self.config_entry.options}
|
||||
@@ -398,7 +422,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
api_key = user_input.get(CONF_API_KEY, "").strip()
|
||||
endpoint = user_input.get(CONF_API_ENDPOINT, default_endpoint)
|
||||
|
||||
# Require API key re-entry when endpoint or provider changed
|
||||
# Require API key re-entry when endpoint or provider changed.
|
||||
# Why: reusing a stored key after provider/endpoint change could
|
||||
# ship credentials to a different service (e.g. OpenAI key to
|
||||
# api.anthropic.com). Always force explicit re-entry.
|
||||
stored_endpoint = current_data.get(CONF_API_ENDPOINT, "")
|
||||
endpoint_changed = endpoint != stored_endpoint
|
||||
if not api_key and (provider_changed or endpoint_changed):
|
||||
@@ -418,8 +445,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
}
|
||||
)
|
||||
|
||||
# Fall back to stored key if not re-entered and endpoint unchanged
|
||||
if not api_key:
|
||||
# Fall back to stored key only when neither provider nor endpoint changed.
|
||||
# Defensive: never silently reuse stored key across providers.
|
||||
if not api_key and not provider_changed and not endpoint_changed:
|
||||
api_key = current_data.get(CONF_API_KEY, "")
|
||||
|
||||
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
||||
@@ -464,8 +492,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
def _get_settings_schema(
|
||||
self,
|
||||
provider: str,
|
||||
current_data: Dict[str, Any],
|
||||
user_input: Optional[Dict[str, Any]],
|
||||
current_data: dict[str, Any],
|
||||
user_input: dict[str, Any] | None,
|
||||
default_endpoint: str,
|
||||
default_model: str,
|
||||
) -> vol.Schema:
|
||||
@@ -473,7 +501,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
data = user_input or current_data
|
||||
|
||||
schema_dict = {
|
||||
vol.Optional(CONF_API_KEY, default=""): str,
|
||||
vol.Optional(CONF_API_KEY, default=""): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_API_ENDPOINT,
|
||||
default=data.get(CONF_API_ENDPOINT, default_endpoint),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Constants for the HA text AI integration.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -29,7 +29,7 @@ API_PROVIDERS: Final = [
|
||||
API_PROVIDER_GEMINI
|
||||
]
|
||||
|
||||
VERSION: Final = "2.4.1"
|
||||
VERSION: Final = "2.5.1"
|
||||
|
||||
# Default endpoints
|
||||
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
|
||||
@@ -50,6 +50,7 @@ CONF_CONTEXT_MESSAGES: Final = "context_messages"
|
||||
CONF_STRUCTURED_OUTPUT: Final = "structured_output"
|
||||
CONF_JSON_SCHEMA: Final = "json_schema"
|
||||
CONF_ALLOW_LOCAL_NETWORK: Final = "allow_local_network"
|
||||
CONF_DISABLE_THINKING: Final = "disable_thinking"
|
||||
|
||||
ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100)
|
||||
MAX_ATTRIBUTE_SIZE = 4 * 1024
|
||||
@@ -57,8 +58,11 @@ MAX_HISTORY_FILE_SIZE = 1 * 1024 * 1024
|
||||
# Default values
|
||||
DEFAULT_MODEL: Final = "gpt-4o-mini"
|
||||
DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
|
||||
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat"
|
||||
DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash"
|
||||
# deepseek-chat/deepseek-reasoner are discontinued 2026-07-24; V4 models
|
||||
# select thinking mode via a request parameter instead of the model name.
|
||||
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-v4-flash"
|
||||
# gemini-2.0-flash was shut down 2026-06-01; 2.5-flash follows 2026-10-16.
|
||||
DEFAULT_GEMINI_MODEL: Final = "gemini-3.5-flash"
|
||||
DEFAULT_TEMPERATURE: Final = 0.1
|
||||
DEFAULT_MAX_TOKENS: Final = 1000
|
||||
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
||||
@@ -69,6 +73,7 @@ DEFAULT_NAME_PREFIX = "ha_text_ai"
|
||||
DEFAULT_INSTANCE_NAME: Final = "my_assistant"
|
||||
DEFAULT_CONTEXT_MESSAGES: Final = 5
|
||||
DEFAULT_ALLOW_LOCAL_NETWORK: Final = False
|
||||
DEFAULT_DISABLE_THINKING: Final = False
|
||||
MIN_CONTEXT_MESSAGES: Final = 1
|
||||
MAX_CONTEXT_MESSAGES: Final = 20
|
||||
MIN_HISTORY_SIZE: Final = 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The HA Text AI coordinator.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -12,7 +12,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -23,6 +23,7 @@ from homeassistant.util import dt as dt_util
|
||||
from .const import (
|
||||
DEFAULT_API_TIMEOUT,
|
||||
DEFAULT_CONTEXT_MESSAGES,
|
||||
DEFAULT_DISABLE_THINKING,
|
||||
DEFAULT_MAX_HISTORY,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_TEMPERATURE,
|
||||
@@ -39,7 +40,6 @@ from .utils import normalize_name
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HATextAICoordinator(DataUpdateCoordinator):
|
||||
"""Home Assistant Text AI Conversation Coordinator."""
|
||||
|
||||
@@ -56,6 +56,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
max_history_size: int = DEFAULT_MAX_HISTORY,
|
||||
context_messages: int = DEFAULT_CONTEXT_MESSAGES,
|
||||
api_timeout: int = DEFAULT_API_TIMEOUT,
|
||||
disable_thinking: bool = DEFAULT_DISABLE_THINKING,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
self.instance_name = instance_name
|
||||
@@ -89,6 +90,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
self.temperature = temperature
|
||||
self.max_tokens = max_tokens
|
||||
self.api_timeout = api_timeout
|
||||
self.disable_thinking = disable_thinking
|
||||
|
||||
# Concurrency control
|
||||
self._request_lock = asyncio.Lock()
|
||||
@@ -98,9 +100,9 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
self._is_rate_limited = False
|
||||
self._is_maintenance = False
|
||||
self.endpoint_status = "ready"
|
||||
self._system_prompt: Optional[str] = None
|
||||
self._system_prompt: str | None = None
|
||||
|
||||
self._last_response: Dict[str, Any] = {
|
||||
self._last_response: dict[str, Any] = {
|
||||
"timestamp": dt_util.utcnow().isoformat(),
|
||||
"question": "",
|
||||
"response": "",
|
||||
@@ -129,7 +131,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
# Convenience accessors for backward compatibility
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def _conversation_history(self) -> List[Dict[str, Any]]:
|
||||
def _conversation_history(self) -> list[dict[str, Any]]:
|
||||
return self._history.conversation_history
|
||||
|
||||
@property
|
||||
@@ -152,12 +154,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
# Last response
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def last_response(self) -> Dict[str, Any]:
|
||||
def last_response(self) -> dict[str, Any]:
|
||||
"""Get the last response."""
|
||||
return self._last_response
|
||||
|
||||
@last_response.setter
|
||||
def last_response(self, value: Dict[str, Any]) -> None:
|
||||
def last_response(self, value: dict[str, Any]) -> None:
|
||||
self._last_response = value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -170,7 +172,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error updating HA state for %s: %s", self.instance_name, err)
|
||||
|
||||
async def _async_update_data(self) -> Dict[str, Any]:
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update coordinator data."""
|
||||
try:
|
||||
current_state = self._get_current_state()
|
||||
@@ -206,13 +208,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
async def async_ask_question(
|
||||
self,
|
||||
question: str,
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
context_messages: Optional[int] = None,
|
||||
model: str | None = None,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
system_prompt: str | None = None,
|
||||
context_messages: int | None = None,
|
||||
structured_output: bool = False,
|
||||
json_schema: Optional[str] = None,
|
||||
json_schema: str | None = None,
|
||||
disable_thinking: bool | None = None,
|
||||
) -> dict:
|
||||
"""Process question with context management."""
|
||||
if self.client is None:
|
||||
@@ -228,6 +231,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
temp_temperature = temperature if temperature is not None else self.temperature
|
||||
temp_max_tokens = max_tokens if max_tokens is not None else self.max_tokens
|
||||
temp_system_prompt = system_prompt if system_prompt is not None else self._system_prompt
|
||||
temp_disable_thinking = disable_thinking if disable_thinking is not None else self.disable_thinking
|
||||
|
||||
start_time = dt_util.utcnow()
|
||||
|
||||
@@ -250,6 +254,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
max_tokens=temp_max_tokens,
|
||||
structured_output=structured_output,
|
||||
json_schema=json_schema,
|
||||
disable_thinking=temp_disable_thinking,
|
||||
)
|
||||
|
||||
latency = (dt_util.utcnow() - start_time).total_seconds()
|
||||
@@ -263,7 +268,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
if error_details.get("is_connection_error"):
|
||||
self.endpoint_status = "unavailable"
|
||||
self.last_response = error_details
|
||||
raise HomeAssistantError(f"Failed to process question: {err}")
|
||||
raise HomeAssistantError(f"Failed to process question: {err}") from err
|
||||
|
||||
finally:
|
||||
self._is_processing = False
|
||||
@@ -273,11 +278,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
self,
|
||||
question: str,
|
||||
model: str,
|
||||
messages: List[Dict[str, str]],
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
structured_output: bool = False,
|
||||
json_schema: Optional[str] = None,
|
||||
json_schema: str | None = None,
|
||||
disable_thinking: bool = False,
|
||||
) -> dict:
|
||||
"""Send request to AI provider and return structured response.
|
||||
|
||||
@@ -292,6 +298,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
max_tokens=max_tokens,
|
||||
structured_output=structured_output,
|
||||
json_schema=json_schema,
|
||||
disable_thinking=disable_thinking,
|
||||
)
|
||||
|
||||
# Reset error state on success
|
||||
@@ -340,12 +347,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def async_get_history(
|
||||
self,
|
||||
limit: Optional[int] = None,
|
||||
filter_model: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
limit: int | None = None,
|
||||
filter_model: str | None = None,
|
||||
start_date: str | None = None,
|
||||
include_metadata: bool = False,
|
||||
sort_order: str = "newest",
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get conversation history with optional filtering."""
|
||||
return await self._history.async_get_history(
|
||||
limit=limit,
|
||||
@@ -375,7 +382,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
return STATE_ERROR
|
||||
return STATE_READY
|
||||
|
||||
def _get_safe_initial_state(self) -> Dict[str, Any]:
|
||||
def _get_safe_initial_state(self) -> dict[str, Any]:
|
||||
return {
|
||||
"state": STATE_ERROR,
|
||||
"metrics": {},
|
||||
@@ -395,7 +402,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
"normalized_name": self.normalized_name,
|
||||
}
|
||||
|
||||
def _get_sanitized_last_response(self) -> Dict[str, Any]:
|
||||
def _get_sanitized_last_response(self) -> dict[str, Any]:
|
||||
"""Get sanitized version of last response with truncation."""
|
||||
response = self.last_response.copy()
|
||||
|
||||
@@ -414,7 +421,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
def _calculate_uptime(self) -> float:
|
||||
return (dt_util.utcnow() - self._start_time).total_seconds()
|
||||
|
||||
def _get_truncated_system_prompt(self) -> Optional[str]:
|
||||
def _get_truncated_system_prompt(self) -> str | None:
|
||||
if not self._system_prompt:
|
||||
return None
|
||||
if len(self._system_prompt) <= 4096:
|
||||
@@ -422,7 +429,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
||||
return self._system_prompt[:4096] + TRUNCATION_INDICATOR
|
||||
|
||||
@staticmethod
|
||||
def _validate_update_data(data: Dict[str, Any]) -> None:
|
||||
def _validate_update_data(data: dict[str, Any]) -> None:
|
||||
for key in ("state", "metrics", "last_response"):
|
||||
if key not in data:
|
||||
raise ValueError(f"Missing required key: {key}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
History management for HA Text AI integration.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -14,7 +14,7 @@ import os
|
||||
import shutil
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
|
||||
@@ -34,7 +34,6 @@ MAX_ARCHIVE_FILES = 3
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncFileHandler:
|
||||
"""Async context manager for file operations."""
|
||||
|
||||
@@ -49,6 +48,16 @@ class AsyncFileHandler:
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.file.close()
|
||||
|
||||
def _assert_not_symlink(path: str) -> None:
|
||||
"""Refuse to operate on a path that resolves to a symlink.
|
||||
|
||||
Why: another component or an attacker with filesystem access could
|
||||
replace our history file with a symlink pointing at arbitrary disk
|
||||
locations. Then os.remove or shutil.move would hit the target
|
||||
instead of our managed file. Check before destructive ops.
|
||||
"""
|
||||
if os.path.islink(path):
|
||||
raise OSError(f"Refusing to operate on symlink: {path}")
|
||||
|
||||
class HistoryManager:
|
||||
"""Manages conversation history for an instance."""
|
||||
@@ -72,10 +81,10 @@ class HistoryManager:
|
||||
history_dir, f"{normalized_name}_history.json"
|
||||
)
|
||||
self._max_history_file_size = MAX_HISTORY_FILE_SIZE
|
||||
self._conversation_history: List[Dict[str, Any]] = []
|
||||
self._conversation_history: list[dict[str, Any]] = []
|
||||
|
||||
@property
|
||||
def conversation_history(self) -> List[Dict[str, Any]]:
|
||||
def conversation_history(self) -> list[dict[str, Any]]:
|
||||
return self._conversation_history
|
||||
|
||||
@property
|
||||
@@ -242,6 +251,9 @@ class HistoryManager:
|
||||
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json",
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
_assert_not_symlink, self._history_file
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
shutil.move, self._history_file, archive_file
|
||||
)
|
||||
@@ -280,6 +292,9 @@ class HistoryManager:
|
||||
archives = await self.hass.async_add_executor_job(find_archives)
|
||||
if len(archives) > MAX_ARCHIVE_FILES:
|
||||
for old_file in archives[:-MAX_ARCHIVE_FILES]:
|
||||
await self.hass.async_add_executor_job(
|
||||
_assert_not_symlink, old_file
|
||||
)
|
||||
await self.hass.async_add_executor_job(os.remove, old_file)
|
||||
_LOGGER.debug("Removed old archive: %s", old_file)
|
||||
except Exception as e:
|
||||
@@ -359,6 +374,9 @@ class HistoryManager:
|
||||
try:
|
||||
self._conversation_history = []
|
||||
if await self._file_exists(self._history_file):
|
||||
await self.hass.async_add_executor_job(
|
||||
_assert_not_symlink, self._history_file
|
||||
)
|
||||
await self.hass.async_add_executor_job(os.remove, self._history_file)
|
||||
_LOGGER.info("History for %s cleared", self.instance_name)
|
||||
except Exception as e:
|
||||
@@ -367,13 +385,13 @@ class HistoryManager:
|
||||
|
||||
async def async_get_history(
|
||||
self,
|
||||
limit: Optional[int] = None,
|
||||
filter_model: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
limit: int | None = None,
|
||||
filter_model: str | None = None,
|
||||
start_date: str | None = None,
|
||||
include_metadata: bool = False,
|
||||
sort_order: str = "newest",
|
||||
default_model: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get conversation history with optional filtering and sorting."""
|
||||
try:
|
||||
history = self._conversation_history.copy()
|
||||
@@ -404,8 +422,11 @@ class HistoryManager:
|
||||
else:
|
||||
history.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||
|
||||
# Clamp limit to ABSOLUTE_MAX_HISTORY_SIZE to prevent pathological
|
||||
# caller requests from producing multi-MB service payloads.
|
||||
if limit and limit > 0:
|
||||
history = history[:limit]
|
||||
effective_limit = min(int(limit), ABSOLUTE_MAX_HISTORY_SIZE)
|
||||
history = history[:effective_limit]
|
||||
|
||||
if include_metadata:
|
||||
enriched = []
|
||||
@@ -426,7 +447,7 @@ class HistoryManager:
|
||||
_LOGGER.error("Error getting history: %s", e)
|
||||
return []
|
||||
|
||||
def get_limited_history(self, max_display: int = 5) -> Dict[str, Any]:
|
||||
def get_limited_history(self, max_display: int = 5) -> dict[str, Any]:
|
||||
"""Get limited conversation history for sensor attributes.
|
||||
|
||||
Returns last `max_display` entries with truncated text for HA state.
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
||||
"loggers": ["custom_components.ha_text_ai"],
|
||||
"requirements": [
|
||||
"aiofiles>=23.0.0",
|
||||
"google-genai>=1.16.0"
|
||||
"aiofiles>=23.0.0,<25.0.0",
|
||||
"google-genai>=1.16.0,<2.0.0"
|
||||
],
|
||||
"single_config_entry": false,
|
||||
"version": "2.4.1"
|
||||
"version": "2.5.1"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Metrics management for HA Text AI integration.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -21,7 +21,7 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_METRICS: Dict[str, Any] = {
|
||||
DEFAULT_METRICS: dict[str, Any] = {
|
||||
"total_tokens": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
@@ -33,7 +33,6 @@ DEFAULT_METRICS: Dict[str, Any] = {
|
||||
"min_latency": 0,
|
||||
}
|
||||
|
||||
|
||||
class MetricsManager:
|
||||
"""Manages performance metrics for an instance."""
|
||||
|
||||
@@ -46,10 +45,10 @@ class MetricsManager:
|
||||
self.hass = hass
|
||||
self.instance_name = instance_name
|
||||
self._metrics_file = metrics_file
|
||||
self._performance_metrics: Dict[str, Any] = DEFAULT_METRICS.copy()
|
||||
self._performance_metrics: dict[str, Any] = DEFAULT_METRICS.copy()
|
||||
|
||||
@property
|
||||
def metrics(self) -> Dict[str, Any]:
|
||||
def metrics(self) -> dict[str, Any]:
|
||||
return self._performance_metrics
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
@@ -57,7 +56,7 @@ class MetricsManager:
|
||||
loaded = await self._load_metrics()
|
||||
self._performance_metrics = loaded or DEFAULT_METRICS.copy()
|
||||
|
||||
async def _load_metrics(self) -> Dict[str, Any] | None:
|
||||
async def _load_metrics(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
exists = await self.hass.async_add_executor_job(
|
||||
os.path.exists, self._metrics_file
|
||||
@@ -108,7 +107,7 @@ class MetricsManager:
|
||||
|
||||
await self._save_metrics()
|
||||
|
||||
async def get_current_metrics(self) -> Dict[str, Any]:
|
||||
async def get_current_metrics(self) -> dict[str, Any]:
|
||||
"""Get current performance metrics."""
|
||||
return self._performance_metrics.copy()
|
||||
|
||||
@@ -116,24 +115,37 @@ class MetricsManager:
|
||||
self,
|
||||
error: Exception,
|
||||
model: str,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Record an error in metrics and return error details."""
|
||||
self._performance_metrics["total_errors"] += 1
|
||||
self._performance_metrics["failed_requests"] += 1
|
||||
await self._save_metrics()
|
||||
|
||||
error_msg = str(error)
|
||||
# Strip URLs, API keys, tokens, and query parameters from error messages
|
||||
# Strip URLs, API keys, tokens, and query parameters from error messages.
|
||||
# Patterns use word boundaries and explicit length bounds so that
|
||||
# overly greedy matches don't accidentally swallow adjacent text.
|
||||
error_msg = re.sub(r'https?://\S+', '[URL]', error_msg)
|
||||
error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg)
|
||||
error_msg = re.sub(r'AIza[A-Za-z0-9_-]+', '***', error_msg)
|
||||
error_msg = re.sub(r'Bearer\s+\S+', 'Bearer ***', error_msg)
|
||||
error_msg = re.sub(r'sk-[A-Za-z0-9_-]{20,}', '***', error_msg)
|
||||
error_msg = re.sub(r'x-api-key:\s*\S+', 'x-api-key: ***', error_msg, flags=re.IGNORECASE)
|
||||
# Google API key: fixed prefix + 30+ url-safe chars, bounded by non-key char.
|
||||
error_msg = re.sub(
|
||||
r'AIza[A-Za-z0-9_\-]{30,}(?=[^A-Za-z0-9_\-]|$)', '***', error_msg
|
||||
)
|
||||
# Anthropic / OpenAI / DeepSeek format: "sk-..." (anchors on word boundary).
|
||||
error_msg = re.sub(r'\bsk-[A-Za-z0-9_\-]{20,}\b', '***', error_msg)
|
||||
# Bearer tokens: header-style and JSON-embedded ("Bearer xxx").
|
||||
error_msg = re.sub(r'[Bb]earer\s+[A-Za-z0-9_\-\.=]+', 'Bearer ***', error_msg)
|
||||
# x-api-key header in any case, both raw and JSON-serialized forms.
|
||||
error_msg = re.sub(
|
||||
r'"?x-api-key"?\s*[:=]\s*"?[A-Za-z0-9_\-\.]+"?',
|
||||
'x-api-key: ***',
|
||||
error_msg,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if len(error_msg) > 256:
|
||||
error_msg = error_msg[:256] + "..."
|
||||
|
||||
error_details: Dict[str, Any] = {
|
||||
error_details: dict[str, Any] = {
|
||||
"timestamp": dt_util.utcnow().isoformat(),
|
||||
"model": model,
|
||||
"instance": self.instance_name,
|
||||
|
||||
@@ -4,7 +4,7 @@ Provider registry for HA Text AI integration.
|
||||
Centralizes provider-specific configuration to avoid dispatch duplication
|
||||
across __init__.py, config_flow.py, and api_client.py.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -62,7 +62,6 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_provider_config(provider: str) -> dict[str, Any]:
|
||||
"""Get full provider configuration.
|
||||
|
||||
@@ -73,17 +72,14 @@ def get_provider_config(provider: str) -> dict[str, Any]:
|
||||
raise ValueError(f"Unknown API provider: {provider}")
|
||||
return PROVIDER_REGISTRY[provider]
|
||||
|
||||
|
||||
def get_default_endpoint(provider: str) -> str:
|
||||
"""Get default API endpoint for a provider."""
|
||||
return get_provider_config(provider)["default_endpoint"]
|
||||
|
||||
|
||||
def get_default_model(provider: str) -> str:
|
||||
"""Get default model for a provider."""
|
||||
return get_provider_config(provider)["default_model"]
|
||||
|
||||
|
||||
def build_auth_headers(provider: str, api_key: str) -> dict[str, str]:
|
||||
"""Build authentication headers for a provider."""
|
||||
config = get_provider_config(provider)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Sensor platform for HA Text AI.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -10,14 +10,14 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -79,7 +79,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_ATTR_TEXT_LIMIT = 2048
|
||||
_ATTR_PROMPT_LIMIT = 512
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
@@ -161,6 +160,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
manufacturer="Community",
|
||||
model=f"{model} ({api_provider} provider)",
|
||||
sw_version=VERSION,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
@@ -184,7 +184,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
return None
|
||||
return value
|
||||
|
||||
def _sanitize_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _sanitize_attributes(self, attributes: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Sanitize all attributes for JSON serialization."""
|
||||
sanitized = {
|
||||
key: self._sanitize_value(value)
|
||||
@@ -230,7 +230,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
||||
return ENTITY_ICON
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return entity specific state attributes."""
|
||||
if not self.coordinator.data:
|
||||
return {}
|
||||
|
||||
@@ -92,6 +92,15 @@ ask_question:
|
||||
text:
|
||||
multiline: true
|
||||
|
||||
disable_thinking:
|
||||
name: Disable Thinking
|
||||
description: >-
|
||||
Disable model thinking/reasoning for this request.
|
||||
Overrides the integration-level setting when provided.
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
clear_history:
|
||||
name: Clear History
|
||||
description: >-
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of context messages to retain (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)",
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of context messages to retain (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)",
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of previous messages to include in context (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)",
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "JSON Schema",
|
||||
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Disable Thinking",
|
||||
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
||||
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
|
||||
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
|
||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
|
||||
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
||||
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
|
||||
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
|
||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
|
||||
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -44,6 +46,7 @@
|
||||
"name_exists": "Eine Instanz mit diesem Namen existiert bereits",
|
||||
"invalid_name": "Ungültiger Instanzname",
|
||||
"invalid_auth": "Authentifizierung fehlgeschlagen - überprüfen Sie Ihren API-Schlüssel",
|
||||
"api_key_required": "API-Schlüssel ist erforderlich, wenn Anbieter oder Endpunkt geändert wird",
|
||||
"invalid_api_key": "Ungültiger API-Schlüssel - bitte überprüfen Sie Ihre Anmeldeinformationen",
|
||||
"cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen",
|
||||
"invalid_model": "Ausgewähltes Modell ist nicht verfügbar",
|
||||
@@ -57,7 +60,6 @@
|
||||
"invalid_instance": "Ungültige Instanz angegeben",
|
||||
"unknown": "Unerwarteter Fehler aufgetreten",
|
||||
"empty": "Name darf nicht leer sein",
|
||||
"invalid_characters": "Name darf nur Buchstaben, Zahlen, Leerzeichen, Unterstriche und Bindestriche enthalten",
|
||||
"name_too_long": "Name darf höchstens 50 Zeichen lang sein"
|
||||
},
|
||||
"abort": {
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
||||
"context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)",
|
||||
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
|
||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
|
||||
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "JSON-Schema",
|
||||
"description": "JSON-Schema, das die Struktur der erwarteten Antwort definiert. Erforderlich wenn structured_output aktiviert ist."
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Thinking deaktivieren",
|
||||
"description": "Thinking/Reasoning-Modus für diese Anfrage deaktivieren. Überschreibt die Integrationseinstellung."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,8 +218,7 @@
|
||||
"rate_limited": "Rate limitiert",
|
||||
"maintenance": "Wartung",
|
||||
"initializing": "Initialisierung",
|
||||
"retrying": "Wiederholen",
|
||||
"queued": "In der Warteschlange"
|
||||
"retrying": "Wiederholen"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
@@ -304,6 +310,21 @@
|
||||
},
|
||||
"min_latency": {
|
||||
"name": "Minimale Latenz"
|
||||
},
|
||||
"last_model": {
|
||||
"name": "Zuletzt verwendetes Modell"
|
||||
},
|
||||
"last_timestamp": {
|
||||
"name": "Zeitpunkt der letzten Antwort"
|
||||
},
|
||||
"instance_name": {
|
||||
"name": "Instanzname"
|
||||
},
|
||||
"normalized_name": {
|
||||
"name": "Normalisierter Name"
|
||||
},
|
||||
"conversation_history": {
|
||||
"name": "Konversationsverlauf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of context messages to retain (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)",
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of context messages to retain (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)",
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "API request timeout in seconds (5-600)",
|
||||
"context_messages": "Number of previous messages to include in context (1-20)",
|
||||
"max_history_size": "Maximum conversation history size (1-100)",
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "JSON Schema",
|
||||
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Disable Thinking",
|
||||
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
||||
"context_messages": "Número de mensajes de contexto a retener (1-20)",
|
||||
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
|
||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
|
||||
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
||||
"context_messages": "Número de mensajes de contexto a retener (1-20)",
|
||||
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
|
||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
|
||||
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -44,6 +46,7 @@
|
||||
"name_exists": "Ya existe una instancia con este nombre",
|
||||
"invalid_name": "Nombre de instancia no válido",
|
||||
"invalid_auth": "La autenticación falló - verifica tu clave API",
|
||||
"api_key_required": "Se requiere la clave API al cambiar de proveedor o endpoint",
|
||||
"invalid_api_key": "Clave API no válida - verifica tus credenciales",
|
||||
"cannot_connect": "Error al conectar con el servicio de API",
|
||||
"invalid_model": "El modelo seleccionado no está disponible",
|
||||
@@ -57,7 +60,6 @@
|
||||
"invalid_instance": "Instancia no válida especificada",
|
||||
"unknown": "Ocurrió un error inesperado",
|
||||
"empty": "El nombre no puede estar vacío",
|
||||
"invalid_characters": "El nombre solo puede contener letras, números, espacios, guiones bajos y guiones",
|
||||
"name_too_long": "El nombre debe tener 50 caracteres o menos"
|
||||
},
|
||||
"abort": {
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
||||
"context_messages": "Número de mensajes anteriores a incluir en el contexto (1-20)",
|
||||
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
|
||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
|
||||
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "Esquema JSON",
|
||||
"description": "Esquema JSON que define la estructura de la respuesta esperada. Requerido cuando structured_output está habilitado."
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Desactivar Thinking",
|
||||
"description": "Desactivar el modo thinking/reasoning para esta solicitud. Anula la configuración de la integración."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,8 +218,7 @@
|
||||
"rate_limited": "Limitado por tasa",
|
||||
"maintenance": "Mantenimiento",
|
||||
"initializing": "Inicializando",
|
||||
"retrying": "Reintentando",
|
||||
"queued": "En cola"
|
||||
"retrying": "Reintentando"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
@@ -304,6 +310,21 @@
|
||||
},
|
||||
"min_latency": {
|
||||
"name": "Latencia Mínima"
|
||||
},
|
||||
"last_model": {
|
||||
"name": "Último modelo utilizado"
|
||||
},
|
||||
"last_timestamp": {
|
||||
"name": "Hora de la última respuesta"
|
||||
},
|
||||
"instance_name": {
|
||||
"name": "Nombre de instancia"
|
||||
},
|
||||
"normalized_name": {
|
||||
"name": "Nombre normalizado"
|
||||
},
|
||||
"conversation_history": {
|
||||
"name": "Historial de conversación"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
||||
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
|
||||
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
|
||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
|
||||
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
||||
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
|
||||
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
|
||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
|
||||
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -44,6 +46,7 @@
|
||||
"name_exists": "इस नाम के साथ एक उदाहरण पहले से मौजूद है",
|
||||
"invalid_name": "अमान्य उदाहरण नाम",
|
||||
"invalid_auth": "प्रमाणीकरण विफल - अपनी एपीआई कुंजी की जांच करें",
|
||||
"api_key_required": "प्रदाता या endpoint बदलते समय API कुंजी आवश्यक है",
|
||||
"invalid_api_key": "अमान्य एपीआई कुंजी - कृपया अपनी क्रेडेंशियल्स की पुष्टि करें",
|
||||
"cannot_connect": "एपीआई सेवा से कनेक्ट करने में विफल",
|
||||
"invalid_model": "चुना हुआ मॉडल उपलब्ध नहीं है",
|
||||
@@ -57,7 +60,6 @@
|
||||
"invalid_instance": "अमान्य उदाहरण निर्दिष्ट किया गया",
|
||||
"unknown": "अप्रत्याशित त्रुटि हुई",
|
||||
"empty": "नाम खाली नहीं हो सकता",
|
||||
"invalid_characters": "नाम में केवल अक्षर, अंक, रिक्त स्थान, अंडरस्कोर और हाइफन हो सकते हैं",
|
||||
"name_too_long": "नाम 50 अक्षरों या उससे कम होना चाहिए"
|
||||
},
|
||||
"abort": {
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
||||
"context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)",
|
||||
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
|
||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
|
||||
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "JSON स्कीमा",
|
||||
"description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।"
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Thinking बंद करें",
|
||||
"description": "इस अनुरोध के लिए thinking/reasoning मोड बंद करें। एकीकरण सेटिंग को ओवरराइड करता है।"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,8 +218,7 @@
|
||||
"rate_limited": "रेट सीमित",
|
||||
"maintenance": "रखरखाव",
|
||||
"initializing": "प्रारंभिककरण",
|
||||
"retrying": "पुनः प्रयास कर रहा है",
|
||||
"queued": "क्यू में"
|
||||
"retrying": "पुनः प्रयास कर रहा है"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
@@ -304,9 +310,24 @@
|
||||
},
|
||||
"min_latency": {
|
||||
"name": "न्यूनतम विलंबता"
|
||||
},
|
||||
"last_model": {
|
||||
"name": "अंतिम उपयोग किया गया मॉडल"
|
||||
},
|
||||
"last_timestamp": {
|
||||
"name": "अंतिम प्रतिक्रिया समय"
|
||||
},
|
||||
"instance_name": {
|
||||
"name": "इंस्टेंस नाम"
|
||||
},
|
||||
"normalized_name": {
|
||||
"name": "सामान्यीकृत नाम"
|
||||
},
|
||||
"conversation_history": {
|
||||
"name": "वार्तालाप इतिहास"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
||||
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
|
||||
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
|
||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
|
||||
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
||||
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
|
||||
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
|
||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
|
||||
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -44,6 +46,7 @@
|
||||
"name_exists": "Esiste già un'istanza con questo nome",
|
||||
"invalid_name": "Nome dell'istanza non valido",
|
||||
"invalid_auth": "Autenticazione fallita - controlla la tua chiave API",
|
||||
"api_key_required": "La chiave API è obbligatoria quando si cambia provider o endpoint",
|
||||
"invalid_api_key": "Chiave API non valida - verifica le tue credenziali",
|
||||
"cannot_connect": "Impossibile connettersi al servizio API",
|
||||
"invalid_model": "Il modello selezionato non è disponibile",
|
||||
@@ -57,7 +60,6 @@
|
||||
"invalid_instance": "Istanze specificata non valida",
|
||||
"unknown": "Si è verificato un errore imprevisto",
|
||||
"empty": "Il nome non può essere vuoto",
|
||||
"invalid_characters": "Il nome può contenere solo lettere, numeri, spazi, trattini bassi e trattini",
|
||||
"name_too_long": "Il nome deve essere lungo 50 caratteri o meno"
|
||||
},
|
||||
"abort": {
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
||||
"context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)",
|
||||
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
|
||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
|
||||
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "Schema JSON",
|
||||
"description": "Schema JSON che definisce la struttura della risposta attesa. Richiesto quando structured_output è abilitato."
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Disabilita Thinking",
|
||||
"description": "Disabilita la modalità thinking/reasoning per questa richiesta. Sovrascrive l'impostazione dell'integrazione."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,8 +218,7 @@
|
||||
"rate_limited": "Limite di frequenza",
|
||||
"maintenance": "Manutenzione",
|
||||
"initializing": "Inizializzazione",
|
||||
"retrying": "Riprova",
|
||||
"queued": "In coda"
|
||||
"retrying": "Riprova"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
@@ -304,6 +310,21 @@
|
||||
},
|
||||
"min_latency": {
|
||||
"name": "Latenza minima"
|
||||
},
|
||||
"last_model": {
|
||||
"name": "Ultimo modello utilizzato"
|
||||
},
|
||||
"last_timestamp": {
|
||||
"name": "Ora dell'ultima risposta"
|
||||
},
|
||||
"instance_name": {
|
||||
"name": "Nome istanza"
|
||||
},
|
||||
"normalized_name": {
|
||||
"name": "Nome normalizzato"
|
||||
},
|
||||
"conversation_history": {
|
||||
"name": "Cronologia conversazione"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
||||
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
|
||||
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
|
||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
|
||||
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
||||
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
|
||||
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
|
||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
|
||||
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
||||
"context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)",
|
||||
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
|
||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
|
||||
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "JSON Schema",
|
||||
"description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output."
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Отключить thinking",
|
||||
"description": "Отключить режим thinking/reasoning для этого запроса. Переопределяет настройку интеграции."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
||||
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
|
||||
"max_history_size": "Максимална величина историје разговора (1-100)",
|
||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
|
||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
|
||||
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
||||
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
|
||||
"max_history_size": "Максимална величина историје разговора (1-100)",
|
||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
|
||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
|
||||
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -44,6 +46,7 @@
|
||||
"name_exists": "Инстанца са овим именом већ постоји",
|
||||
"invalid_name": "Неважеће име инстанце",
|
||||
"invalid_auth": "Аутентификација није успела - проверите ваш API кључ",
|
||||
"api_key_required": "API кључ је обавезан при промени провајдера или endpoint-а",
|
||||
"invalid_api_key": "Неважећи API кључ - молимо проверите ваше акредитиве",
|
||||
"cannot_connect": "Неуспело повезивање са API сервисом",
|
||||
"invalid_model": "Изабрани модел није доступан",
|
||||
@@ -57,7 +60,6 @@
|
||||
"invalid_instance": "Неважећа инстанца је назначена",
|
||||
"unknown": "Дошло је до неочекиване грешке",
|
||||
"empty": "Име не може бити празно",
|
||||
"invalid_characters": "Име може садржавати само слова, цифре, размаке, подцрта и цртице",
|
||||
"name_too_long": "Име мора бити 50 знакова или мање"
|
||||
},
|
||||
"abort": {
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
||||
"context_messages": "Број претходних порука које треба укључити у контекст (1-20)",
|
||||
"max_history_size": "Максимална величина историје разговора (1-100)",
|
||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
|
||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
|
||||
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "JSON шема",
|
||||
"description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен."
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "Онемогући thinking",
|
||||
"description": "Онемогући thinking/reasoning режим за овај захтев. Надмашује поставку интеграције."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,8 +218,7 @@
|
||||
"rate_limited": "Ограничење захтева",
|
||||
"maintenance": "Одржавање",
|
||||
"initializing": "Инициализује се",
|
||||
"retrying": "Покушава поново",
|
||||
"queued": "У реду"
|
||||
"retrying": "Покушава поново"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
@@ -304,9 +310,24 @@
|
||||
},
|
||||
"min_latency": {
|
||||
"name": "Минимална латенција"
|
||||
},
|
||||
"last_model": {
|
||||
"name": "Последњи коришћени модел"
|
||||
},
|
||||
"last_timestamp": {
|
||||
"name": "Време последњег одговора"
|
||||
},
|
||||
"instance_name": {
|
||||
"name": "Назив инстанце"
|
||||
},
|
||||
"normalized_name": {
|
||||
"name": "Нормализовани назив"
|
||||
},
|
||||
"conversation_history": {
|
||||
"name": "Историја разговора"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"api_timeout": "API请求超时时间(5-600秒)",
|
||||
"context_messages": "保留的上下文消息数量(1-20)",
|
||||
"max_history_size": "最大对话历史大小(1-100)",
|
||||
"allow_local_network": "允许本地网络端点(用于自托管代理)"
|
||||
"allow_local_network": "允许本地网络端点(用于自托管代理)",
|
||||
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -33,7 +34,8 @@
|
||||
"api_timeout": "API请求超时时间(5-600秒)",
|
||||
"context_messages": "保留的上下文消息数量(1-20)",
|
||||
"max_history_size": "最大对话历史大小(1-100)",
|
||||
"allow_local_network": "允许本地网络端点(用于自托管代理)"
|
||||
"allow_local_network": "允许本地网络端点(用于自托管代理)",
|
||||
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -44,6 +46,7 @@
|
||||
"name_exists": "具有此名称的实例已存在",
|
||||
"invalid_name": "无效的实例名称",
|
||||
"invalid_auth": "身份验证失败 - 检查您的API密钥",
|
||||
"api_key_required": "更改提供商或端点时需要输入 API 密钥",
|
||||
"invalid_api_key": "无效的API密钥 - 请验证您的凭据",
|
||||
"cannot_connect": "无法连接到API服务",
|
||||
"invalid_model": "所选模型不可用",
|
||||
@@ -57,7 +60,6 @@
|
||||
"invalid_instance": "指定的实例无效",
|
||||
"unknown": "发生意外错误",
|
||||
"empty": "名称不能为空",
|
||||
"invalid_characters": "名称只能包含字母、数字、空格、下划线和连字符",
|
||||
"name_too_long": "名称必须少于50个字符"
|
||||
},
|
||||
"abort": {
|
||||
@@ -86,7 +88,8 @@
|
||||
"api_timeout": "API请求超时时间(5-600秒)",
|
||||
"context_messages": "要包含在上下文中的先前消息数量(1-20)",
|
||||
"max_history_size": "最大对话历史大小(1-100)",
|
||||
"allow_local_network": "允许本地网络端点(用于自托管代理)"
|
||||
"allow_local_network": "允许本地网络端点(用于自托管代理)",
|
||||
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +144,10 @@
|
||||
"json_schema": {
|
||||
"name": "JSON模式",
|
||||
"description": "定义预期响应结构的JSON模式。启用structured_output时必需。"
|
||||
},
|
||||
"disable_thinking": {
|
||||
"name": "禁用思考",
|
||||
"description": "为此请求禁用思考/推理模式。覆盖集成级别的设置。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,8 +218,7 @@
|
||||
"rate_limited": "速率限制",
|
||||
"maintenance": "维护中",
|
||||
"initializing": "初始化中",
|
||||
"retrying": "重试中",
|
||||
"queued": "排队中"
|
||||
"retrying": "重试中"
|
||||
},
|
||||
"state_attributes": {
|
||||
"question": {
|
||||
@@ -304,9 +310,24 @@
|
||||
},
|
||||
"min_latency": {
|
||||
"name": "最小延迟"
|
||||
},
|
||||
"last_model": {
|
||||
"name": "最近使用的模型"
|
||||
},
|
||||
"last_timestamp": {
|
||||
"name": "最近响应时间"
|
||||
},
|
||||
"instance_name": {
|
||||
"name": "实例名称"
|
||||
},
|
||||
"normalized_name": {
|
||||
"name": "规范化名称"
|
||||
},
|
||||
"conversation_history": {
|
||||
"name": "对话历史"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
"""
|
||||
Utility functions for HA Text AI integration.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.abc import AbstractResolver
|
||||
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Normalize name to conform to HA naming convention using underscores."""
|
||||
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
|
||||
normalized = '_'.join(filter(None, normalized.split('_')))
|
||||
return normalized.lower()
|
||||
"""Normalize name to conform to HA naming convention using underscores.
|
||||
|
||||
If the input collapses to an empty string (all non-alphanumeric or
|
||||
all underscores), fall back to a short hash of the original so that
|
||||
downstream entity IDs never end with a trailing underscore.
|
||||
"""
|
||||
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
|
||||
normalized = '_'.join(filter(None, normalized.split('_'))).lower()
|
||||
if not normalized:
|
||||
digest = hashlib.sha256(name.encode("utf-8", errors="replace")).hexdigest()[:8]
|
||||
normalized = f"instance_{digest}"
|
||||
return normalized
|
||||
|
||||
def safe_log_data(
|
||||
data: dict[str, Any],
|
||||
@@ -31,11 +44,9 @@ def safe_log_data(
|
||||
"""Filter sensitive keys from data for safe logging."""
|
||||
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
|
||||
|
||||
|
||||
class _RestrictedIPError(ValueError):
|
||||
"""Raised when an IP address is in a restricted range."""
|
||||
|
||||
|
||||
def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Check if an IP address is in a restricted range."""
|
||||
return (
|
||||
@@ -47,14 +58,143 @@ def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) ->
|
||||
or addr.is_unspecified
|
||||
)
|
||||
|
||||
def _is_cloud_metadata_or_unsafe(
|
||||
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> bool:
|
||||
"""Block link-local and cloud instance-metadata addresses.
|
||||
|
||||
async def validate_endpoint(hass: HomeAssistant, endpoint: str, *, allow_local: bool = False) -> str:
|
||||
"""Validate API endpoint URL for security.
|
||||
These must be blocked even in allow_local_network mode:
|
||||
- IPv4 link-local (169.254.0.0/16) covers AWS/GCP/Azure IMDS 169.254.169.254.
|
||||
- IPv6 link-local (fe80::/10).
|
||||
- Multicast and unspecified addresses.
|
||||
Without this check, a self-hosted HA running on a cloud VM could be
|
||||
tricked into exfiltrating cloud credentials via IMDS.
|
||||
"""
|
||||
return addr.is_multicast or addr.is_unspecified or addr.is_link_local
|
||||
|
||||
class _PinnedResolver(AbstractResolver):
|
||||
"""aiohttp resolver that returns pre-validated IPs for a single hostname.
|
||||
|
||||
Why: prevents DNS-rebinding attacks. After validate_endpoint has
|
||||
confirmed the hostname resolves to a safe IP, we pin that IP in the
|
||||
resolver used by the aiohttp session. aiohttp then skips its own
|
||||
DNS lookup on each request and uses the pinned IP, closing the
|
||||
TOCTOU gap between validation and actual HTTP call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pinned: dict[str, list[tuple[str, int]]],
|
||||
) -> None:
|
||||
self._pinned = pinned
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
host: str,
|
||||
port: int = 0,
|
||||
family: int = socket.AF_INET,
|
||||
) -> list[dict[str, Any]]:
|
||||
entries = self._pinned.get(host.lower())
|
||||
if entries is None:
|
||||
# Every request on a pinned session must target the validated
|
||||
# host. Resolving anything else means a request escaped the pin
|
||||
# (a new call site or a config bug) — fail closed rather than
|
||||
# fall back to live, unvalidated DNS.
|
||||
raise OSError(f"Refusing to resolve unpinned host: {host}")
|
||||
return [
|
||||
{
|
||||
"hostname": host,
|
||||
"host": ip,
|
||||
"port": port or default_port,
|
||||
"family": _family_for(ip),
|
||||
"proto": 0,
|
||||
"flags": 0,
|
||||
}
|
||||
for ip, default_port in entries
|
||||
]
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Nothing to close; the resolver holds only the pinned map."""
|
||||
|
||||
def _family_for(ip: str) -> int:
|
||||
"""Return AF_INET or AF_INET6 based on the IP literal."""
|
||||
try:
|
||||
return socket.AF_INET6 if ":" in ip else socket.AF_INET
|
||||
except Exception:
|
||||
return socket.AF_INET
|
||||
|
||||
async def resolve_hostname_ips(
|
||||
hass: HomeAssistant,
|
||||
hostname: str,
|
||||
) -> list[str]:
|
||||
"""Resolve hostname to all its IPs via the event-loop-safe executor.
|
||||
|
||||
Returns a list of IP strings (may contain both IPv4 and IPv6).
|
||||
Raises ValueError on resolution failure.
|
||||
"""
|
||||
try:
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
ips = []
|
||||
seen: set[str] = set()
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||
ip = sockaddr[0]
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
ips.append(ip)
|
||||
if not ips:
|
||||
raise ValueError(f"No IPs for hostname: {hostname}")
|
||||
return ips
|
||||
|
||||
def create_pinned_session(
|
||||
endpoint: str,
|
||||
resolved_ips: list[str],
|
||||
) -> aiohttp.ClientSession:
|
||||
"""Create an isolated aiohttp session with pinned DNS and no cookie jar.
|
||||
|
||||
Addresses two issues at once:
|
||||
- DNS rebinding: aiohttp will reuse the pinned IPs from validate_endpoint
|
||||
rather than re-resolving the hostname on each request.
|
||||
- Cookie pollution: DummyCookieJar prevents cookies from leaking between
|
||||
this integration and other HA components sharing the same domain.
|
||||
|
||||
Built directly on aiohttp: HA's async_create_clientsession always
|
||||
injects its own pooled connector and rejects a caller-supplied one,
|
||||
so a custom resolver cannot go through the helper. The caller owns
|
||||
the session and must close it. Requests must pass
|
||||
allow_redirects=False so a 3xx response cannot route past the pinned
|
||||
resolver to an unvalidated host.
|
||||
"""
|
||||
parsed = urlparse(endpoint)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
pinned: dict[str, list[tuple[str, int]]] = {
|
||||
hostname: [(ip, port) for ip in resolved_ips]
|
||||
}
|
||||
connector = aiohttp.TCPConnector(resolver=_PinnedResolver(pinned))
|
||||
return aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
cookie_jar=aiohttp.DummyCookieJar(),
|
||||
)
|
||||
|
||||
async def validate_endpoint(
|
||||
hass: HomeAssistant,
|
||||
endpoint: str,
|
||||
*,
|
||||
allow_local: bool = False,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Validate API endpoint URL for security and pin resolved IPs.
|
||||
|
||||
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection).
|
||||
When allow_local is True, permits private IPs and HTTP scheme for self-hosted proxies.
|
||||
Uses async DNS resolution to avoid blocking the event loop.
|
||||
Returns the validated endpoint stripped of trailing slashes.
|
||||
|
||||
Returns: (validated_endpoint_without_trailing_slash, resolved_ips).
|
||||
The resolved IPs are intended for pinning in aiohttp resolver via
|
||||
create_pinned_session(), closing the DNS-rebinding TOCTOU gap.
|
||||
|
||||
Raises:
|
||||
ValueError: If the endpoint fails validation.
|
||||
@@ -72,54 +212,47 @@ async def validate_endpoint(hass: HomeAssistant, endpoint: str, *, allow_local:
|
||||
if not hostname:
|
||||
raise ValueError("Invalid endpoint URL: no hostname")
|
||||
|
||||
if allow_local:
|
||||
# Even in local mode, block multicast and unspecified addresses
|
||||
resolved_ips: list[str] = []
|
||||
|
||||
# Collect and check all resolved IPs (or IP literal directly).
|
||||
def _collect(ips: list[str]) -> None:
|
||||
for ip in ips:
|
||||
if ip not in resolved_ips:
|
||||
resolved_ips.append(ip)
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
_is_ip_literal = True
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
_is_ip_literal = False
|
||||
except ValueError:
|
||||
addr = None
|
||||
_is_ip_literal = False
|
||||
|
||||
if _is_ip_literal:
|
||||
if addr.is_multicast or addr.is_unspecified:
|
||||
raise ValueError("Multicast and unspecified addresses are not allowed")
|
||||
else:
|
||||
# Not an IP literal — resolve and check
|
||||
try:
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
for family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||
resolved = ipaddress.ip_address(sockaddr[0])
|
||||
if resolved.is_multicast or resolved.is_unspecified:
|
||||
raise ValueError(
|
||||
"Hostname resolves to multicast/unspecified address"
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
if _is_ip_literal:
|
||||
_collect([hostname])
|
||||
else:
|
||||
# Full SSRF protection — block private/reserved IPs
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
if _check_ip_restricted(addr):
|
||||
raise _RestrictedIPError("Private/reserved IP addresses are not allowed")
|
||||
except _RestrictedIPError:
|
||||
raise
|
||||
except ValueError:
|
||||
# Not an IP literal — resolve hostname and check all resolved IPs
|
||||
# to prevent DNS rebinding attacks
|
||||
try:
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
for family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||
ip_str = sockaddr[0]
|
||||
resolved_addr = ipaddress.ip_address(ip_str)
|
||||
if _check_ip_restricted(resolved_addr):
|
||||
raise ValueError(
|
||||
"Hostname resolves to a restricted IP range"
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
addrinfos = await hass.async_add_executor_job(
|
||||
socket.getaddrinfo, hostname, None
|
||||
)
|
||||
except socket.gaierror as err:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||
_collect([sockaddr[0] for (*_, sockaddr) in addrinfos])
|
||||
|
||||
return endpoint.rstrip("/")
|
||||
if not resolved_ips:
|
||||
raise ValueError(f"No IPs resolved for hostname: {hostname}")
|
||||
|
||||
# Validate each resolved IP against the selected policy.
|
||||
for ip_str in resolved_ips:
|
||||
ip_obj = ipaddress.ip_address(ip_str)
|
||||
if allow_local:
|
||||
if _is_cloud_metadata_or_unsafe(ip_obj):
|
||||
raise ValueError(
|
||||
"Link-local/metadata/multicast addresses are not allowed"
|
||||
)
|
||||
else:
|
||||
if _check_ip_restricted(ip_obj):
|
||||
raise _RestrictedIPError(
|
||||
"Private/reserved IP addresses are not allowed"
|
||||
)
|
||||
|
||||
return endpoint.rstrip("/"), resolved_ips
|
||||
|
||||
Reference in New Issue
Block a user