From 5af733b1b0ec7d6beee9528508d7468d77fe852d Mon Sep 17 00:00:00 2001 From: SMKRV Date: Fri, 17 Apr 2026 01:30:57 +0300 Subject: [PATCH] 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. --- .github/dependabot.yml | 15 ++ .github/workflows/hassfest.yaml | 7 +- .github/workflows/release.yml | 1 + .gitignore | 1 + LICENSE | 144 ++-------- README.md | 4 +- custom_components/ha_text_ai/__init__.py | 27 +- custom_components/ha_text_ai/api_client.py | 253 +++++++++++++++--- custom_components/ha_text_ai/config_flow.py | 27 +- custom_components/ha_text_ai/const.py | 6 +- custom_components/ha_text_ai/coordinator.py | 12 +- custom_components/ha_text_ai/history.py | 23 +- custom_components/ha_text_ai/manifest.json | 6 +- custom_components/ha_text_ai/metrics.py | 25 +- custom_components/ha_text_ai/providers.py | 2 +- custom_components/ha_text_ai/sensor.py | 5 +- custom_components/ha_text_ai/services.yaml | 9 + custom_components/ha_text_ai/strings.json | 13 +- .../ha_text_ai/translations/de.json | 13 +- .../ha_text_ai/translations/en.json | 13 +- .../ha_text_ai/translations/es.json | 13 +- .../ha_text_ai/translations/hi.json | 13 +- .../ha_text_ai/translations/it.json | 13 +- .../ha_text_ai/translations/ru.json | 13 +- .../ha_text_ai/translations/sr.json | 13 +- .../ha_text_ai/translations/zh.json | 13 +- custom_components/ha_text_ai/utils.py | 17 +- 27 files changed, 475 insertions(+), 226 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c217b5b --- /dev/null +++ b/.github/dependabot.yml @@ -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: + - "*" diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index e6cbc78..1526864 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -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)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f44d6b..8976e8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.event.release.tag_name }} + persist-credentials: false - name: Create zip archive run: | diff --git a/.gitignore b/.gitignore index 0b7c92a..d0cdba3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ Thumbs.db CLAUDE.md docs/specs/ docs/superpowers/ +docs/plans/ diff --git a/LICENSE b/LICENSE index 1a71cb6..8028e3e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,131 +1,21 @@ -# PolyForm Noncommercial License 1.0.0 +MIT License - +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. diff --git a/README.md b/README.md index b9bd07b..c460eac 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
- ![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 diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index d1cda59..d5e6d3e 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -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 diff --git a/custom_components/ha_text_ai/api_client.py b/custom_components/ha_text_ai/api_client.py index 42636c9..9f266a5 100644 --- a/custom_components/ha_text_ai/api_client.py +++ b/custom_components/ha_text_ai/api_client.py @@ -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 ... reasoning blocks from model output. + + Why: Some reasoning models (DeepSeek-R1, Qwen-Thinking) emit chain-of-thought + wrapped in 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 "" not in text: + return text + import re + pattern = re.compile(r".*?", 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 open, drop the rest + # from that marker onward to avoid leaking partial reasoning. + if "" in cleaned: + cleaned = cleaned.split("", 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" - payload = { - "model": model, - "messages": messages, - "temperature": temperature, - "max_tokens": max_tokens, - } + is_reasoning = self._is_openai_reasoning_model(model) + + if is_reasoning: + prepared_messages = self._convert_system_to_developer(messages) + payload: Dict[str, Any] = { + "model": model, + "messages": prepared_messages, + "max_completion_tokens": max_tokens, + } + if disable_thinking: + payload["reasoning_effort"] = "low" + else: + prepared_messages = ( + self._apply_no_think_tag(messages) if disable_thinking else messages + ) + payload = { + "model": model, + "messages": prepared_messages, + "temperature": temperature, + "max_tokens": max_tokens, + } self._apply_structured_output(payload, structured_output, json_schema) data = await self._make_request(url, payload) + content = data["choices"][0]["message"]["content"] + # Strip 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,35 +450,53 @@ class APIClient: else: filtered_messages.append(msg) - # For Anthropic, add structured output instruction to system prompt + # For Anthropic, add structured output instruction to system prompt. + # Validate schema is well-formed JSON before concatenation: untrusted + # schema strings (built from templates/webhook data) could otherwise + # break out of the JSON fence and rewrite the system instruction. if structured_output and json_schema: - schema_instruction = ( - f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches " - f"this JSON Schema:\n{json_schema}\n" - f"Do not include any text before or after the JSON. " - f"Do not wrap the JSON in markdown code blocks." - ) - if system_prompt: - system_prompt += schema_instruction + 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: - system_prompt = schema_instruction.strip() - _LOGGER.debug("Anthropic structured output enabled via system prompt") + schema_instruction = ( + f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches " + f"this JSON Schema:\n{json_schema}\n" + f"Do not include any text before or after the JSON. " + f"Do not wrap the JSON in markdown code blocks." + ) + if system_prompt: + system_prompt += schema_instruction + else: + system_prompt = schema_instruction.strip() + _LOGGER.debug("Anthropic structured output enabled via system prompt") + # Anthropic accepts temperature in [0, 1], not [0, 2] like OpenAI. + # Clip silently to avoid a 400 when a user-set config exceeds the cap. + clipped_temp = min(1.0, max(0.0, float(temperature))) payload = { "model": model, "messages": filtered_messages, "max_tokens": max_tokens, - "temperature": temperature, + "temperature": clipped_temp, } if system_prompt: payload["system"] = system_prompt data = await self._make_request(url, payload) + # Anthropic returns text in "content" array; extended thinking arrives + # as a separate thinking-type block (not 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": { diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index c691d9d..c744c36 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -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), diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index 04ed450..97f08ba 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -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 diff --git a/custom_components/ha_text_ai/coordinator.py b/custom_components/ha_text_ai/coordinator.py index 7a1ac00..104d08a 100644 --- a/custom_components/ha_text_ai/coordinator.py +++ b/custom_components/ha_text_ai/coordinator.py @@ -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 diff --git a/custom_components/ha_text_ai/history.py b/custom_components/ha_text_ai/history.py index 6480a0e..cd440d7 100644 --- a/custom_components/ha_text_ai/history.py +++ b/custom_components/ha_text_ai/history.py @@ -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: diff --git a/custom_components/ha_text_ai/manifest.json b/custom_components/ha_text_ai/manifest.json index 104ddcf..92e6698 100644 --- a/custom_components/ha_text_ai/manifest.json +++ b/custom_components/ha_text_ai/manifest.json @@ -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" } diff --git a/custom_components/ha_text_ai/metrics.py b/custom_components/ha_text_ai/metrics.py index 2e73020..6369ef8 100644 --- a/custom_components/ha_text_ai/metrics.py +++ b/custom_components/ha_text_ai/metrics.py @@ -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] + "..." diff --git a/custom_components/ha_text_ai/providers.py b/custom_components/ha_text_ai/providers.py index d70548d..1e5f959 100644 --- a/custom_components/ha_text_ai/providers.py +++ b/custom_components/ha_text_ai/providers.py @@ -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 diff --git a/custom_components/ha_text_ai/sensor.py b/custom_components/ha_text_ai/sensor.py index 989a105..20c50c6 100644 --- a/custom_components/ha_text_ai/sensor.py +++ b/custom_components/ha_text_ai/sensor.py @@ -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( diff --git a/custom_components/ha_text_ai/services.yaml b/custom_components/ha_text_ai/services.yaml index 750ad1d..5915c05 100644 --- a/custom_components/ha_text_ai/services.yaml +++ b/custom_components/ha_text_ai/services.yaml @@ -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: >- diff --git a/custom_components/ha_text_ai/strings.json b/custom_components/ha_text_ai/strings.json index c01b3aa..035cd29 100644 --- a/custom_components/ha_text_ai/strings.json +++ b/custom_components/ha_text_ai/strings.json @@ -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 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 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 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." } } }, diff --git a/custom_components/ha_text_ai/translations/de.json b/custom_components/ha_text_ai/translations/de.json index 3592914..7e3906f 100644 --- a/custom_components/ha_text_ai/translations/de.json +++ b/custom_components/ha_text_ai/translations/de.json @@ -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, -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, -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, -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." } } }, diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index c01b3aa..035cd29 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -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 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 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 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." } } }, diff --git a/custom_components/ha_text_ai/translations/es.json b/custom_components/ha_text_ai/translations/es.json index 18f6e5a..414b0ef 100644 --- a/custom_components/ha_text_ai/translations/es.json +++ b/custom_components/ha_text_ai/translations/es.json @@ -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 , 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 , 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 , 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." } } }, diff --git a/custom_components/ha_text_ai/translations/hi.json b/custom_components/ha_text_ai/translations/hi.json index d0062ce..32c72cf 100644 --- a/custom_components/ha_text_ai/translations/hi.json +++ b/custom_components/ha_text_ai/translations/hi.json @@ -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, ब्लॉक हटाता है, 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, ब्लॉक हटाता है, 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, ब्लॉक हटाता है, 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 मोड बंद करें। एकीकरण सेटिंग को ओवरराइड करता है।" } } }, diff --git a/custom_components/ha_text_ai/translations/it.json b/custom_components/ha_text_ai/translations/it.json index f040020..68cd438 100644 --- a/custom_components/ha_text_ai/translations/it.json +++ b/custom_components/ha_text_ai/translations/it.json @@ -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 , 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 , 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 , 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." } } }, diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json index 239d566..91ee063 100644 --- a/custom_components/ha_text_ai/translations/ru.json +++ b/custom_components/ha_text_ai/translations/ru.json @@ -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, срезание блоков , 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, срезание блоков , 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, срезание блоков , 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 для этого запроса. Переопределяет настройку интеграции." } } }, diff --git a/custom_components/ha_text_ai/translations/sr.json b/custom_components/ha_text_ai/translations/sr.json index a2fdecc..d87ea89 100644 --- a/custom_components/ha_text_ai/translations/sr.json +++ b/custom_components/ha_text_ai/translations/sr.json @@ -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, уклања блокове, 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, уклања блокове, 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, уклања блокове, 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 режим за овај захтев. Надмашује поставку интеграције." } } }, diff --git a/custom_components/ha_text_ai/translations/zh.json b/custom_components/ha_text_ai/translations/zh.json index fd74cef..a163ab4 100644 --- a/custom_components/ha_text_ai/translations/zh.json +++ b/custom_components/ha_text_ai/translations/zh.json @@ -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,移除 块,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,移除 块,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,移除 块,Gemini 2.5 thinking_budget=0)" } } } @@ -141,6 +144,10 @@ "json_schema": { "name": "JSON模式", "description": "定义预期响应结构的JSON模式。启用structured_output时必需。" + }, + "disable_thinking": { + "name": "禁用思考", + "description": "为此请求禁用思考/推理模式。覆盖集成级别的设置。" } } }, diff --git a/custom_components/ha_text_ai/utils.py b/custom_components/ha_text_ai/utils.py index 5f511a2..1e6d106 100644 --- a/custom_components/ha_text_ai/utils.py +++ b/custom_components/ha_text_ai/utils.py @@ -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(