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.
This commit is contained in:
SMKRV
2026-04-17 01:30:57 +03:00
parent 1e2ff81d07
commit 5af733b1b0
27 changed files with 475 additions and 226 deletions
+15
View File
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore"
labels:
- "dependencies"
- "github-actions"
groups:
actions:
patterns:
- "*"
+4 -3
View File
@@ -26,15 +26,16 @@ jobs:
timeout-minutes: 10
steps:
- name: ⤵️ Check out code from GitHub
- name: Check out code from GitHub
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: 🚀 Run hassfest validation
- name: Run hassfest validation
uses: home-assistant/actions/hassfest@master
- name: Print hassfest version
- name: Print hassfest version
if: always()
run: |
echo "Hassfest version: $(hassfest --version)"
+1
View File
@@ -17,6 +17,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- name: Create zip archive
run: |
+1
View File
@@ -30,3 +30,4 @@ Thumbs.db
CLAUDE.md
docs/specs/
docs/superpowers/
docs/plans/
+17 -127
View File
@@ -1,131 +1,21 @@
# PolyForm Noncommercial License 1.0.0
MIT License
<https://polyformproject.org/licenses/noncommercial/1.0.0>
Copyright (c) 2024-2026 SMKRV
## Acceptance
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
## Copyright License
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -2
View File
@@ -2,7 +2,7 @@
<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)
@@ -653,7 +653,7 @@ DEALINGS IN THE SOFTWARE.
## 📝 License
Author: SMKRV
[PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0) - see [LICENSE](LICENSE) for details.
[MIT License](https://opensource.org/licenses/MIT) - see [LICENSE](LICENSE) for details.
## 💡 Support the Project
+22 -5
View File
@@ -1,7 +1,7 @@
"""
The HA Text AI integration.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -51,6 +51,8 @@ from .const import (
CONF_MAX_HISTORY_SIZE,
CONF_ALLOW_LOCAL_NETWORK,
DEFAULT_ALLOW_LOCAL_NETWORK,
CONF_DISABLE_THINKING,
DEFAULT_DISABLE_THINKING,
)
_LOGGER = logging.getLogger(__name__)
@@ -69,6 +71,7 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
vol.Optional("context_messages"): cv.positive_int,
vol.Optional("structured_output", default=False): cv.boolean,
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
vol.Optional("disable_thinking"): cv.boolean,
})
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
@@ -78,7 +81,8 @@ SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
vol.Required("instance"): cv.string,
vol.Optional("limit"): cv.positive_int,
# Cap limit to protect automations from huge response payloads.
vol.Optional("limit", default=10): vol.All(cv.positive_int, vol.Range(min=1, max=100)),
vol.Optional("filter_model"): cv.string,
vol.Optional("start_date"): cv.string,
vol.Optional("include_metadata"): cv.boolean,
@@ -124,6 +128,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
context_messages=call.data.get("context_messages"),
structured_output=call.data.get("structured_output", False),
json_schema=call.data.get("json_schema"),
disable_thinking=call.data.get("disable_thinking"),
)
# Return structured response data
@@ -162,7 +167,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
await coordinator.async_clear_history()
except Exception as err:
_LOGGER.error("Error clearing history: %s", str(err))
raise HomeAssistantError(f"Failed to clear history: {str(err)}")
raise HomeAssistantError(f"Failed to clear history: {str(err)}") from err
async def async_get_history(call: ServiceCall) -> list:
"""Handle get_history service."""
@@ -177,7 +182,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
)
except Exception as err:
_LOGGER.error("Error getting history: %s", str(err))
raise HomeAssistantError(f"Failed to get history: {str(err)}")
raise HomeAssistantError(f"Failed to get history: {str(err)}") from err
async def async_set_system_prompt(call: ServiceCall) -> None:
"""Handle set_system_prompt service."""
@@ -186,7 +191,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
await coordinator.async_set_system_prompt(call.data["prompt"])
except Exception as err:
_LOGGER.error("Error setting system prompt: %s", str(err))
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}")
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}") from err
# Register services
hass.services.async_register(
@@ -294,6 +299,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
context_messages = config.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
disable_thinking = config.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING)
headers = build_auth_headers(api_provider, api_key)
@@ -324,6 +330,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
max_history_size=max_history_size,
context_messages=context_messages,
api_timeout=api_timeout,
disable_thinking=disable_thinking,
)
# Initialize coordinator (directories, history, metrics)
@@ -369,8 +376,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_shutdown()
# When removing the last config entry, also unregister services and
# clear the domain bucket so HA doesn't show stale services in the UI.
if not hass.data.get(DOMAIN):
hass.data.pop(DOMAIN, None)
for service in (
SERVICE_ASK_QUESTION,
SERVICE_CLEAR_HISTORY,
SERVICE_GET_HISTORY,
SERVICE_SET_SYSTEM_PROMPT,
):
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
return unload_ok
+203 -20
View File
@@ -1,7 +1,7 @@
"""
API Client for HA Text AI.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -91,10 +91,20 @@ class APIClient:
url: str,
payload: 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']}
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
retryable_5xx = {502, 503, 504}
for attempt in range(API_RETRY_COUNT):
try:
async with self.session.post(
@@ -114,25 +124,41 @@ class APIClient:
except Exception:
error_data = {"raw": await response.text()}
# Rate limit — retry with backoff
# Rate limit — retry with backoff, prefer Retry-After header
if response.status == 429:
_LOGGER.warning(
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
)
if attempt < API_RETRY_COUNT - 1:
await asyncio.sleep(2 ** attempt)
retry_after = self._parse_retry_after(
response.headers.get("Retry-After")
)
await asyncio.sleep(retry_after or (2 ** attempt))
continue
raise HomeAssistantError("API rate limit exceeded")
# Client/server errors — don't retry
# Upstream transient errors — retry with backoff
if response.status in retryable_5xx:
_LOGGER.warning(
"Upstream %d on attempt %d/%d",
response.status, attempt + 1, API_RETRY_COUNT,
)
if attempt < API_RETRY_COUNT - 1:
await asyncio.sleep(2 ** attempt)
continue
raise HomeAssistantError(
f"Upstream error after retries: status {response.status}"
)
# Other client/server errors — don't retry
truncated_error = str(error_data)[:512]
_LOGGER.error("API error (status %d): %s", response.status, truncated_error)
raise HomeAssistantError(f"API error: status {response.status}")
except asyncio.TimeoutError:
except asyncio.TimeoutError as err:
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
if attempt == API_RETRY_COUNT - 1:
raise HomeAssistantError("API request timed out")
raise HomeAssistantError("API request timed out") from err
await asyncio.sleep(2 ** attempt)
except HomeAssistantError:
raise
@@ -147,6 +173,19 @@ class APIClient:
raise HomeAssistantError("API request failed after all retries")
@staticmethod
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
"""Parse Retry-After header (seconds). Caps at 60s to avoid long stalls."""
if not value:
return None
try:
seconds = float(value.strip())
except (ValueError, AttributeError):
return None
if seconds <= 0:
return None
return min(seconds, 60.0)
async def create(
self,
model: str,
@@ -155,6 +194,7 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using appropriate API."""
try:
@@ -163,26 +203,103 @@ class APIClient:
if self.api_provider == API_PROVIDER_ANTHROPIC:
return await self._create_anthropic_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
elif self.api_provider == API_PROVIDER_DEEPSEEK:
return await self._create_deepseek_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
elif self.api_provider == API_PROVIDER_GEMINI:
return await self._create_gemini_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
else:
return await self._create_openai_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
except Exception as e:
_LOGGER.error("API request failed: %s", str(e))
raise HomeAssistantError(f"API request failed: {str(e)}")
raise HomeAssistantError(f"API request failed: {str(e)}") from e
@staticmethod
def _is_openai_reasoning_model(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". Cutoff: models released 2025-09 and later are all
reasoning-by-default (o3, o4-mini, gpt-5, gpt-5-mini, gpt-5-nano).
"""
if not model:
return False
m = model.lower().lstrip()
# Match bare model names and dated variants (o3-2025-04-16 etc.)
return (
m.startswith(("o1", "o3", "o4-mini", "o4"))
or m.startswith(("gpt-5", "gpt5"))
)
@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
]
@staticmethod
def _apply_no_think_tag(
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.
"""
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 "/no_think" not in content:
patched[i]["content"] = f"{content} /no_think".strip()
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
import re
pattern = re.compile(r"<think>.*?</think>", flags=re.DOTALL)
cleaned = text
# Iterative pass: each iteration peels one layer of nested tags.
# Bounded to 10 iterations to avoid pathological inputs.
for _ in range(10):
new = pattern.sub("", cleaned)
if new == cleaned:
break
cleaned = new
# If a truncated response left a dangling <think> open, drop the rest
# from that marker onward to avoid leaking partial reasoning.
if "<think>" in cleaned:
cleaned = cleaned.split("<think>", 1)[0]
return cleaned.strip()
@staticmethod
def _apply_structured_output(
@@ -216,12 +333,14 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using DeepSeek API."""
url = f"{self.endpoint}/chat/completions"
final_messages = self._apply_no_think_tag(messages) if disable_thinking else messages
payload = {
"model": model,
"messages": messages,
"messages": final_messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
@@ -229,10 +348,13 @@ class APIClient:
self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload)
content = data["choices"][0]["message"]["content"]
if disable_thinking:
content = self._strip_think_blocks(content)
return {
"choices": [
{
"message": {"content": data["choices"][0]["message"]["content"]},
"message": {"content": content},
}
],
"usage": {
@@ -250,22 +372,51 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using OpenAI API."""
"""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"
is_reasoning = self._is_openai_reasoning_model(model)
if is_reasoning:
prepared_messages = self._convert_system_to_developer(messages)
payload: Dict[str, Any] = {
"model": model,
"messages": prepared_messages,
"max_completion_tokens": max_tokens,
}
if disable_thinking:
payload["reasoning_effort"] = "low"
else:
prepared_messages = (
self._apply_no_think_tag(messages) if disable_thinking else messages
)
payload = {
"model": model,
"messages": messages,
"messages": prepared_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload)
content = data["choices"][0]["message"]["content"]
# Strip <think> blocks only for classic chat models. Reasoning models
# never emit the tags in user-facing content.
if disable_thinking and not is_reasoning:
content = self._strip_think_blocks(content)
return {
"choices": [
{
"message": {"content": data["choices"][0]["message"]["content"]},
"message": {"content": content},
}
],
"usage": {
@@ -283,6 +434,7 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using Anthropic API."""
url = f"{self.endpoint}/v1/messages"
@@ -298,8 +450,19 @@ class APIClient:
else:
filtered_messages.append(msg)
# For Anthropic, add structured output instruction to system prompt
# For Anthropic, add structured output instruction to system prompt.
# Validate schema is well-formed JSON before concatenation: untrusted
# schema strings (built from templates/webhook data) could otherwise
# break out of the JSON fence and rewrite the system instruction.
if structured_output and json_schema:
import json as _json
try:
_json.loads(json_schema)
except _json.JSONDecodeError as err:
_LOGGER.warning(
"Anthropic: invalid JSON schema, ignoring structured_output: %s", err
)
else:
schema_instruction = (
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
f"this JSON Schema:\n{json_schema}\n"
@@ -312,21 +475,28 @@ class APIClient:
system_prompt = schema_instruction.strip()
_LOGGER.debug("Anthropic structured output enabled via system prompt")
# Anthropic accepts temperature in [0, 1], not [0, 2] like OpenAI.
# Clip silently to avoid a 400 when a user-set config exceeds the cap.
clipped_temp = min(1.0, max(0.0, float(temperature)))
payload = {
"model": model,
"messages": filtered_messages,
"max_tokens": max_tokens,
"temperature": temperature,
"temperature": clipped_temp,
}
if system_prompt:
payload["system"] = system_prompt
data = await self._make_request(url, payload)
# Anthropic returns text in "content" array; extended thinking arrives
# as a separate thinking-type block (not <think> tags), so no strip
# is required. Extended thinking is opt-in — we simply never request it.
content = data["content"][0]["text"]
return {
"choices": [
{
"message": {"content": data["content"][0]["text"]},
"message": {"content": content},
}
],
"usage": {
@@ -344,6 +514,7 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using Gemini API with google-genai library.
@@ -418,6 +589,15 @@ class APIClient:
config.response_mime_type = "application/json"
config.response_schema = parsed_schema
# Disable thinking for Gemini 2.5+ models (ignored by 2.0 and earlier)
if disable_thinking:
try:
config.thinking_config = types.ThinkingConfig(thinking_budget=0)
except (AttributeError, TypeError) as err:
_LOGGER.debug(
"ThinkingConfig not supported by this google-genai version: %s", err
)
return config
config = await asyncio.to_thread(create_config)
@@ -491,6 +671,9 @@ class APIClient:
response_text, usage = await asyncio.to_thread(extract_response)
if disable_thinking:
response_text = self._strip_think_blocks(response_text)
return {
"choices": [{
"message": {
+21 -6
View File
@@ -1,7 +1,7 @@
"""
Config flow for HA text AI integration.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -56,6 +56,8 @@ from .const import (
MAX_HISTORY_SIZE,
CONF_ALLOW_LOCAL_NETWORK,
DEFAULT_ALLOW_LOCAL_NETWORK,
CONF_DISABLE_THINKING,
DEFAULT_DISABLE_THINKING,
)
from homeassistant.util import dt as dt_util
@@ -92,6 +94,10 @@ def _build_parameter_schema(data: Dict[str, Any]) -> dict:
CONF_MAX_HISTORY_SIZE,
default=data.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
): vol.All(vol.Coerce(int), vol.Range(min=MIN_HISTORY_SIZE, max=MAX_HISTORY_SIZE)),
vol.Optional(
CONF_DISABLE_THINKING,
default=data.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
): bool,
}
@@ -131,7 +137,9 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
defaults = data or {}
schema_dict = {
vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, DEFAULT_INSTANCE_NAME)): str,
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_API_KEY): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(CONF_MODEL, default=defaults.get(CONF_MODEL, get_default_model(self._provider))): str,
vol.Required(CONF_API_ENDPOINT, default=defaults.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
vol.Optional(
@@ -290,6 +298,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES),
CONF_MAX_HISTORY_SIZE: user_input.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
CONF_ALLOW_LOCAL_NETWORK: user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
CONF_DISABLE_THINKING: user_input.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
}
_LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
@@ -398,7 +407,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
api_key = user_input.get(CONF_API_KEY, "").strip()
endpoint = user_input.get(CONF_API_ENDPOINT, default_endpoint)
# Require API key re-entry when endpoint or provider changed
# Require API key re-entry when endpoint or provider changed.
# Why: reusing a stored key after provider/endpoint change could
# ship credentials to a different service (e.g. OpenAI key to
# api.anthropic.com). Always force explicit re-entry.
stored_endpoint = current_data.get(CONF_API_ENDPOINT, "")
endpoint_changed = endpoint != stored_endpoint
if not api_key and (provider_changed or endpoint_changed):
@@ -418,8 +430,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
}
)
# Fall back to stored key if not re-entered and endpoint unchanged
if not api_key:
# Fall back to stored key only when neither provider nor endpoint changed.
# Defensive: never silently reuse stored key across providers.
if not api_key and not provider_changed and not endpoint_changed:
api_key = current_data.get(CONF_API_KEY, "")
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
@@ -473,7 +486,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
data = user_input or current_data
schema_dict = {
vol.Optional(CONF_API_KEY, default=""): str,
vol.Optional(CONF_API_KEY, default=""): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(
CONF_API_ENDPOINT,
default=data.get(CONF_API_ENDPOINT, default_endpoint),
+4 -2
View File
@@ -1,7 +1,7 @@
"""
Constants for the HA text AI integration.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -29,7 +29,7 @@ API_PROVIDERS: Final = [
API_PROVIDER_GEMINI
]
VERSION: Final = "2.4.1"
VERSION: Final = "2.5.0"
# Default endpoints
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
@@ -50,6 +50,7 @@ CONF_CONTEXT_MESSAGES: Final = "context_messages"
CONF_STRUCTURED_OUTPUT: Final = "structured_output"
CONF_JSON_SCHEMA: Final = "json_schema"
CONF_ALLOW_LOCAL_NETWORK: Final = "allow_local_network"
CONF_DISABLE_THINKING: Final = "disable_thinking"
ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100)
MAX_ATTRIBUTE_SIZE = 4 * 1024
@@ -69,6 +70,7 @@ DEFAULT_NAME_PREFIX = "ha_text_ai"
DEFAULT_INSTANCE_NAME: Final = "my_assistant"
DEFAULT_CONTEXT_MESSAGES: Final = 5
DEFAULT_ALLOW_LOCAL_NETWORK: Final = False
DEFAULT_DISABLE_THINKING: Final = False
MIN_CONTEXT_MESSAGES: Final = 1
MAX_CONTEXT_MESSAGES: Final = 20
MIN_HISTORY_SIZE: Final = 1
+10 -2
View File
@@ -1,7 +1,7 @@
"""
The HA Text AI coordinator.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -23,6 +23,7 @@ from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_API_TIMEOUT,
DEFAULT_CONTEXT_MESSAGES,
DEFAULT_DISABLE_THINKING,
DEFAULT_MAX_HISTORY,
DEFAULT_MAX_TOKENS,
DEFAULT_TEMPERATURE,
@@ -56,6 +57,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
max_history_size: int = DEFAULT_MAX_HISTORY,
context_messages: int = DEFAULT_CONTEXT_MESSAGES,
api_timeout: int = DEFAULT_API_TIMEOUT,
disable_thinking: bool = DEFAULT_DISABLE_THINKING,
) -> None:
"""Initialize coordinator."""
self.instance_name = instance_name
@@ -89,6 +91,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
self.temperature = temperature
self.max_tokens = max_tokens
self.api_timeout = api_timeout
self.disable_thinking = disable_thinking
# Concurrency control
self._request_lock = asyncio.Lock()
@@ -213,6 +216,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
context_messages: Optional[int] = None,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: Optional[bool] = None,
) -> dict:
"""Process question with context management."""
if self.client is None:
@@ -228,6 +232,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
temp_temperature = temperature if temperature is not None else self.temperature
temp_max_tokens = max_tokens if max_tokens is not None else self.max_tokens
temp_system_prompt = system_prompt if system_prompt is not None else self._system_prompt
temp_disable_thinking = disable_thinking if disable_thinking is not None else self.disable_thinking
start_time = dt_util.utcnow()
@@ -250,6 +255,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
max_tokens=temp_max_tokens,
structured_output=structured_output,
json_schema=json_schema,
disable_thinking=temp_disable_thinking,
)
latency = (dt_util.utcnow() - start_time).total_seconds()
@@ -263,7 +269,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
if error_details.get("is_connection_error"):
self.endpoint_status = "unavailable"
self.last_response = error_details
raise HomeAssistantError(f"Failed to process question: {err}")
raise HomeAssistantError(f"Failed to process question: {err}") from err
finally:
self._is_processing = False
@@ -278,6 +284,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> dict:
"""Send request to AI provider and return structured response.
@@ -292,6 +299,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
max_tokens=max_tokens,
structured_output=structured_output,
json_schema=json_schema,
disable_thinking=disable_thinking,
)
# Reset error state on success
+22 -1
View File
@@ -1,7 +1,7 @@
"""
History management for HA Text AI integration.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -50,6 +50,18 @@ class AsyncFileHandler:
await self.file.close()
def _assert_not_symlink(path: str) -> None:
"""Refuse to operate on a path that resolves to a symlink.
Why: another component or an attacker with filesystem access could
replace our history file with a symlink pointing at arbitrary disk
locations. Then os.remove or shutil.move would hit the target
instead of our managed file. Check before destructive ops.
"""
if os.path.islink(path):
raise OSError(f"Refusing to operate on symlink: {path}")
class HistoryManager:
"""Manages conversation history for an instance."""
@@ -242,6 +254,9 @@ class HistoryManager:
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json",
)
await self.hass.async_add_executor_job(
_assert_not_symlink, self._history_file
)
await self.hass.async_add_executor_job(
shutil.move, self._history_file, archive_file
)
@@ -280,6 +295,9 @@ class HistoryManager:
archives = await self.hass.async_add_executor_job(find_archives)
if len(archives) > MAX_ARCHIVE_FILES:
for old_file in archives[:-MAX_ARCHIVE_FILES]:
await self.hass.async_add_executor_job(
_assert_not_symlink, old_file
)
await self.hass.async_add_executor_job(os.remove, old_file)
_LOGGER.debug("Removed old archive: %s", old_file)
except Exception as e:
@@ -359,6 +377,9 @@ class HistoryManager:
try:
self._conversation_history = []
if await self._file_exists(self._history_file):
await self.hass.async_add_executor_job(
_assert_not_symlink, self._history_file
)
await self.hass.async_add_executor_job(os.remove, self._history_file)
_LOGGER.info("History for %s cleared", self.instance_name)
except Exception as e:
+3 -3
View File
@@ -11,9 +11,9 @@
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
"loggers": ["custom_components.ha_text_ai"],
"requirements": [
"aiofiles>=23.0.0",
"google-genai>=1.16.0"
"aiofiles>=23.0.0,<25.0.0",
"google-genai>=1.16.0,<2.0.0"
],
"single_config_entry": false,
"version": "2.4.1"
"version": "2.5.0"
}
+19 -6
View File
@@ -1,7 +1,7 @@
"""
Metrics management for HA Text AI integration.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -123,13 +123,26 @@ class MetricsManager:
await self._save_metrics()
error_msg = str(error)
# Strip URLs, API keys, tokens, and query parameters from error messages
# Strip URLs, API keys, tokens, and query parameters from error messages.
# Patterns use word boundaries and explicit length bounds so that
# overly greedy matches don't accidentally swallow adjacent text.
error_msg = re.sub(r'https?://\S+', '[URL]', error_msg)
error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg)
error_msg = re.sub(r'AIza[A-Za-z0-9_-]+', '***', error_msg)
error_msg = re.sub(r'Bearer\s+\S+', 'Bearer ***', error_msg)
error_msg = re.sub(r'sk-[A-Za-z0-9_-]{20,}', '***', error_msg)
error_msg = re.sub(r'x-api-key:\s*\S+', 'x-api-key: ***', error_msg, flags=re.IGNORECASE)
# Google API key: fixed prefix + 30+ url-safe chars, bounded by non-key char.
error_msg = re.sub(
r'AIza[A-Za-z0-9_\-]{30,}(?=[^A-Za-z0-9_\-]|$)', '***', error_msg
)
# Anthropic / OpenAI / DeepSeek format: "sk-..." (anchors on word boundary).
error_msg = re.sub(r'\bsk-[A-Za-z0-9_\-]{20,}\b', '***', error_msg)
# Bearer tokens: header-style and JSON-embedded ("Bearer xxx").
error_msg = re.sub(r'[Bb]earer\s+[A-Za-z0-9_\-\.=]+', 'Bearer ***', error_msg)
# x-api-key header in any case, both raw and JSON-serialized forms.
error_msg = re.sub(
r'"?x-api-key"?\s*[:=]\s*"?[A-Za-z0-9_\-\.]+"?',
'x-api-key: ***',
error_msg,
flags=re.IGNORECASE,
)
if len(error_msg) > 256:
error_msg = error_msg[:256] + "..."
+1 -1
View File
@@ -4,7 +4,7 @@ Provider registry for HA Text AI integration.
Centralizes provider-specific configuration to avoid dispatch duplication
across __init__.py, config_flow.py, and api_client.py.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
+3 -2
View File
@@ -1,7 +1,7 @@
"""
Sensor platform for HA Text AI.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -161,6 +161,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
manufacturer="Community",
model=f"{model} ({api_provider} provider)",
sw_version=VERSION,
entry_type=DeviceEntryType.SERVICE,
)
_LOGGER.debug(
@@ -92,6 +92,15 @@ ask_question:
text:
multiline: true
disable_thinking:
name: Disable Thinking
description: >-
Disable model thinking/reasoning for this request.
Overrides the integration-level setting when provided.
required: false
selector:
boolean: {}
clear_history:
name: Clear History
description: >-
+10 -3
View File
@@ -15,7 +15,8 @@
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips <think> blocks, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips <think> blocks, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips <think> blocks, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "JSON Schema",
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
},
"disable_thinking": {
"name": "Disable Thinking",
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, <think>-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, <think>-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
"context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)",
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, <think>-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "JSON-Schema",
"description": "JSON-Schema, das die Struktur der erwarteten Antwort definiert. Erforderlich wenn structured_output aktiviert ist."
},
"disable_thinking": {
"name": "Thinking deaktivieren",
"description": "Thinking/Reasoning-Modus für diese Anfrage deaktivieren. Überschreibt die Integrationseinstellung."
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips <think> blocks, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of context messages to retain (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips <think> blocks, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "API request timeout in seconds (5-600)",
"context_messages": "Number of previous messages to include in context (1-20)",
"max_history_size": "Maximum conversation history size (1-100)",
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips <think> blocks, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "JSON Schema",
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
},
"disable_thinking": {
"name": "Disable Thinking",
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)",
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques <think>, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes de contexto a retener (1-20)",
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques <think>, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
"context_messages": "Número de mensajes anteriores a incluir en el contexto (1-20)",
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques <think>, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "Esquema JSON",
"description": "Esquema JSON que define la estructura de la respuesta esperada. Requerido cuando structured_output está habilitado."
},
"disable_thinking": {
"name": "Desactivar Thinking",
"description": "Desactivar el modo thinking/reasoning para esta solicitud. Anula la configuración de la integración."
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, <think> ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, <think> ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
"context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)",
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, <think> ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "JSON स्कीमा",
"description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।"
},
"disable_thinking": {
"name": "Thinking बंद करें",
"description": "इस अनुरोध के लिए thinking/reasoning मोड बंद करें। एकीकरण सेटिंग को ओवरराइड करता है।"
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi <think>, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi <think>, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
"context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)",
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi <think>, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "Schema JSON",
"description": "Schema JSON che definisce la struttura della risposta attesa. Richiesto quando structured_output è abilitato."
},
"disable_thinking": {
"name": "Disabilita Thinking",
"description": "Disabilita la modalità thinking/reasoning per questa richiesta. Sovrascrive l'impostazione dell'integrazione."
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков <think>, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков <think>, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
"context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)",
"max_history_size": "Максимальный размер истории разговора (1-100)",
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков <think>, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "JSON Schema",
"description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output."
},
"disable_thinking": {
"name": "Отключить thinking",
"description": "Отключить режим thinking/reasoning для этого запроса. Переопределяет настройку интеграции."
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања <think> блокове, Gemini 2.5 thinking_budget=0)"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања <think> блокове, Gemini 2.5 thinking_budget=0)"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
"context_messages": "Број претходних порука које треба укључити у контекст (1-20)",
"max_history_size": "Максимална величина историје разговора (1-100)",
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања <think> блокове, Gemini 2.5 thinking_budget=0)"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "JSON шема",
"description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен."
},
"disable_thinking": {
"name": "Онемогући thinking",
"description": "Онемогући thinking/reasoning режим за овај захтев. Надмашује поставку интеграције."
}
}
},
@@ -15,7 +15,8 @@
"api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)"
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 <think> 块,Gemini 2.5 thinking_budget=0"
}
},
"user": {
@@ -33,7 +34,8 @@
"api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "保留的上下文消息数量(1-20",
"max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)"
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 <think> 块,Gemini 2.5 thinking_budget=0"
}
}
},
@@ -86,7 +88,8 @@
"api_timeout": "API请求超时时间(5-600秒)",
"context_messages": "要包含在上下文中的先前消息数量(1-20)",
"max_history_size": "最大对话历史大小(1-100",
"allow_local_network": "允许本地网络端点(用于自托管代理)"
"allow_local_network": "允许本地网络端点(用于自托管代理)",
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 <think> 块,Gemini 2.5 thinking_budget=0"
}
}
}
@@ -141,6 +144,10 @@
"json_schema": {
"name": "JSON模式",
"description": "定义预期响应结构的JSON模式。启用structured_output时必需。"
},
"disable_thinking": {
"name": "禁用思考",
"description": "为此请求禁用思考/推理模式。覆盖集成级别的设置。"
}
}
},
+13 -4
View File
@@ -1,7 +1,7 @@
"""
Utility functions for HA Text AI integration.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -18,10 +18,19 @@ from homeassistant.core import HomeAssistant
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.
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('_')))
return normalized.lower()
normalized = '_'.join(filter(None, normalized.split('_'))).lower()
if not normalized:
import hashlib
digest = hashlib.sha256(name.encode("utf-8", errors="replace")).hexdigest()[:8]
normalized = f"instance_{digest}"
return normalized
def safe_log_data(