mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-21 22:54:00 +08:00
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:
@@ -0,0 +1,15 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -26,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)"
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create zip archive
|
||||
run: |
|
||||
|
||||
@@ -30,3 +30,4 @@ Thumbs.db
|
||||
CLAUDE.md
|
||||
docs/specs/
|
||||
docs/superpowers/
|
||||
docs/plans/
|
||||
|
||||
@@ -1,131 +1,21 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
MIT License
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
Copyright (c) 2024-2026 SMKRV
|
||||
|
||||
## Acceptance
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the
|
||||
software to do everything you might do with the software
|
||||
that would otherwise infringe the licensor's copyright
|
||||
in it for any permitted purpose. However, you may
|
||||
only distribute the software according to [Distribution
|
||||
License](#distribution-license) and make changes or new works
|
||||
based on the software according to [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to distribute copies of the software. Your license
|
||||
to distribute covers distributing the software with
|
||||
changes and new works permitted by [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of
|
||||
the software from you also gets a copy of these terms or the
|
||||
URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for
|
||||
the benefit of public knowledge, personal study, private
|
||||
entertainment, hobby projects, amateur pursuits, or religious
|
||||
observance, without any anticipated commercial application,
|
||||
is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution,
|
||||
public research organization, public safety or health
|
||||
organization, environmental protection organization,
|
||||
or government institution is use for a permitted purpose
|
||||
regardless of the source of funding or obligations resulting
|
||||
from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have
|
||||
violated any of these terms, or done anything with the software
|
||||
not covered by your licenses, your licenses can nonetheless
|
||||
continue if you come into full compliance with these terms,
|
||||
and take practical steps to correct past violations, within
|
||||
32 days of receiving notice. Otherwise, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
  [](https://polyformproject.org/licenses/noncommercial/1.0.0) [](https://github.com/hacs/integration)
|
||||
  [](https://opensource.org/licenses/MIT) [](https://github.com/hacs/integration)
|
||||
       
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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] + "..."
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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": "为此请求禁用思考/推理模式。覆盖集成级别的设置。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user