Compare commits

..
9 Commits
Author SHA1 Message Date
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
SMKRV 1e2ff81d07 feat: Add allow_local_network option for self-hosted LLM proxies
Add per-instance boolean option to allow private IP endpoints and HTTP
scheme for self-hosted LLM proxies (LiteLLM, Ollama, vLLM, etc.).

- New CONF_ALLOW_LOCAL_NETWORK config option (default: false)
- When enabled: allows RFC1918 private IPs and HTTP endpoints
- When disabled: full SSRF protection preserved (HTTPS + public IPs only)
- Multicast and unspecified addresses blocked regardless of setting
- Warning logged when local network mode is active
- Checkbox added to ConfigFlow and OptionsFlow UI
- Translations for all 8 languages

Closes #9
2026-03-23 12:18:58 +03:00
SMKRV 47c731c9ee docs: Update structure.md to reflect current file layout
Fix root path to custom_components/ha_text_ai/, add new modules
(history.py, metrics.py, providers.py, utils.py, strings.json).
2026-03-12 14:54:18 +03:00
28 changed files with 1105 additions and 429 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:
- "*"
+3 -7
View File
@@ -26,19 +26,15 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: ⤵️ Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: 🚀 Run hassfest validation - name: Run hassfest validation
uses: home-assistant/actions/hassfest@master uses: home-assistant/actions/hassfest@master
- name: ️ Print hassfest version
if: always()
run: |
echo "Hassfest version: $(hassfest --version)"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
+1
View File
@@ -17,6 +17,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.release.tag_name }} ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- name: Create zip archive - name: Create zip archive
run: | run: |
+7
View File
@@ -28,5 +28,12 @@ Thumbs.db
# Claude Code working files # Claude Code working files
CLAUDE.md CLAUDE.md
AGENTS.md
GEMINI.md
.claude/
.cursor/
.cursorrules
.windsurfrules
docs/specs/ docs/specs/
docs/superpowers/ 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 The above copyright notice and this permission notice shall be included in all
to them as both strict obligations and conditions to all copies or substantial portions of the Software.
your licenses.
## Copyright License THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
The licensor grants you a copyright license for the FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
software to do everything you might do with the software AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
that would otherwise infringe the licensor's copyright LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
in it for any permitted purpose. However, you may OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
only distribute the software according to [Distribution SOFTWARE.
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.
+14 -9
View File
@@ -2,7 +2,7 @@
<div align="center"> <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) ![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)
@@ -142,12 +142,16 @@ Transform your smart home experience with powerful AI assistance powered by mult
- **Claude Haiku 4.5** - The fastest and most economical option in the series - **Claude Haiku 4.5** - The fastest and most economical option in the series
#### DeepSeek Models #### DeepSeek Models
- **DeepSeek-V3** - A general-purpose model for a wide range of tasks - **deepseek-v4-flash** - A fast general-purpose model for a wide range of tasks (default)
- **DeepSeek-R1** - A specialized model focused on reasoning and coding - **deepseek-v4-pro** - A more capable model for 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 #### Google Gemini Models
- **Gemini 3.1 Pro** - The newest and most advanced model available - **gemini-3.5-flash** - Fast and cost-efficient, suitable for most tasks (default)
- **Gemini 3.1 Flash Lite** - Fastest and most cost-efficient model for high-volume workloads - **Gemini 3.1 Pro** - The most advanced Gemini model available
> 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> <details>
<summary>🌐 Potentially Compatible Providers</summary> <summary>🌐 Potentially Compatible Providers</summary>
@@ -199,8 +203,8 @@ If the integration is not found in the default repository:
5. Click "Download" 5. Click "Download"
### Manual Installation ### Manual Installation
1. Download the latest release 1. Download `ha_text_ai.zip` from the latest release
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory 2. Extract the archive and copy the `ha_text_ai` folder into your `custom_components` directory
3. Restart Home Assistant 3. Restart Home Assistant
4. Add configuration via UI (Settings → Devices & Services → Add Integration) 4. Add configuration via UI (Settings → Devices & Services → Add Integration)
@@ -279,12 +283,13 @@ data:
```yaml ```yaml
service: ha_text_ai.get_history service: ha_text_ai.get_history
data: 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 filter_model: "gpt-4o" # optional, filter by specific AI model
start_date: "2025-02-01" # optional, filter conversations from this date start_date: "2025-02-01" # optional, filter conversations from this date
include_metadata: false # optional, include tokens, response time, etc. include_metadata: false # optional, include tokens, response time, etc.
sort_order: "newest" # optional, sort order: "newest" or "oldest" sort_order: "newest" # optional, sort order: "newest" or "oldest"
instance: sensor.ha_text_ai_gpt instance: sensor.ha_text_ai_gpt
response_variable: history_result # entries are in history_result.history
``` ```
## 🚀 Advanced Automation Examples with Response Variables ## 🚀 Advanced Automation Examples with Response Variables
@@ -653,7 +658,7 @@ DEALINGS IN THE SOFTWARE.
## 📝 License ## 📝 License
Author: SMKRV 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
+88 -19
View File
@@ -1,7 +1,7 @@
""" """
The HA Text AI integration. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: 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 from __future__ import annotations
import logging import logging
from typing import Any, Dict from typing import Any
import asyncio import asyncio
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import aiohttp_client
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .coordinator import HATextAICoordinator from .coordinator import HATextAICoordinator
from .api_client import APIClient 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 .providers import get_default_endpoint, get_default_model, build_auth_headers
from .const import ( from .const import (
DOMAIN, DOMAIN,
@@ -49,6 +48,10 @@ from .const import (
SERVICE_SET_SYSTEM_PROMPT, SERVICE_SET_SYSTEM_PROMPT,
DEFAULT_MAX_HISTORY, DEFAULT_MAX_HISTORY,
CONF_MAX_HISTORY_SIZE, CONF_MAX_HISTORY_SIZE,
CONF_ALLOW_LOCAL_NETWORK,
DEFAULT_ALLOW_LOCAL_NETWORK,
CONF_DISABLE_THINKING,
DEFAULT_DISABLE_THINKING,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -67,6 +70,7 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Optional("context_messages"): cv.positive_int, vol.Optional("context_messages"): cv.positive_int,
vol.Optional("structured_output", default=False): cv.boolean, vol.Optional("structured_output", default=False): cv.boolean,
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)), 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({ SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
@@ -76,7 +80,11 @@ SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({ SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
vol.Required("instance"): cv.string, 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("filter_model"): cv.string,
vol.Optional("start_date"): cv.string, vol.Optional("start_date"): cv.string,
vol.Optional("include_metadata"): cv.boolean, vol.Optional("include_metadata"): cv.boolean,
@@ -104,10 +112,23 @@ def get_coordinator_by_instance(hass: HomeAssistant, instance: str) -> HATextAIC
raise HomeAssistantError(f"Instance {instance} not found") 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.""" """Set up the Home Assistant Text AI component."""
# Initialize domain data storage # Initialize domain data storage
hass.data.setdefault(DOMAIN, {}) 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: async def async_ask_question(call: ServiceCall) -> dict:
"""Handle ask_question service with response data.""" """Handle ask_question service with response data."""
@@ -122,6 +143,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
context_messages=call.data.get("context_messages"), context_messages=call.data.get("context_messages"),
structured_output=call.data.get("structured_output", False), structured_output=call.data.get("structured_output", False),
json_schema=call.data.get("json_schema"), json_schema=call.data.get("json_schema"),
disable_thinking=call.data.get("disable_thinking"),
) )
# Return structured response data # Return structured response data
@@ -160,22 +182,26 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
await coordinator.async_clear_history() await coordinator.async_clear_history()
except Exception as err: except Exception as err:
_LOGGER.error("Error clearing history: %s", str(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.""" """Handle get_history service."""
try: try:
coordinator = get_coordinator_by_instance(hass, call.data["instance"]) 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"), limit=call.data.get("limit"),
filter_model=call.data.get("filter_model"), filter_model=call.data.get("filter_model"),
start_date=call.data.get("start_date"), start_date=call.data.get("start_date"),
include_metadata=call.data.get("include_metadata", False), include_metadata=call.data.get("include_metadata", False),
sort_order=call.data.get("sort_order", "newest") 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: except Exception as err:
_LOGGER.error("Error getting history: %s", str(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: async def async_set_system_prompt(call: ServiceCall) -> None:
"""Handle set_system_prompt service.""" """Handle set_system_prompt service."""
@@ -184,7 +210,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
await coordinator.async_set_system_prompt(call.data["prompt"]) await coordinator.async_set_system_prompt(call.data["prompt"])
except Exception as err: except Exception as err:
_LOGGER.error("Error setting system prompt: %s", str(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 # Register services
hass.services.async_register( hass.services.async_register(
@@ -217,8 +243,6 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT 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: 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.""" """Check API availability using provider registry configuration."""
try: try:
@@ -238,7 +262,9 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
check_url = f"{endpoint}{check_path}" check_url = f"{endpoint}{check_path}"
async with asyncio.timeout(api_timeout): 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: if response.status == 200:
return True return True
elif response.status == 401: elif response.status == 401:
@@ -258,6 +284,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA Text AI from a config entry.""" """Set up HA Text AI from a config entry."""
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data))) _LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
session = None
try: try:
# Get provider from data or options (options takes precedence) # Get provider from data or options (options takes precedence)
config = {**entry.data, **entry.options} config = {**entry.data, **entry.options}
@@ -267,15 +294,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.error("API provider not specified") _LOGGER.error("API provider not specified")
raise ConfigEntryNotReady("API provider is required") raise ConfigEntryNotReady("API provider is required")
session = aiohttp_client.async_get_clientsession(hass)
model = config.get(CONF_MODEL, get_default_model(api_provider)) model = config.get(CONF_MODEL, get_default_model(api_provider))
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(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.info(
"Local network mode enabled for endpoint %s"
"SSRF protection relaxed for self-hosted proxies",
raw_endpoint,
)
try: try:
endpoint = await validate_endpoint(hass, raw_endpoint) endpoint, resolved_ips = await validate_endpoint(
hass, raw_endpoint, allow_local=allow_local
)
except ValueError as err: except ValueError as err:
_LOGGER.error("Invalid API endpoint: %s", err) _LOGGER.error("Invalid API endpoint: %s", err)
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from 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 can now be updated via options
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY)) api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
instance_name = entry.data.get(CONF_NAME, entry.entry_id) instance_name = entry.data.get(CONF_NAME, entry.entry_id)
@@ -285,6 +325,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY) max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
context_messages = config.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES) 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) headers = build_auth_headers(api_provider, api_key)
@@ -315,6 +356,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
max_history_size=max_history_size, max_history_size=max_history_size,
context_messages=context_messages, context_messages=context_messages,
api_timeout=api_timeout, api_timeout=api_timeout,
disable_thinking=disable_thinking,
) )
# Initialize coordinator (directories, history, metrics) # Initialize coordinator (directories, history, metrics)
@@ -326,6 +368,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator 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) _LOGGER.debug("Stored coordinator in hass.data[%s][%s]", DOMAIN, entry.entry_id)
# Set up platforms # Set up platforms
@@ -334,12 +379,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Register update listener for options changes # Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(async_update_options)) 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) _LOGGER.debug("Setup completed for %s", instance_name)
return True return True
except Exception as err: except Exception as err:
_LOGGER.exception("Error setting up HA Text AI: %s", err) _LOGGER.exception("Error setting up HA Text AI: %s", err)
if session is not None and not session.closed:
await session.close()
raise raise
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -347,7 +407,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
_LOGGER.info("Options updated for %s, reloading integration", entry.title) _LOGGER.info("Options updated for %s, reloading integration", entry.title)
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
try: try:
@@ -360,8 +419,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_shutdown() 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): if not hass.data.get(DOMAIN):
hass.data.pop(DOMAIN, None) 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 return unload_ok
+319 -66
View File
@@ -1,16 +1,18 @@
""" """
API Client for HA Text AI. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai @source: https://github.com/smkrv/ha-text-ai
""" """
from __future__ import annotations from __future__ import annotations
import logging
import asyncio 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 aiohttp import ClientSession, ClientTimeout
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@@ -29,7 +31,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class APIClient: class APIClient:
"""API Client for OpenAI and Anthropic.""" """API Client for OpenAI and Anthropic."""
@@ -37,11 +38,11 @@ class APIClient:
self, self,
session: ClientSession, session: ClientSession,
endpoint: str, endpoint: str,
headers: Dict[str, str], headers: dict[str, str],
api_provider: str, api_provider: str,
model: str, model: str,
api_timeout: int = DEFAULT_API_TIMEOUT, api_timeout: int = DEFAULT_API_TIMEOUT,
api_key: Optional[str] = None, api_key: str | None = None,
) -> None: ) -> None:
"""Initialize API client.""" """Initialize API client."""
self.session = session self.session = session
@@ -89,12 +90,22 @@ class APIClient:
async def _make_request( async def _make_request(
self, self,
url: str, url: str,
payload: Dict[str, Any], payload: dict[str, Any],
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Make API request with retry logic for transient errors only.""" """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']} 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) _LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
retryable_5xx = {502, 503, 504}
for attempt in range(API_RETRY_COUNT): for attempt in range(API_RETRY_COUNT):
try: try:
async with self.session.post( async with self.session.post(
@@ -102,6 +113,9 @@ class APIClient:
json=payload, json=payload,
headers=self.headers, headers=self.headers,
timeout=self.timeout, 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: ) as response:
_LOGGER.debug("Response status: %s", response.status) _LOGGER.debug("Response status: %s", response.status)
if response.status == 200: if response.status == 200:
@@ -114,25 +128,41 @@ class APIClient:
except Exception: except Exception:
error_data = {"raw": await response.text()} error_data = {"raw": await response.text()}
# Rate limit — retry with backoff # Rate limit — retry with backoff, prefer Retry-After header
if response.status == 429: if response.status == 429:
_LOGGER.warning( _LOGGER.warning(
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT "Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
) )
if attempt < API_RETRY_COUNT - 1: 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 continue
raise HomeAssistantError("API rate limit exceeded") 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] truncated_error = str(error_data)[:512]
_LOGGER.error("API error (status %d): %s", response.status, truncated_error) _LOGGER.error("API error (status %d): %s", response.status, truncated_error)
raise HomeAssistantError(f"API error: status {response.status}") 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) _LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
if attempt == API_RETRY_COUNT - 1: 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) await asyncio.sleep(2 ** attempt)
except HomeAssistantError: except HomeAssistantError:
raise raise
@@ -147,15 +177,29 @@ class APIClient:
raise HomeAssistantError("API request failed after all retries") 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( async def create(
self, self,
model: str, model: str,
messages: List[Dict[str, str]], messages: list[dict[str, str]],
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
structured_output: bool = False, structured_output: bool = False,
json_schema: Optional[str] = None, json_schema: str | None = None,
) -> Dict[str, Any]: disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using appropriate API.""" """Create completion using appropriate API."""
try: try:
self._validate_parameters(temperature, max_tokens) self._validate_parameters(temperature, max_tokens)
@@ -163,37 +207,129 @@ class APIClient:
if self.api_provider == API_PROVIDER_ANTHROPIC: if self.api_provider == API_PROVIDER_ANTHROPIC:
return await self._create_anthropic_completion( return await self._create_anthropic_completion(
model, messages, temperature, max_tokens, model, messages, temperature, max_tokens,
structured_output, json_schema structured_output, json_schema, disable_thinking
) )
elif self.api_provider == API_PROVIDER_DEEPSEEK: elif self.api_provider == API_PROVIDER_DEEPSEEK:
return await self._create_deepseek_completion( return await self._create_deepseek_completion(
model, messages, temperature, max_tokens, model, messages, temperature, max_tokens,
structured_output, json_schema structured_output, json_schema, disable_thinking
) )
elif self.api_provider == API_PROVIDER_GEMINI: elif self.api_provider == API_PROVIDER_GEMINI:
return await self._create_gemini_completion( return await self._create_gemini_completion(
model, messages, temperature, max_tokens, model, messages, temperature, max_tokens,
structured_output, json_schema structured_output, json_schema, disable_thinking
) )
else: else:
return await self._create_openai_completion( return await self._create_openai_completion(
model, messages, temperature, max_tokens, model, messages, temperature, max_tokens,
structured_output, json_schema structured_output, json_schema, disable_thinking
) )
except Exception as e: except Exception as e:
_LOGGER.error("API request failed: %s", str(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 @staticmethod
def _apply_structured_output( def _apply_structured_output(
payload: Dict[str, Any], payload: dict[str, Any],
structured_output: bool, structured_output: bool,
json_schema: Optional[str], json_schema: str | None,
) -> None: ) -> None:
"""Apply OpenAI-compatible structured output to payload in-place.""" """Apply OpenAI-compatible structured output to payload in-place."""
if not (structured_output and json_schema): if not (structured_output and json_schema):
return return
import json
try: try:
schema = json.loads(json_schema) schema = json.loads(json_schema)
payload["response_format"] = { payload["response_format"] = {
@@ -211,28 +347,58 @@ class APIClient:
async def _create_deepseek_completion( async def _create_deepseek_completion(
self, self,
model: str, model: str,
messages: List[Dict[str, str]], messages: list[dict[str, str]],
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
structured_output: bool = False, structured_output: bool = False,
json_schema: Optional[str] = None, json_schema: str | None = None,
) -> Dict[str, Any]: disable_thinking: bool = False,
"""Create completion using DeepSeek API.""" ) -> 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" 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 = { payload = {
"model": model, "model": model,
"messages": messages, "messages": final_messages,
"temperature": temperature, "temperature": temperature,
"max_tokens": max_tokens, "max_tokens": max_tokens,
"stream": False, "stream": False,
} }
if disable_thinking and is_v4plus:
payload["thinking"] = {"type": "disabled"}
self._apply_structured_output(payload, structured_output, json_schema) self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload) 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 { return {
"choices": [ "choices": [
{ {
"message": {"content": data["choices"][0]["message"]["content"]}, "message": {
"content": content,
**({"reasoning_content": reasoning} if reasoning else {}),
},
} }
], ],
"usage": { "usage": {
@@ -245,27 +411,59 @@ class APIClient:
async def _create_openai_completion( async def _create_openai_completion(
self, self,
model: str, model: str,
messages: List[Dict[str, str]], messages: list[dict[str, str]],
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
structured_output: bool = False, structured_output: bool = False,
json_schema: Optional[str] = None, json_schema: str | None = None,
) -> Dict[str, Any]: disable_thinking: bool = False,
"""Create completion using OpenAI API.""" ) -> 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" url = f"{self.endpoint}/chat/completions"
payload = { is_reasoning = self._is_openai_reasoning_model(model)
"model": model,
"messages": messages, if is_reasoning:
"temperature": temperature, prepared_messages = self._convert_system_to_developer(messages)
"max_tokens": max_tokens, 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) self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload) 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 { return {
"choices": [ "choices": [
{ {
"message": {"content": data["choices"][0]["message"]["content"]}, "message": {"content": content},
} }
], ],
"usage": { "usage": {
@@ -278,12 +476,13 @@ class APIClient:
async def _create_anthropic_completion( async def _create_anthropic_completion(
self, self,
model: str, model: str,
messages: List[Dict[str, str]], messages: list[dict[str, str]],
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
structured_output: bool = False, structured_output: bool = False,
json_schema: Optional[str] = None, json_schema: str | None = None,
) -> Dict[str, Any]: disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using Anthropic API.""" """Create completion using Anthropic API."""
url = f"{self.endpoint}/v1/messages" url = f"{self.endpoint}/v1/messages"
@@ -298,35 +497,56 @@ class APIClient:
else: else:
filtered_messages.append(msg) 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: if structured_output and json_schema:
schema_instruction = ( try:
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches " json.loads(json_schema)
f"this JSON Schema:\n{json_schema}\n" except json.JSONDecodeError as err:
f"Do not include any text before or after the JSON. " _LOGGER.warning(
f"Do not wrap the JSON in markdown code blocks." "Anthropic: invalid JSON schema, ignoring structured_output: %s", err
) )
if system_prompt:
system_prompt += schema_instruction
else: else:
system_prompt = schema_instruction.strip() schema_instruction = (
_LOGGER.debug("Anthropic structured output enabled via system prompt") 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 = { payload = {
"model": model, "model": model,
"messages": filtered_messages, "messages": filtered_messages,
"max_tokens": max_tokens, "max_tokens": max_tokens,
"temperature": temperature, "temperature": clipped_temp,
} }
if system_prompt: if system_prompt:
payload["system"] = system_prompt payload["system"] = system_prompt
data = await self._make_request(url, payload) 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 { return {
"choices": [ "choices": [
{ {
"message": {"content": data["content"][0]["text"]}, "message": {"content": content},
} }
], ],
"usage": { "usage": {
@@ -339,12 +559,13 @@ class APIClient:
async def _create_gemini_completion( async def _create_gemini_completion(
self, self,
model: str, model: str,
messages: List[Dict[str, str]], messages: list[dict[str, str]],
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
structured_output: bool = False, structured_output: bool = False,
json_schema: Optional[str] = None, json_schema: str | None = None,
) -> Dict[str, Any]: disable_thinking: bool = False,
) -> dict[str, Any]:
"""Create completion using Gemini API with google-genai library. """Create completion using Gemini API with google-genai library.
Args: Args:
@@ -395,7 +616,6 @@ class APIClient:
parsed_schema = None parsed_schema = None
if structured_output and json_schema: if structured_output and json_schema:
try: try:
import json
parsed_schema = json.loads(json_schema) parsed_schema = json.loads(json_schema)
_LOGGER.debug("Gemini structured output enabled with schema") _LOGGER.debug("Gemini structured output enabled with schema")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
@@ -418,6 +638,30 @@ class APIClient:
config.response_mime_type = "application/json" config.response_mime_type = "application/json"
config.response_schema = parsed_schema 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 return config
config = await asyncio.to_thread(create_config) config = await asyncio.to_thread(create_config)
@@ -491,6 +735,9 @@ class APIClient:
response_text, usage = await asyncio.to_thread(extract_response) response_text, usage = await asyncio.to_thread(extract_response)
if disable_thinking:
response_text = self._strip_think_blocks(response_text)
return { return {
"choices": [{ "choices": [{
"message": { "message": {
@@ -502,13 +749,19 @@ class APIClient:
except ImportError as e: except ImportError as e:
_LOGGER.error("Google Gemini library not installed: %s", 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: except Exception as e:
_LOGGER.error("Gemini API error: %s", 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: async def shutdown(self) -> None:
"""Shutdown API client.""" """Shutdown API client and close its dedicated session."""
_LOGGER.debug("Shutting down API client") _LOGGER.debug("Shutting down API client")
self._closed = True 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()
+90 -45
View File
@@ -1,7 +1,7 @@
""" """
Config flow for HA text AI integration. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: 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 from __future__ import annotations
import logging import logging
from typing import Any, Dict, Optional from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.config_entries import ConfigFlowResult from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers import selector from homeassistant.helpers import selector
from .const import ( from .const import (
@@ -54,16 +53,24 @@ from .const import (
MAX_CONTEXT_MESSAGES, MAX_CONTEXT_MESSAGES,
MIN_HISTORY_SIZE, MIN_HISTORY_SIZE,
MAX_HISTORY_SIZE, 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 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 from .providers import get_default_endpoint, get_default_model, build_auth_headers
_LOGGER = logging.getLogger(__name__) _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.""" """Build shared parameter schema fields used by both ConfigFlow and OptionsFlow."""
return { return {
vol.Optional( vol.Optional(
@@ -90,9 +97,12 @@ def _build_parameter_schema(data: Dict[str, Any]) -> dict:
CONF_MAX_HISTORY_SIZE, CONF_MAX_HISTORY_SIZE,
default=data.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY), 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.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): class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HA text AI.""" """Handle a config flow for HA text AI."""
@@ -104,7 +114,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._data = {} self._data = {}
self._provider = None 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.""" """Handle the initial step."""
if user_input is None: if user_input is None:
return self.async_show_form( return self.async_show_form(
@@ -123,20 +133,26 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_provider() return await self.async_step_provider()
def _build_provider_schema( def _build_provider_schema(
self, data: Optional[Dict[str, Any]] = None self, data: dict[str, Any] | None = None
) -> vol.Schema: ) -> vol.Schema:
"""Build provider configuration schema with optional defaults from data.""" """Build provider configuration schema with optional defaults from data."""
defaults = data or {} defaults = data or {}
schema_dict = { schema_dict = {
vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, DEFAULT_INSTANCE_NAME)): str, 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_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.Required(CONF_API_ENDPOINT, default=defaults.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
vol.Optional(
CONF_ALLOW_LOCAL_NETWORK,
default=defaults.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
): bool,
} }
schema_dict.update(_build_parameter_schema(defaults)) schema_dict.update(_build_parameter_schema(defaults))
return vol.Schema(schema_dict) 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.""" """Handle provider configuration step."""
self._errors = {} self._errors = {}
@@ -215,7 +231,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return normalized 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.""" """Validate API connection using provider registry."""
try: try:
if CONF_API_KEY not in user_input: if CONF_API_KEY not in user_input:
@@ -224,7 +240,10 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return False return False
try: try:
endpoint = await validate_endpoint(self.hass, user_input[CONF_API_ENDPOINT]) allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
endpoint, resolved_ips = await validate_endpoint(
self.hass, user_input[CONF_API_ENDPOINT], allow_local=allow_local
)
except ValueError as err: except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err) _LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect" self._errors["base"] = "cannot_connect"
@@ -236,28 +255,35 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return False return False
return True return True
session = async_get_clientsession(self.hass)
headers = build_auth_headers(self._provider, user_input[CONF_API_KEY]) headers = build_auth_headers(self._provider, user_input[CONF_API_KEY])
from .providers import get_provider_config from .providers import get_provider_config
check_path = get_provider_config(self._provider).get("check_path", "/models") check_path = get_provider_config(self._provider).get("check_path", "/models")
check_url = f"{endpoint}{check_path}" check_url = f"{endpoint}{check_path}"
async with session.get(check_url, headers=headers) as response: # Pinned session ensures the reachability check goes to the same
if response.status == 401: # IP that will later be used by api_client (no DNS rebinding).
self._errors["base"] = "invalid_auth" session = create_pinned_session(endpoint, resolved_ips)
return False try:
elif response.status != 200: async with session.get(
self._errors["base"] = "cannot_connect" check_url, headers=headers, allow_redirects=False
return False ) as response:
return True 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: except Exception as err:
_LOGGER.error("API validation error: %s", str(err)) _LOGGER.error("API validation error: %s", str(err))
self._errors["base"] = "cannot_connect" self._errors["base"] = "cannot_connect"
return False 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.""" """Create the config entry with unique_id deduplication."""
instance_name = user_input[CONF_NAME] instance_name = user_input[CONF_NAME]
normalized_name = normalize_name(instance_name) normalized_name = normalize_name(instance_name)
@@ -280,6 +306,8 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_API_TIMEOUT: user_input.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT), CONF_API_TIMEOUT: user_input.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT),
CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES), 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_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)) _LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
@@ -295,11 +323,10 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return OptionsFlowHandler() return OptionsFlowHandler()
class OptionsFlowHandler(config_entries.OptionsFlow): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow.""" """Handle options flow."""
async def _async_validate_api(self, provider: str, api_key: str, endpoint: str) -> bool: async def _async_validate_api(self, provider: str, api_key: str, endpoint: str, *, allow_local: bool = False) -> bool:
"""Validate API connection using provider registry.""" """Validate API connection using provider registry."""
try: try:
if not api_key: if not api_key:
@@ -307,7 +334,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
return False return False
try: try:
endpoint = await validate_endpoint(self.hass, endpoint) endpoint, resolved_ips = await validate_endpoint(
self.hass, endpoint, allow_local=allow_local
)
except ValueError as err: except ValueError as err:
_LOGGER.error("Endpoint validation failed: %s", err) _LOGGER.error("Endpoint validation failed: %s", err)
self._errors["base"] = "cannot_connect" self._errors["base"] = "cannot_connect"
@@ -316,32 +345,37 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if provider == API_PROVIDER_GEMINI: if provider == API_PROVIDER_GEMINI:
return True return True
session = async_get_clientsession(self.hass)
headers = build_auth_headers(provider, api_key) headers = build_auth_headers(provider, api_key)
from .providers import get_provider_config from .providers import get_provider_config
check_path = get_provider_config(provider).get("check_path", "/models") check_path = get_provider_config(provider).get("check_path", "/models")
check_url = f"{endpoint}{check_path}" check_url = f"{endpoint}{check_path}"
async with session.get(check_url, headers=headers) as response: session = create_pinned_session(endpoint, resolved_ips)
if response.status == 401: try:
self._errors["base"] = "invalid_auth" async with session.get(
return False check_url, headers=headers, allow_redirects=False
elif response.status != 200: ) as response:
self._errors["base"] = "cannot_connect" if response.status == 401:
return False self._errors["base"] = "invalid_auth"
return True return False
elif response.status != 200:
self._errors["base"] = "cannot_connect"
return False
return True
finally:
await session.close()
except Exception as err: except Exception as err:
_LOGGER.error("API validation error: %s", str(err)) _LOGGER.error("API validation error: %s", str(err))
self._errors["base"] = "cannot_connect" self._errors["base"] = "cannot_connect"
return False 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.""" """Handle provider selection step."""
if not hasattr(self, "_errors"): if not hasattr(self, "_errors"):
self._errors: dict[str, str] = {} 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_data = {**self.config_entry.data, **self.config_entry.options}
current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI) current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI)
@@ -367,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.""" """Handle settings configuration step."""
self._errors = {} self._errors = {}
current_data = {**self.config_entry.data, **self.config_entry.options} current_data = {**self.config_entry.data, **self.config_entry.options}
@@ -388,7 +422,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
api_key = user_input.get(CONF_API_KEY, "").strip() api_key = user_input.get(CONF_API_KEY, "").strip()
endpoint = user_input.get(CONF_API_ENDPOINT, default_endpoint) 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, "") stored_endpoint = current_data.get(CONF_API_ENDPOINT, "")
endpoint_changed = endpoint != stored_endpoint endpoint_changed = endpoint != stored_endpoint
if not api_key and (provider_changed or endpoint_changed): if not api_key and (provider_changed or endpoint_changed):
@@ -408,11 +445,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
} }
) )
# Fall back to stored key if not re-entered and endpoint unchanged # Fall back to stored key only when neither provider nor endpoint changed.
if not api_key: # 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, "") api_key = current_data.get(CONF_API_KEY, "")
if await self._async_validate_api(provider, api_key, endpoint): allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
if await self._async_validate_api(provider, api_key, endpoint, allow_local=allow_local):
final_data = { final_data = {
CONF_API_PROVIDER: provider, CONF_API_PROVIDER: provider,
**user_input, **user_input,
@@ -453,8 +492,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
def _get_settings_schema( def _get_settings_schema(
self, self,
provider: str, provider: str,
current_data: Dict[str, Any], current_data: dict[str, Any],
user_input: Optional[Dict[str, Any]], user_input: dict[str, Any] | None,
default_endpoint: str, default_endpoint: str,
default_model: str, default_model: str,
) -> vol.Schema: ) -> vol.Schema:
@@ -462,7 +501,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
data = user_input or current_data data = user_input or current_data
schema_dict = { 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( vol.Required(
CONF_API_ENDPOINT, CONF_API_ENDPOINT,
default=data.get(CONF_API_ENDPOINT, default_endpoint), default=data.get(CONF_API_ENDPOINT, default_endpoint),
@@ -471,6 +512,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
CONF_MODEL, CONF_MODEL,
default=data.get(CONF_MODEL, default_model), default=data.get(CONF_MODEL, default_model),
): str, ): str,
vol.Optional(
CONF_ALLOW_LOCAL_NETWORK,
default=data.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
): bool,
} }
schema_dict.update(_build_parameter_schema(data)) schema_dict.update(_build_parameter_schema(data))
return vol.Schema(schema_dict) return vol.Schema(schema_dict)
+11 -4
View File
@@ -1,7 +1,7 @@
""" """
Constants for the HA text AI integration. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: 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 API_PROVIDER_GEMINI
] ]
VERSION: Final = "2.4.0" VERSION: Final = "2.5.1"
# Default endpoints # Default endpoints
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1" DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
@@ -49,6 +49,8 @@ CONF_MAX_HISTORY_SIZE: Final = "max_history_size" # Correct constant name
CONF_CONTEXT_MESSAGES: Final = "context_messages" CONF_CONTEXT_MESSAGES: Final = "context_messages"
CONF_STRUCTURED_OUTPUT: Final = "structured_output" CONF_STRUCTURED_OUTPUT: Final = "structured_output"
CONF_JSON_SCHEMA: Final = "json_schema" 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) ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100)
MAX_ATTRIBUTE_SIZE = 4 * 1024 MAX_ATTRIBUTE_SIZE = 4 * 1024
@@ -56,8 +58,11 @@ MAX_HISTORY_FILE_SIZE = 1 * 1024 * 1024
# Default values # Default values
DEFAULT_MODEL: Final = "gpt-4o-mini" DEFAULT_MODEL: Final = "gpt-4o-mini"
DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6" DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat" # deepseek-chat/deepseek-reasoner are discontinued 2026-07-24; V4 models
DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash" # 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_TEMPERATURE: Final = 0.1
DEFAULT_MAX_TOKENS: Final = 1000 DEFAULT_MAX_TOKENS: Final = 1000
DEFAULT_REQUEST_INTERVAL: Final = 1.0 DEFAULT_REQUEST_INTERVAL: Final = 1.0
@@ -67,6 +72,8 @@ DEFAULT_NAME: Final = "HA Text AI"
DEFAULT_NAME_PREFIX = "ha_text_ai" DEFAULT_NAME_PREFIX = "ha_text_ai"
DEFAULT_INSTANCE_NAME: Final = "my_assistant" DEFAULT_INSTANCE_NAME: Final = "my_assistant"
DEFAULT_CONTEXT_MESSAGES: Final = 5 DEFAULT_CONTEXT_MESSAGES: Final = 5
DEFAULT_ALLOW_LOCAL_NETWORK: Final = False
DEFAULT_DISABLE_THINKING: Final = False
MIN_CONTEXT_MESSAGES: Final = 1 MIN_CONTEXT_MESSAGES: Final = 1
MAX_CONTEXT_MESSAGES: Final = 20 MAX_CONTEXT_MESSAGES: Final = 20
MIN_HISTORY_SIZE: Final = 1 MIN_HISTORY_SIZE: Final = 1
+33 -26
View File
@@ -1,7 +1,7 @@
""" """
The HA Text AI coordinator. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai @source: https://github.com/smkrv/ha-text-ai
@@ -12,7 +12,7 @@ import asyncio
import logging import logging
import os import os
from datetime import timedelta from datetime import timedelta
from typing import Any, Dict, List, Optional from typing import Any
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -23,6 +23,7 @@ from homeassistant.util import dt as dt_util
from .const import ( from .const import (
DEFAULT_API_TIMEOUT, DEFAULT_API_TIMEOUT,
DEFAULT_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES,
DEFAULT_DISABLE_THINKING,
DEFAULT_MAX_HISTORY, DEFAULT_MAX_HISTORY,
DEFAULT_MAX_TOKENS, DEFAULT_MAX_TOKENS,
DEFAULT_TEMPERATURE, DEFAULT_TEMPERATURE,
@@ -39,7 +40,6 @@ from .utils import normalize_name
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class HATextAICoordinator(DataUpdateCoordinator): class HATextAICoordinator(DataUpdateCoordinator):
"""Home Assistant Text AI Conversation Coordinator.""" """Home Assistant Text AI Conversation Coordinator."""
@@ -56,6 +56,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
max_history_size: int = DEFAULT_MAX_HISTORY, max_history_size: int = DEFAULT_MAX_HISTORY,
context_messages: int = DEFAULT_CONTEXT_MESSAGES, context_messages: int = DEFAULT_CONTEXT_MESSAGES,
api_timeout: int = DEFAULT_API_TIMEOUT, api_timeout: int = DEFAULT_API_TIMEOUT,
disable_thinking: bool = DEFAULT_DISABLE_THINKING,
) -> None: ) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
self.instance_name = instance_name self.instance_name = instance_name
@@ -89,6 +90,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
self.temperature = temperature self.temperature = temperature
self.max_tokens = max_tokens self.max_tokens = max_tokens
self.api_timeout = api_timeout self.api_timeout = api_timeout
self.disable_thinking = disable_thinking
# Concurrency control # Concurrency control
self._request_lock = asyncio.Lock() self._request_lock = asyncio.Lock()
@@ -98,9 +100,9 @@ class HATextAICoordinator(DataUpdateCoordinator):
self._is_rate_limited = False self._is_rate_limited = False
self._is_maintenance = False self._is_maintenance = False
self.endpoint_status = "ready" 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(), "timestamp": dt_util.utcnow().isoformat(),
"question": "", "question": "",
"response": "", "response": "",
@@ -129,7 +131,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
# Convenience accessors for backward compatibility # Convenience accessors for backward compatibility
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@property @property
def _conversation_history(self) -> List[Dict[str, Any]]: def _conversation_history(self) -> list[dict[str, Any]]:
return self._history.conversation_history return self._history.conversation_history
@property @property
@@ -152,12 +154,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
# Last response # Last response
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@property @property
def last_response(self) -> Dict[str, Any]: def last_response(self) -> dict[str, Any]:
"""Get the last response.""" """Get the last response."""
return self._last_response return self._last_response
@last_response.setter @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 self._last_response = value
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -170,7 +172,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
except Exception as err: except Exception as err:
_LOGGER.error("Error updating HA state for %s: %s", self.instance_name, 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.""" """Update coordinator data."""
try: try:
current_state = self._get_current_state() current_state = self._get_current_state()
@@ -206,13 +208,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
async def async_ask_question( async def async_ask_question(
self, self,
question: str, question: str,
model: Optional[str] = None, model: str | None = None,
temperature: Optional[float] = None, temperature: float | None = None,
max_tokens: Optional[int] = None, max_tokens: int | None = None,
system_prompt: Optional[str] = None, system_prompt: str | None = None,
context_messages: Optional[int] = None, context_messages: int | None = None,
structured_output: bool = False, structured_output: bool = False,
json_schema: Optional[str] = None, json_schema: str | None = None,
disable_thinking: bool | None = None,
) -> dict: ) -> dict:
"""Process question with context management.""" """Process question with context management."""
if self.client is None: if self.client is None:
@@ -228,6 +231,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
temp_temperature = temperature if temperature is not None else self.temperature 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_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_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() start_time = dt_util.utcnow()
@@ -250,6 +254,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
max_tokens=temp_max_tokens, max_tokens=temp_max_tokens,
structured_output=structured_output, structured_output=structured_output,
json_schema=json_schema, json_schema=json_schema,
disable_thinking=temp_disable_thinking,
) )
latency = (dt_util.utcnow() - start_time).total_seconds() latency = (dt_util.utcnow() - start_time).total_seconds()
@@ -263,7 +268,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
if error_details.get("is_connection_error"): if error_details.get("is_connection_error"):
self.endpoint_status = "unavailable" self.endpoint_status = "unavailable"
self.last_response = error_details self.last_response = error_details
raise HomeAssistantError(f"Failed to process question: {err}") raise HomeAssistantError(f"Failed to process question: {err}") from err
finally: finally:
self._is_processing = False self._is_processing = False
@@ -273,11 +278,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
self, self,
question: str, question: str,
model: str, model: str,
messages: List[Dict[str, str]], messages: list[dict[str, str]],
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
structured_output: bool = False, structured_output: bool = False,
json_schema: Optional[str] = None, json_schema: str | None = None,
disable_thinking: bool = False,
) -> dict: ) -> dict:
"""Send request to AI provider and return structured response. """Send request to AI provider and return structured response.
@@ -292,6 +298,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
max_tokens=max_tokens, max_tokens=max_tokens,
structured_output=structured_output, structured_output=structured_output,
json_schema=json_schema, json_schema=json_schema,
disable_thinking=disable_thinking,
) )
# Reset error state on success # Reset error state on success
@@ -340,12 +347,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
async def async_get_history( async def async_get_history(
self, self,
limit: Optional[int] = None, limit: int | None = None,
filter_model: Optional[str] = None, filter_model: str | None = None,
start_date: Optional[str] = None, start_date: str | None = None,
include_metadata: bool = False, include_metadata: bool = False,
sort_order: str = "newest", sort_order: str = "newest",
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get conversation history with optional filtering.""" """Get conversation history with optional filtering."""
return await self._history.async_get_history( return await self._history.async_get_history(
limit=limit, limit=limit,
@@ -375,7 +382,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
return STATE_ERROR return STATE_ERROR
return STATE_READY return STATE_READY
def _get_safe_initial_state(self) -> Dict[str, Any]: def _get_safe_initial_state(self) -> dict[str, Any]:
return { return {
"state": STATE_ERROR, "state": STATE_ERROR,
"metrics": {}, "metrics": {},
@@ -395,7 +402,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
"normalized_name": self.normalized_name, "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.""" """Get sanitized version of last response with truncation."""
response = self.last_response.copy() response = self.last_response.copy()
@@ -414,7 +421,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
def _calculate_uptime(self) -> float: def _calculate_uptime(self) -> float:
return (dt_util.utcnow() - self._start_time).total_seconds() 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: if not self._system_prompt:
return None return None
if len(self._system_prompt) <= 4096: if len(self._system_prompt) <= 4096:
@@ -422,7 +429,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
return self._system_prompt[:4096] + TRUNCATION_INDICATOR return self._system_prompt[:4096] + TRUNCATION_INDICATOR
@staticmethod @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"): for key in ("state", "metrics", "last_response"):
if key not in data: if key not in data:
raise ValueError(f"Missing required key: {key}") raise ValueError(f"Missing required key: {key}")
+32 -11
View File
@@ -1,7 +1,7 @@
""" """
History management for HA Text AI integration. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai @source: https://github.com/smkrv/ha-text-ai
@@ -14,7 +14,7 @@ import os
import shutil import shutil
import traceback import traceback
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any
import aiofiles import aiofiles
@@ -34,7 +34,6 @@ MAX_ARCHIVE_FILES = 3
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class AsyncFileHandler: class AsyncFileHandler:
"""Async context manager for file operations.""" """Async context manager for file operations."""
@@ -49,6 +48,16 @@ class AsyncFileHandler:
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.file.close() 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: class HistoryManager:
"""Manages conversation history for an instance.""" """Manages conversation history for an instance."""
@@ -72,10 +81,10 @@ class HistoryManager:
history_dir, f"{normalized_name}_history.json" history_dir, f"{normalized_name}_history.json"
) )
self._max_history_file_size = MAX_HISTORY_FILE_SIZE self._max_history_file_size = MAX_HISTORY_FILE_SIZE
self._conversation_history: List[Dict[str, Any]] = [] self._conversation_history: list[dict[str, Any]] = []
@property @property
def conversation_history(self) -> List[Dict[str, Any]]: def conversation_history(self) -> list[dict[str, Any]]:
return self._conversation_history return self._conversation_history
@property @property
@@ -242,6 +251,9 @@ class HistoryManager:
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json", 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( await self.hass.async_add_executor_job(
shutil.move, self._history_file, archive_file shutil.move, self._history_file, archive_file
) )
@@ -280,6 +292,9 @@ class HistoryManager:
archives = await self.hass.async_add_executor_job(find_archives) archives = await self.hass.async_add_executor_job(find_archives)
if len(archives) > MAX_ARCHIVE_FILES: if len(archives) > MAX_ARCHIVE_FILES:
for old_file in 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) await self.hass.async_add_executor_job(os.remove, old_file)
_LOGGER.debug("Removed old archive: %s", old_file) _LOGGER.debug("Removed old archive: %s", old_file)
except Exception as e: except Exception as e:
@@ -359,6 +374,9 @@ class HistoryManager:
try: try:
self._conversation_history = [] self._conversation_history = []
if await self._file_exists(self._history_file): 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) await self.hass.async_add_executor_job(os.remove, self._history_file)
_LOGGER.info("History for %s cleared", self.instance_name) _LOGGER.info("History for %s cleared", self.instance_name)
except Exception as e: except Exception as e:
@@ -367,13 +385,13 @@ class HistoryManager:
async def async_get_history( async def async_get_history(
self, self,
limit: Optional[int] = None, limit: int | None = None,
filter_model: Optional[str] = None, filter_model: str | None = None,
start_date: Optional[str] = None, start_date: str | None = None,
include_metadata: bool = False, include_metadata: bool = False,
sort_order: str = "newest", sort_order: str = "newest",
default_model: str = "", default_model: str = "",
) -> List[Dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get conversation history with optional filtering and sorting.""" """Get conversation history with optional filtering and sorting."""
try: try:
history = self._conversation_history.copy() history = self._conversation_history.copy()
@@ -404,8 +422,11 @@ class HistoryManager:
else: else:
history.sort(key=lambda x: x.get("timestamp", ""), reverse=True) 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: if limit and limit > 0:
history = history[:limit] effective_limit = min(int(limit), ABSOLUTE_MAX_HISTORY_SIZE)
history = history[:effective_limit]
if include_metadata: if include_metadata:
enriched = [] enriched = []
@@ -426,7 +447,7 @@ class HistoryManager:
_LOGGER.error("Error getting history: %s", e) _LOGGER.error("Error getting history: %s", e)
return [] 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. """Get limited conversation history for sensor attributes.
Returns last `max_display` entries with truncated text for HA state. 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", "issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"loggers": ["custom_components.ha_text_ai"], "loggers": ["custom_components.ha_text_ai"],
"requirements": [ "requirements": [
"aiofiles>=23.0.0", "aiofiles>=23.0.0,<25.0.0",
"google-genai>=1.16.0" "google-genai>=1.16.0,<2.0.0"
], ],
"single_config_entry": false, "single_config_entry": false,
"version": "2.4.0" "version": "2.5.1"
} }
+27 -15
View File
@@ -1,7 +1,7 @@
""" """
Metrics management for HA Text AI integration. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai @source: https://github.com/smkrv/ha-text-ai
@@ -13,7 +13,7 @@ import logging
import os import os
import re import re
import traceback import traceback
from typing import Any, Dict from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@@ -21,7 +21,7 @@ from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_METRICS: Dict[str, Any] = { DEFAULT_METRICS: dict[str, Any] = {
"total_tokens": 0, "total_tokens": 0,
"prompt_tokens": 0, "prompt_tokens": 0,
"completion_tokens": 0, "completion_tokens": 0,
@@ -33,7 +33,6 @@ DEFAULT_METRICS: Dict[str, Any] = {
"min_latency": 0, "min_latency": 0,
} }
class MetricsManager: class MetricsManager:
"""Manages performance metrics for an instance.""" """Manages performance metrics for an instance."""
@@ -46,10 +45,10 @@ class MetricsManager:
self.hass = hass self.hass = hass
self.instance_name = instance_name self.instance_name = instance_name
self._metrics_file = metrics_file self._metrics_file = metrics_file
self._performance_metrics: Dict[str, Any] = DEFAULT_METRICS.copy() self._performance_metrics: dict[str, Any] = DEFAULT_METRICS.copy()
@property @property
def metrics(self) -> Dict[str, Any]: def metrics(self) -> dict[str, Any]:
return self._performance_metrics return self._performance_metrics
async def async_initialize(self) -> None: async def async_initialize(self) -> None:
@@ -57,7 +56,7 @@ class MetricsManager:
loaded = await self._load_metrics() loaded = await self._load_metrics()
self._performance_metrics = loaded or DEFAULT_METRICS.copy() 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: try:
exists = await self.hass.async_add_executor_job( exists = await self.hass.async_add_executor_job(
os.path.exists, self._metrics_file os.path.exists, self._metrics_file
@@ -108,7 +107,7 @@ class MetricsManager:
await self._save_metrics() 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.""" """Get current performance metrics."""
return self._performance_metrics.copy() return self._performance_metrics.copy()
@@ -116,24 +115,37 @@ class MetricsManager:
self, self,
error: Exception, error: Exception,
model: str, model: str,
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Record an error in metrics and return error details.""" """Record an error in metrics and return error details."""
self._performance_metrics["total_errors"] += 1 self._performance_metrics["total_errors"] += 1
self._performance_metrics["failed_requests"] += 1 self._performance_metrics["failed_requests"] += 1
await self._save_metrics() await self._save_metrics()
error_msg = str(error) 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'https?://\S+', '[URL]', error_msg)
error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg) error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg)
error_msg = re.sub(r'AIza[A-Za-z0-9_-]+', '***', error_msg) # Google API key: fixed prefix + 30+ url-safe chars, bounded by non-key char.
error_msg = re.sub(r'Bearer\s+\S+', 'Bearer ***', error_msg) error_msg = re.sub(
error_msg = re.sub(r'sk-[A-Za-z0-9_-]{20,}', '***', error_msg) r'AIza[A-Za-z0-9_\-]{30,}(?=[^A-Za-z0-9_\-]|$)', '***', error_msg
error_msg = re.sub(r'x-api-key:\s*\S+', 'x-api-key: ***', error_msg, flags=re.IGNORECASE) )
# 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: if len(error_msg) > 256:
error_msg = error_msg[:256] + "..." error_msg = error_msg[:256] + "..."
error_details: Dict[str, Any] = { error_details: dict[str, Any] = {
"timestamp": dt_util.utcnow().isoformat(), "timestamp": dt_util.utcnow().isoformat(),
"model": model, "model": model,
"instance": self.instance_name, "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 Centralizes provider-specific configuration to avoid dispatch duplication
across __init__.py, config_flow.py, and api_client.py. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: 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]: def get_provider_config(provider: str) -> dict[str, Any]:
"""Get full provider configuration. """Get full provider configuration.
@@ -73,17 +72,14 @@ def get_provider_config(provider: str) -> dict[str, Any]:
raise ValueError(f"Unknown API provider: {provider}") raise ValueError(f"Unknown API provider: {provider}")
return PROVIDER_REGISTRY[provider] return PROVIDER_REGISTRY[provider]
def get_default_endpoint(provider: str) -> str: def get_default_endpoint(provider: str) -> str:
"""Get default API endpoint for a provider.""" """Get default API endpoint for a provider."""
return get_provider_config(provider)["default_endpoint"] return get_provider_config(provider)["default_endpoint"]
def get_default_model(provider: str) -> str: def get_default_model(provider: str) -> str:
"""Get default model for a provider.""" """Get default model for a provider."""
return get_provider_config(provider)["default_model"] return get_provider_config(provider)["default_model"]
def build_auth_headers(provider: str, api_key: str) -> dict[str, str]: def build_auth_headers(provider: str, api_key: str) -> dict[str, str]:
"""Build authentication headers for a provider.""" """Build authentication headers for a provider."""
config = get_provider_config(provider) config = get_provider_config(provider)
+6 -6
View File
@@ -1,7 +1,7 @@
""" """
Sensor platform for HA Text AI. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: 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 logging
import math import math
from typing import Any, Dict from typing import Any
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -79,7 +79,6 @@ _LOGGER = logging.getLogger(__name__)
_ATTR_TEXT_LIMIT = 2048 _ATTR_TEXT_LIMIT = 2048
_ATTR_PROMPT_LIMIT = 512 _ATTR_PROMPT_LIMIT = 512
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
@@ -161,6 +160,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
manufacturer="Community", manufacturer="Community",
model=f"{model} ({api_provider} provider)", model=f"{model} ({api_provider} provider)",
sw_version=VERSION, sw_version=VERSION,
entry_type=DeviceEntryType.SERVICE,
) )
_LOGGER.debug( _LOGGER.debug(
@@ -184,7 +184,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
return None return None
return value 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.""" """Sanitize all attributes for JSON serialization."""
sanitized = { sanitized = {
key: self._sanitize_value(value) key: self._sanitize_value(value)
@@ -230,7 +230,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
return ENTITY_ICON return ENTITY_ICON
@property @property
def extra_state_attributes(self) -> Dict[str, Any]: def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes.""" """Return entity specific state attributes."""
if not self.coordinator.data: if not self.coordinator.data:
return {} return {}
@@ -92,6 +92,15 @@ ask_question:
text: text:
multiline: true 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: clear_history:
name: Clear History name: Clear History
description: >- description: >-
+13 -3
View File
@@ -14,7 +14,9 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)" "max_history_size": "Maximum conversation history size (1-100)",
"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": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)" "max_history_size": "Maximum conversation history size (1-100)",
"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)"
} }
} }
}, },
@@ -83,7 +87,9 @@
"request_interval": "Minimum request interval (0.1-60 seconds)", "request_interval": "Minimum request interval (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)", "context_messages": "Number of previous messages to include in context (1-20)",
"max_history_size": "Maximum conversation history size (1-100)" "max_history_size": "Maximum conversation history size (1-100)",
"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)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "JSON Schema", "name": "JSON Schema",
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled." "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."
} }
} }
}, },
@@ -14,7 +14,9 @@
"request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)", "request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)", "context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)" "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"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": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)", "request_interval": "Minimale Zeit zwischen Anfragen (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)", "context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)" "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"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)"
} }
} }
}, },
@@ -42,6 +46,7 @@
"name_exists": "Eine Instanz mit diesem Namen existiert bereits", "name_exists": "Eine Instanz mit diesem Namen existiert bereits",
"invalid_name": "Ungültiger Instanzname", "invalid_name": "Ungültiger Instanzname",
"invalid_auth": "Authentifizierung fehlgeschlagen - überprüfen Sie Ihren API-Schlüssel", "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", "invalid_api_key": "Ungültiger API-Schlüssel - bitte überprüfen Sie Ihre Anmeldeinformationen",
"cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen", "cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen",
"invalid_model": "Ausgewähltes Modell ist nicht verfügbar", "invalid_model": "Ausgewähltes Modell ist nicht verfügbar",
@@ -55,7 +60,6 @@
"invalid_instance": "Ungültige Instanz angegeben", "invalid_instance": "Ungültige Instanz angegeben",
"unknown": "Unerwarteter Fehler aufgetreten", "unknown": "Unerwarteter Fehler aufgetreten",
"empty": "Name darf nicht leer sein", "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" "name_too_long": "Name darf höchstens 50 Zeichen lang sein"
}, },
"abort": { "abort": {
@@ -83,7 +87,9 @@
"request_interval": "Minimales Anfrageintervall (0,1-60 Sekunden)", "request_interval": "Minimales Anfrageintervall (0,1-60 Sekunden)",
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)", "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)" "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"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)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "JSON-Schema", "name": "JSON-Schema",
"description": "JSON-Schema, das die Struktur der erwarteten Antwort definiert. Erforderlich wenn structured_output aktiviert ist." "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."
} }
} }
}, },
@@ -208,8 +218,7 @@
"rate_limited": "Rate limitiert", "rate_limited": "Rate limitiert",
"maintenance": "Wartung", "maintenance": "Wartung",
"initializing": "Initialisierung", "initializing": "Initialisierung",
"retrying": "Wiederholen", "retrying": "Wiederholen"
"queued": "In der Warteschlange"
}, },
"state_attributes": { "state_attributes": {
"question": { "question": {
@@ -301,6 +310,21 @@
}, },
"min_latency": { "min_latency": {
"name": "Minimale Latenz" "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"
} }
} }
} }
@@ -14,7 +14,9 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)" "max_history_size": "Maximum conversation history size (1-100)",
"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": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "Minimum time between requests (0.1-60 seconds)", "request_interval": "Minimum time between requests (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)", "context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)" "max_history_size": "Maximum conversation history size (1-100)",
"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)"
} }
} }
}, },
@@ -83,7 +87,9 @@
"request_interval": "Minimum request interval (0.1-60 seconds)", "request_interval": "Minimum request interval (0.1-60 seconds)",
"api_timeout": "API request timeout in seconds (5-600)", "api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)", "context_messages": "Number of previous messages to include in context (1-20)",
"max_history_size": "Maximum conversation history size (1-100)" "max_history_size": "Maximum conversation history size (1-100)",
"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)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "JSON Schema", "name": "JSON Schema",
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled." "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."
} }
} }
}, },
@@ -14,7 +14,9 @@
"request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)", "request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)", "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)" "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)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
} }
}, },
"user": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)", "request_interval": "Tiempo mínimo entre solicitudes (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)", "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)" "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)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
} }
} }
}, },
@@ -42,6 +46,7 @@
"name_exists": "Ya existe una instancia con este nombre", "name_exists": "Ya existe una instancia con este nombre",
"invalid_name": "Nombre de instancia no válido", "invalid_name": "Nombre de instancia no válido",
"invalid_auth": "La autenticación falló - verifica tu clave API", "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", "invalid_api_key": "Clave API no válida - verifica tus credenciales",
"cannot_connect": "Error al conectar con el servicio de API", "cannot_connect": "Error al conectar con el servicio de API",
"invalid_model": "El modelo seleccionado no está disponible", "invalid_model": "El modelo seleccionado no está disponible",
@@ -55,7 +60,6 @@
"invalid_instance": "Instancia no válida especificada", "invalid_instance": "Instancia no válida especificada",
"unknown": "Ocurrió un error inesperado", "unknown": "Ocurrió un error inesperado",
"empty": "El nombre no puede estar vacío", "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" "name_too_long": "El nombre debe tener 50 caracteres o menos"
}, },
"abort": { "abort": {
@@ -83,7 +87,9 @@
"request_interval": "Intervalo mínimo de solicitud (0.1-60 segundos)", "request_interval": "Intervalo mínimo de solicitud (0.1-60 segundos)",
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)", "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)", "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)" "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)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "Esquema JSON", "name": "Esquema JSON",
"description": "Esquema JSON que define la estructura de la respuesta esperada. Requerido cuando structured_output está habilitado." "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."
} }
} }
}, },
@@ -208,8 +218,7 @@
"rate_limited": "Limitado por tasa", "rate_limited": "Limitado por tasa",
"maintenance": "Mantenimiento", "maintenance": "Mantenimiento",
"initializing": "Inicializando", "initializing": "Inicializando",
"retrying": "Reintentando", "retrying": "Reintentando"
"queued": "En cola"
}, },
"state_attributes": { "state_attributes": {
"question": { "question": {
@@ -301,6 +310,21 @@
}, },
"min_latency": { "min_latency": {
"name": "Latencia Mínima" "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"
} }
} }
} }
@@ -14,7 +14,9 @@
"request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)", "request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)", "context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
} }
}, },
"user": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)", "request_interval": "अनुरोधों के बीच न्यूनतम समय (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)", "context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
} }
} }
}, },
@@ -42,6 +46,7 @@
"name_exists": "इस नाम के साथ एक उदाहरण पहले से मौजूद है", "name_exists": "इस नाम के साथ एक उदाहरण पहले से मौजूद है",
"invalid_name": "अमान्य उदाहरण नाम", "invalid_name": "अमान्य उदाहरण नाम",
"invalid_auth": "प्रमाणीकरण विफल - अपनी एपीआई कुंजी की जांच करें", "invalid_auth": "प्रमाणीकरण विफल - अपनी एपीआई कुंजी की जांच करें",
"api_key_required": "प्रदाता या endpoint बदलते समय API कुंजी आवश्यक है",
"invalid_api_key": "अमान्य एपीआई कुंजी - कृपया अपनी क्रेडेंशियल्स की पुष्टि करें", "invalid_api_key": "अमान्य एपीआई कुंजी - कृपया अपनी क्रेडेंशियल्स की पुष्टि करें",
"cannot_connect": "एपीआई सेवा से कनेक्ट करने में विफल", "cannot_connect": "एपीआई सेवा से कनेक्ट करने में विफल",
"invalid_model": "चुना हुआ मॉडल उपलब्ध नहीं है", "invalid_model": "चुना हुआ मॉडल उपलब्ध नहीं है",
@@ -55,7 +60,6 @@
"invalid_instance": "अमान्य उदाहरण निर्दिष्ट किया गया", "invalid_instance": "अमान्य उदाहरण निर्दिष्ट किया गया",
"unknown": "अप्रत्याशित त्रुटि हुई", "unknown": "अप्रत्याशित त्रुटि हुई",
"empty": "नाम खाली नहीं हो सकता", "empty": "नाम खाली नहीं हो सकता",
"invalid_characters": "नाम में केवल अक्षर, अंक, रिक्त स्थान, अंडरस्कोर और हाइफन हो सकते हैं",
"name_too_long": "नाम 50 अक्षरों या उससे कम होना चाहिए" "name_too_long": "नाम 50 अक्षरों या उससे कम होना चाहिए"
}, },
"abort": { "abort": {
@@ -83,7 +87,9 @@
"request_interval": "न्यूनतम अनुरोध अंतराल (0.1-60 सेकंड)", "request_interval": "न्यूनतम अनुरोध अंतराल (0.1-60 सेकंड)",
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)", "api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)", "context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)" "max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "JSON स्कीमा", "name": "JSON स्कीमा",
"description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।" "description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।"
},
"disable_thinking": {
"name": "Thinking बंद करें",
"description": "इस अनुरोध के लिए thinking/reasoning मोड बंद करें। एकीकरण सेटिंग को ओवरराइड करता है।"
} }
} }
}, },
@@ -208,8 +218,7 @@
"rate_limited": "रेट सीमित", "rate_limited": "रेट सीमित",
"maintenance": "रखरखाव", "maintenance": "रखरखाव",
"initializing": "प्रारंभिककरण", "initializing": "प्रारंभिककरण",
"retrying": "पुनः प्रयास कर रहा है", "retrying": "पुनः प्रयास कर रहा है"
"queued": "क्यू में"
}, },
"state_attributes": { "state_attributes": {
"question": { "question": {
@@ -301,6 +310,21 @@
}, },
"min_latency": { "min_latency": {
"name": "न्यूनतम विलंबता" "name": "न्यूनतम विलंबता"
},
"last_model": {
"name": "अंतिम उपयोग किया गया मॉडल"
},
"last_timestamp": {
"name": "अंतिम प्रतिक्रिया समय"
},
"instance_name": {
"name": "इंस्टेंस नाम"
},
"normalized_name": {
"name": "सामान्यीकृत नाम"
},
"conversation_history": {
"name": "वार्तालाप इतिहास"
} }
} }
} }
@@ -14,7 +14,9 @@
"request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)", "request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)", "api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)", "context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)" "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"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": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)", "request_interval": "Tempo minimo tra le richieste (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)", "api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)", "context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)" "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"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)"
} }
} }
}, },
@@ -42,6 +46,7 @@
"name_exists": "Esiste già un'istanza con questo nome", "name_exists": "Esiste già un'istanza con questo nome",
"invalid_name": "Nome dell'istanza non valido", "invalid_name": "Nome dell'istanza non valido",
"invalid_auth": "Autenticazione fallita - controlla la tua chiave API", "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", "invalid_api_key": "Chiave API non valida - verifica le tue credenziali",
"cannot_connect": "Impossibile connettersi al servizio API", "cannot_connect": "Impossibile connettersi al servizio API",
"invalid_model": "Il modello selezionato non è disponibile", "invalid_model": "Il modello selezionato non è disponibile",
@@ -55,7 +60,6 @@
"invalid_instance": "Istanze specificata non valida", "invalid_instance": "Istanze specificata non valida",
"unknown": "Si è verificato un errore imprevisto", "unknown": "Si è verificato un errore imprevisto",
"empty": "Il nome non può essere vuoto", "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" "name_too_long": "Il nome deve essere lungo 50 caratteri o meno"
}, },
"abort": { "abort": {
@@ -83,7 +87,9 @@
"request_interval": "Intervallo minimo di richiesta (0.1-60 secondi)", "request_interval": "Intervallo minimo di richiesta (0.1-60 secondi)",
"api_timeout": "Timeout della richiesta API in secondi (5-600)", "api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)", "context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)" "max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"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)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "Schema JSON", "name": "Schema JSON",
"description": "Schema JSON che definisce la struttura della risposta attesa. Richiesto quando structured_output è abilitato." "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."
} }
} }
}, },
@@ -208,8 +218,7 @@
"rate_limited": "Limite di frequenza", "rate_limited": "Limite di frequenza",
"maintenance": "Manutenzione", "maintenance": "Manutenzione",
"initializing": "Inizializzazione", "initializing": "Inizializzazione",
"retrying": "Riprova", "retrying": "Riprova"
"queued": "In coda"
}, },
"state_attributes": { "state_attributes": {
"question": { "question": {
@@ -301,6 +310,21 @@
}, },
"min_latency": { "min_latency": {
"name": "Latenza minima" "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"
} }
} }
} }
@@ -14,7 +14,9 @@
"request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)", "api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)", "context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)" "max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
} }
}, },
"user": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)", "api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)", "context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)" "max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
} }
} }
}, },
@@ -83,7 +87,9 @@
"request_interval": "Минимальный интервал между запросами (0.1-60 секунд)", "request_interval": "Минимальный интервал между запросами (0.1-60 секунд)",
"api_timeout": "Таймаут API-запроса в секундах (5-600)", "api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)", "context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)" "max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "JSON Schema", "name": "JSON Schema",
"description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output." "description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output."
},
"disable_thinking": {
"name": "Отключить thinking",
"description": "Отключить режим thinking/reasoning для этого запроса. Переопределяет настройку интеграции."
} }
} }
}, },
@@ -14,7 +14,9 @@
"request_interval": "Минимално време између захтева (0.1-60 секунди)", "request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)", "context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)" "max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
} }
}, },
"user": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "Минимално време између захтева (0.1-60 секунди)", "request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)", "context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)" "max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
} }
} }
}, },
@@ -42,6 +46,7 @@
"name_exists": "Инстанца са овим именом већ постоји", "name_exists": "Инстанца са овим именом већ постоји",
"invalid_name": "Неважеће име инстанце", "invalid_name": "Неважеће име инстанце",
"invalid_auth": "Аутентификација није успела - проверите ваш API кључ", "invalid_auth": "Аутентификација није успела - проверите ваш API кључ",
"api_key_required": "API кључ је обавезан при промени провајдера или endpoint-а",
"invalid_api_key": "Неважећи API кључ - молимо проверите ваше акредитиве", "invalid_api_key": "Неважећи API кључ - молимо проверите ваше акредитиве",
"cannot_connect": "Неуспело повезивање са API сервисом", "cannot_connect": "Неуспело повезивање са API сервисом",
"invalid_model": "Изабрани модел није доступан", "invalid_model": "Изабрани модел није доступан",
@@ -55,7 +60,6 @@
"invalid_instance": "Неважећа инстанца је назначена", "invalid_instance": "Неважећа инстанца је назначена",
"unknown": "Дошло је до неочекиване грешке", "unknown": "Дошло је до неочекиване грешке",
"empty": "Име не може бити празно", "empty": "Име не може бити празно",
"invalid_characters": "Име може садржавати само слова, цифре, размаке, подцрта и цртице",
"name_too_long": "Име мора бити 50 знакова или мање" "name_too_long": "Име мора бити 50 знакова или мање"
}, },
"abort": { "abort": {
@@ -83,7 +87,9 @@
"request_interval": "Минимално време између захтева (0.1-60 секунди)", "request_interval": "Минимално време између захтева (0.1-60 секунди)",
"api_timeout": "Временско ограничење API захтева у секундама (5-600)", "api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број претходних порука које треба укључити у контекст (1-20)", "context_messages": "Број претходних порука које треба укључити у контекст (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)" "max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "JSON шема", "name": "JSON шема",
"description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен." "description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен."
},
"disable_thinking": {
"name": "Онемогући thinking",
"description": "Онемогући thinking/reasoning режим за овај захтев. Надмашује поставку интеграције."
} }
} }
}, },
@@ -208,8 +218,7 @@
"rate_limited": "Ограничење захтева", "rate_limited": "Ограничење захтева",
"maintenance": "Одржавање", "maintenance": "Одржавање",
"initializing": "Инициализује се", "initializing": "Инициализује се",
"retrying": "Покушава поново", "retrying": "Покушава поново"
"queued": "У реду"
}, },
"state_attributes": { "state_attributes": {
"question": { "question": {
@@ -301,6 +310,21 @@
}, },
"min_latency": { "min_latency": {
"name": "Минимална латенција" "name": "Минимална латенција"
},
"last_model": {
"name": "Последњи коришћени модел"
},
"last_timestamp": {
"name": "Време последњег одговора"
},
"instance_name": {
"name": "Назив инстанце"
},
"normalized_name": {
"name": "Нормализовани назив"
},
"conversation_history": {
"name": "Историја разговора"
} }
} }
} }
@@ -14,7 +14,9 @@
"request_interval": "请求之间的最小时间(0.1-60秒)", "request_interval": "请求之间的最小时间(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)", "api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20", "context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100" "max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0"
} }
}, },
"user": { "user": {
@@ -31,7 +33,9 @@
"request_interval": "请求之间的最小时间(0.1-60秒)", "request_interval": "请求之间的最小时间(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)", "api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20", "context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100" "max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0"
} }
} }
}, },
@@ -42,6 +46,7 @@
"name_exists": "具有此名称的实例已存在", "name_exists": "具有此名称的实例已存在",
"invalid_name": "无效的实例名称", "invalid_name": "无效的实例名称",
"invalid_auth": "身份验证失败 - 检查您的API密钥", "invalid_auth": "身份验证失败 - 检查您的API密钥",
"api_key_required": "更改提供商或端点时需要输入 API 密钥",
"invalid_api_key": "无效的API密钥 - 请验证您的凭据", "invalid_api_key": "无效的API密钥 - 请验证您的凭据",
"cannot_connect": "无法连接到API服务", "cannot_connect": "无法连接到API服务",
"invalid_model": "所选模型不可用", "invalid_model": "所选模型不可用",
@@ -55,7 +60,6 @@
"invalid_instance": "指定的实例无效", "invalid_instance": "指定的实例无效",
"unknown": "发生意外错误", "unknown": "发生意外错误",
"empty": "名称不能为空", "empty": "名称不能为空",
"invalid_characters": "名称只能包含字母、数字、空格、下划线和连字符",
"name_too_long": "名称必须少于50个字符" "name_too_long": "名称必须少于50个字符"
}, },
"abort": { "abort": {
@@ -83,7 +87,9 @@
"request_interval": "最小请求间隔(0.1-60秒)", "request_interval": "最小请求间隔(0.1-60秒)",
"api_timeout": "API请求超时时间(5-600秒)", "api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "要包含在上下文中的先前消息数量(1-20)", "context_messages": "要包含在上下文中的先前消息数量(1-20)",
"max_history_size": "最大对话历史大小(1-100" "max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0"
} }
} }
} }
@@ -138,6 +144,10 @@
"json_schema": { "json_schema": {
"name": "JSON模式", "name": "JSON模式",
"description": "定义预期响应结构的JSON模式。启用structured_output时必需。" "description": "定义预期响应结构的JSON模式。启用structured_output时必需。"
},
"disable_thinking": {
"name": "禁用思考",
"description": "为此请求禁用思考/推理模式。覆盖集成级别的设置。"
} }
} }
}, },
@@ -208,8 +218,7 @@
"rate_limited": "速率限制", "rate_limited": "速率限制",
"maintenance": "维护中", "maintenance": "维护中",
"initializing": "初始化中", "initializing": "初始化中",
"retrying": "重试中", "retrying": "重试中"
"queued": "排队中"
}, },
"state_attributes": { "state_attributes": {
"question": { "question": {
@@ -301,6 +310,21 @@
}, },
"min_latency": { "min_latency": {
"name": "最小延迟" "name": "最小延迟"
},
"last_model": {
"name": "最近使用的模型"
},
"last_timestamp": {
"name": "最近响应时间"
},
"instance_name": {
"name": "实例名称"
},
"normalized_name": {
"name": "规范化名称"
},
"conversation_history": {
"name": "对话历史"
} }
} }
} }
+191 -27
View File
@@ -1,28 +1,41 @@
""" """
Utility functions for HA Text AI integration. 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 @author: SMKRV
@github: https://github.com/smkrv/ha-text-ai @github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai @source: https://github.com/smkrv/ha-text-ai
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import ipaddress import ipaddress
import logging
import socket import socket
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import aiohttp
from aiohttp.abc import AbstractResolver
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
def normalize_name(name: str) -> str: def normalize_name(name: str) -> str:
"""Normalize name to conform to HA naming convention using underscores.""" """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()
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( def safe_log_data(
data: dict[str, Any], data: dict[str, Any],
@@ -31,11 +44,9 @@ def safe_log_data(
"""Filter sensitive keys from data for safe logging.""" """Filter sensitive keys from data for safe logging."""
return {k: "***" if k in sensitive_keys else v for k, v in data.items()} return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
class _RestrictedIPError(ValueError): class _RestrictedIPError(ValueError):
"""Raised when an IP address is in a restricted range.""" """Raised when an IP address is in a restricted range."""
def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""Check if an IP address is in a restricted range.""" """Check if an IP address is in a restricted range."""
return ( return (
@@ -47,48 +58,201 @@ def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) ->
or addr.is_unspecified 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) -> str: These must be blocked even in allow_local_network mode:
"""Validate API endpoint URL for security. - 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). 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. 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: Raises:
ValueError: If the endpoint fails validation. ValueError: If the endpoint fails validation.
""" """
parsed = urlparse(endpoint) parsed = urlparse(endpoint)
if parsed.scheme not in ("https",): if allow_local:
raise ValueError("Only HTTPS endpoints are allowed") if parsed.scheme not in ("https", "http"):
raise ValueError("Only HTTPS and HTTP endpoints are allowed")
else:
if parsed.scheme not in ("https",):
raise ValueError("Only HTTPS endpoints are allowed")
hostname = parsed.hostname hostname = parsed.hostname
if not hostname: if not hostname:
raise ValueError("Invalid endpoint URL: no hostname") raise ValueError("Invalid endpoint URL: no hostname")
# Block private/reserved IPs (direct IP or resolved hostname) 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: try:
addr = ipaddress.ip_address(hostname) addr = ipaddress.ip_address(hostname)
if _check_ip_restricted(addr): _is_ip_literal = True
raise _RestrictedIPError("Private/reserved IP addresses are not allowed")
except _RestrictedIPError:
raise
except ValueError: except ValueError:
# Not an IP literal — resolve hostname and check all resolved IPs addr = None
# to prevent DNS rebinding attacks _is_ip_literal = False
if _is_ip_literal:
_collect([hostname])
else:
try: try:
addrinfos = await hass.async_add_executor_job( addrinfos = await hass.async_add_executor_job(
socket.getaddrinfo, hostname, None 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: except socket.gaierror as err:
raise ValueError(f"Cannot resolve hostname: {hostname}") from 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
+16 -11
View File
@@ -1,22 +1,27 @@
``` ```
ha_text_ai/ custom_components/ha_text_ai/
├── __init__.py ├── __init__.py
├── api_client.py ├── api_client.py
├── config_flow.py ├── config_flow.py
├── const.py ├── const.py
├── coordinator.py ├── coordinator.py
├── icons ├── history.py
│   ├── dark_icon.png ├── metrics.py
│   ├── dark_icon@2x.png ├── providers.py
│   ├── dark_logo.png
│   ├── dark_logo@2x.png
│   ├── icon.png
│   ├── icon@2x.png
│   ├── logo.png
│   └── logo@2x.png
├── manifest.json
├── sensor.py ├── sensor.py
├── services.yaml ├── services.yaml
├── strings.json
├── utils.py
├── icons
│ ├── dark_icon.png
│ ├── dark_icon@2x.png
│ ├── dark_logo.png
│ ├── dark_logo@2x.png
│ ├── icon.png
│ ├── icon@2x.png
│ ├── logo.png
│ └── logo@2x.png
├── manifest.json
└── translations └── translations
├── de.json ├── de.json
├── en.json ├── en.json