Compare commits

...
11 Commits
Author SHA1 Message Date
SMKRV 03cd40de29 docs: Add Quick Start section (issue #13) 2026-07-21 10:21:36 +03:00
SMKRV 33d4a190b9 chore: Ignore handoff snapshots 2026-07-13 00:44:56 +03:00
SMKRV 990134bde5 docs: Fix sensor attribute examples, refresh model lineup, strip filler from README 2026-07-13 00:40:45 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3cd8d3076a chore: bump the actions group with 2 updates (#12)
Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `softprops/action-gh-release` from 2 to 3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 02:15:43 +03:00
SMKRV 65dbb5dfc0 fix: Fail-closed pinned resolver, close session on HA stop, drop dead workflow step
- _PinnedResolver raises on unpinned hosts instead of falling back to
  live DNS: nothing legitimate resolves other hosts on a pinned session
  (redirects are off), so fail closed
- dedicated session is also closed on EVENT_HOMEASSISTANT_CLOSE: core
  stop does not unload entries, and the raw ClientSession has no HA
  auto-cleanup, which left 'Unclosed client session' logs at shutdown
- hassfest.yaml: remove the version-print step, hassfest runs inside
  the action's docker image and is never on the runner PATH
2026-07-07 01:49:03 +03:00
SMKRV fc59f584a3 fix: Services survive config entry reload, get_history returns a dict response
- service registration extracted to idempotent _async_register_services,
  called from both async_setup and async_setup_entry: unloading the last
  entry unregisters services, and a reload (every options change) never
  re-ran async_setup, leaving the integration without services
- get_history handler wraps the list in {"history": [...]}: HA rejects
  non-dict action responses, so every return_response call failed with a
  server error since the service gained SupportsResponse (v2.4.x) - the
  path never worked, no consumer could depend on the old shape
- README: get_history example shows response_variable usage and the
  actual limit clamp semantics

Both found by live smoke test in HA 2026.7 (Docker), not by static review
2026-07-07 01:38:27 +03:00
SMKRV bcfe69b5f7 fix: Pinned session TypeError, dead default models, get_history compat, i18n sync
- create_pinned_session built directly on aiohttp: the HA helper injects
  its own connector and raised TypeError on every setup and config flow
- session lifecycle: closed in APIClient.shutdown, on failed setup, and
  in config flow validators; allow_redirects=False everywhere so 3xx
  cannot route past the pinned resolver
- defaults: gemini-2.0-flash (shut down 2026-06-01) -> gemini-3.5-flash,
  deepseek-chat (retires 2026-07-24) -> deepseek-v4-flash
- DeepSeek V4 disable_thinking via thinking request parameter
- Gemini 3.x disable_thinking via thinking_level (MINIMAL, LOW for Pro)
- get_history limit: no forced default/max in schema, storage layer clamps
- translations: 6 locales caught up with strings.json, 2 orphan keys dropped
- README: manual install path matches zip layout, model sections updated
2026-07-07 01:23:01 +03:00
SMKRV 9d4d2d5cd1 fix: Strip angle brackets from disable_thinking labels for hassfest translations check 2026-07-07 01:05:57 +03:00
SMKRV 6e635f7e2f fix: v2.5.1 security/ML/reliability patch
Security:
- H1 DNS rebinding TOCTOU: validate_endpoint returns (endpoint, resolved_ips);
  create_pinned_session builds an aiohttp session with a custom resolver that
  returns only the pre-validated IPs, closing the re-resolve gap.
- H2/M3 Shared session cookie pollution: integration no longer uses HA shared
  clientsession; isolated session with DummyCookieJar prevents cross-integration
  cookie leaks.
- Cloud metadata / link-local block added to allow_local_network mode to
  prevent IMDS exfiltration on cloud VMs.
- L3 hard cap extended to history.async_get_history (not only service schema).

ML/LLM correctness:
- _is_openai_reasoning_model uses regex with gpt-5-chat* blacklist and handles
  OpenRouter-style openai/ prefix. Future o5/gpt-6 auto-recognized.
- reasoning_effort=minimal for gpt-5 family, low for o-series.
- Gemini 2.5 Pro gets thinking_budget=128 (Pro rejects 0, flash accepts 0).
- Anthropic extracts first type=text block instead of hardcoded content[0].
- /no_think dedup uses word-boundary regex instead of substring.
- DeepSeek-reasoner detected: skips /no_think; preserves reasoning_content.

Reliability:
- Exception chaining added to Gemini-block re-raises.
- Top-level imports for json/re/hashlib (no more lazy stdlib imports).
- allow_local_network logged at INFO, not WARNING on every setup.

Code style:
- PEP 604 type hints across all .py files (dict/list/| None).

No breaking changes for users. Internal API: validate_endpoint return type
changed from str to tuple[str, list[str]].
2026-04-17 01:58:16 +03:00
SMKRV e8b9b911ef chore: Extend .gitignore with AI tooling directories
Add .claude/, .cursor/, .cursorrules, .windsurfrules, AGENTS.md, GEMINI.md
as defensive gitignore entries. None of these files currently exist in the
repo, but this prevents accidental leaks of AI-assistant local state if
such files appear in a future workspace.
2026-04-17 01:36:44 +03:00
SMKRV 5af733b1b0 feat: v2.5.0 - disable_thinking, reasoning models, MIT license, security hardening
Features:
- disable_thinking toggle (issue #11) with per-provider semantics:
  OpenAI classic gets /no_think soft-switch, OpenAI reasoning gets
  reasoning_effort=low, DeepSeek gets both, Anthropic no-op (thinking
  opt-in), Gemini 2.5+ gets thinking_budget=0
- OpenAI reasoning model support (o1/o3/o4-mini/gpt-5 family):
  max_completion_tokens, developer role, reasoning_effort
- Per-request disable_thinking override in ask_question service
- Provider-specific temperature clip (Anthropic 0-1, others 0-2)

Security:
- Require API key re-entry on provider change (OptionsFlow)
- Validate Anthropic json_schema before system-prompt concatenation
- Symlink protection in history file operations
- Hardened secret redaction regexes (sk-*, AIza*, x-api-key)
- get_history service: hard cap on limit (default 10, max 100)

Reliability:
- Retry on 502/503/504 in addition to 429/timeout
- Honor Retry-After header on 429
- Exception chaining (raise ... from err)
- _strip_think_blocks handles nested/dangling tags
- normalize_name sha256 fallback for empty-collapse inputs

UI/housekeeping:
- api_key uses TextSelector(type=PASSWORD)
- DeviceInfo entry_type=SERVICE
- Services unregister on last entry unload
- Dependabot for GitHub Actions
- persist-credentials=false in checkout steps
- manifest requirements pinned with upper bounds
- Ignore docs/plans/ (working artifacts)

License: PolyForm Noncommercial 1.0.0 -> MIT

No breaking changes: service response shape, sensor attributes,
entity IDs and on-disk formats unchanged.
2026-04-17 01:30:57 +03:00
27 changed files with 1241 additions and 698 deletions
+15
View File
@@ -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:
- "*"
+4 -8
View File
@@ -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
+3 -2
View File
@@ -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
+8
View File
@@ -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/
+17 -127
View File
@@ -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.
+217 -257
View File
@@ -1,183 +1,118 @@
# 🤖 HA Text AI for Home Assistant
# HA Text AI for Home Assistant
<div align="center">
![GitHub release](https://img.shields.io/github/v/release/smkrv/ha-text-ai?style=flat-square) ![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai?style=flat-square) [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial%201.0.0-lightgrey.svg?style=flat-square)](https://polyformproject.org/licenses/noncommercial/1.0.0) [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=flat-square)](https://github.com/hacs/integration)
![GitHub release](https://img.shields.io/github/v/release/smkrv/ha-text-ai?style=flat-square) ![GitHub last commit](https://img.shields.io/github/last-commit/smkrv/ha-text-ai?style=flat-square) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=flat-square)](https://github.com/hacs/integration)
![Deutsch](https://img.shields.io/badge/lang-DE-blue?style=flat-square) ![English](https://img.shields.io/badge/lang-EN-blue?style=flat-square) ![Español](https://img.shields.io/badge/lang-ES-blue?style=flat-square) ![हिन्दी](https://img.shields.io/badge/lang-HI-blue?style=flat-square) ![Italiano](https://img.shields.io/badge/lang-IT-blue?style=flat-square) ![Русский](https://img.shields.io/badge/lang-RU-blue?style=flat-square) ![Српски](https://img.shields.io/badge/lang-SR-blue?style=flat-square) ![中文](https://img.shields.io/badge/lang-ZH-blue?style=flat-square)
<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>
+81 -21
View File
@@ -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
+319 -66
View File
@@ -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()
+73 -43
View File
@@ -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),
+9 -4
View File
@@ -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
+33 -26
View File
@@ -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}")
+32 -11
View File
@@ -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.
+3 -3
View File
@@ -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"
}
+27 -15
View File
@@ -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,
+1 -5
View File
@@ -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)
+6 -6
View File
@@ -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: >-
+10 -3
View File
@@ -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": "对话历史"
}
}
}
}
}
}
}
+189 -56
View File
@@ -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