mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 23:24:03 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65dbb5dfc0 | ||
|
|
fc59f584a3 | ||
|
|
bcfe69b5f7 | ||
|
|
9d4d2d5cd1 | ||
|
|
6e635f7e2f | ||
|
|
e8b9b911ef | ||
|
|
5af733b1b0 |
@@ -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,19 +26,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: ⤵️ Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: 🚀 Run hassfest validation
|
- name: Run hassfest validation
|
||||||
uses: home-assistant/actions/hassfest@master
|
uses: home-assistant/actions/hassfest@master
|
||||||
|
|
||||||
- name: ℹ️ Print hassfest version
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "Hassfest version: $(hassfest --version)"
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.release.tag_name }}
|
ref: ${{ github.event.release.tag_name }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Create zip archive
|
- name: Create zip archive
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -28,5 +28,12 @@ Thumbs.db
|
|||||||
|
|
||||||
# Claude Code working files
|
# Claude Code working files
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
GEMINI.md
|
||||||
|
.claude/
|
||||||
|
.cursor/
|
||||||
|
.cursorrules
|
||||||
|
.windsurfrules
|
||||||
docs/specs/
|
docs/specs/
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
docs/plans/
|
||||||
|
|||||||
@@ -1,131 +1,21 @@
|
|||||||
# PolyForm Noncommercial License 1.0.0
|
MIT License
|
||||||
|
|
||||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
Copyright (c) 2024-2026 SMKRV
|
||||||
|
|
||||||
## Acceptance
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
In order to get any license under these terms, you must agree
|
The above copyright notice and this permission notice shall be included in all
|
||||||
to them as both strict obligations and conditions to all
|
copies or substantial portions of the Software.
|
||||||
your licenses.
|
|
||||||
|
|
||||||
## Copyright License
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
The licensor grants you a copyright license for the
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
software to do everything you might do with the software
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
that would otherwise infringe the licensor's copyright
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
in it for any permitted purpose. However, you may
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
only distribute the software according to [Distribution
|
SOFTWARE.
|
||||||
License](#distribution-license) and make changes or new works
|
|
||||||
based on the software according to [Changes and New Works
|
|
||||||
License](#changes-and-new-works-license).
|
|
||||||
|
|
||||||
## Distribution License
|
|
||||||
|
|
||||||
The licensor grants you an additional copyright license
|
|
||||||
to distribute copies of the software. Your license
|
|
||||||
to distribute covers distributing the software with
|
|
||||||
changes and new works permitted by [Changes and New Works
|
|
||||||
License](#changes-and-new-works-license).
|
|
||||||
|
|
||||||
## Notices
|
|
||||||
|
|
||||||
You must ensure that anyone who gets a copy of any part of
|
|
||||||
the software from you also gets a copy of these terms or the
|
|
||||||
URL for them above, as well as copies of any plain-text lines
|
|
||||||
beginning with `Required Notice:` that the licensor provided
|
|
||||||
with the software. For example:
|
|
||||||
|
|
||||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
||||||
|
|
||||||
## Changes and New Works License
|
|
||||||
|
|
||||||
The licensor grants you an additional copyright license to
|
|
||||||
make changes and new works based on the software for any
|
|
||||||
permitted purpose.
|
|
||||||
|
|
||||||
## Patent License
|
|
||||||
|
|
||||||
The licensor grants you a patent license for the software that
|
|
||||||
covers patent claims the licensor can license, or becomes able
|
|
||||||
to license, that you would infringe by using the software.
|
|
||||||
|
|
||||||
## Noncommercial Purposes
|
|
||||||
|
|
||||||
Any noncommercial purpose is a permitted purpose.
|
|
||||||
|
|
||||||
## Personal Uses
|
|
||||||
|
|
||||||
Personal use for research, experiment, and testing for
|
|
||||||
the benefit of public knowledge, personal study, private
|
|
||||||
entertainment, hobby projects, amateur pursuits, or religious
|
|
||||||
observance, without any anticipated commercial application,
|
|
||||||
is use for a permitted purpose.
|
|
||||||
|
|
||||||
## Noncommercial Organizations
|
|
||||||
|
|
||||||
Use by any charitable organization, educational institution,
|
|
||||||
public research organization, public safety or health
|
|
||||||
organization, environmental protection organization,
|
|
||||||
or government institution is use for a permitted purpose
|
|
||||||
regardless of the source of funding or obligations resulting
|
|
||||||
from the funding.
|
|
||||||
|
|
||||||
## Fair Use
|
|
||||||
|
|
||||||
You may have "fair use" rights for the software under the
|
|
||||||
law. These terms do not limit them.
|
|
||||||
|
|
||||||
## No Other Rights
|
|
||||||
|
|
||||||
These terms do not allow you to sublicense or transfer any of
|
|
||||||
your licenses to anyone else, or prevent the licensor from
|
|
||||||
granting licenses to anyone else. These terms do not imply
|
|
||||||
any other licenses.
|
|
||||||
|
|
||||||
## Patent Defense
|
|
||||||
|
|
||||||
If you make any written claim that the software infringes or
|
|
||||||
contributes to infringement of any patent, your patent license
|
|
||||||
for the software granted under these terms ends immediately. If
|
|
||||||
your company makes such a claim, your patent license ends
|
|
||||||
immediately for work on behalf of your company.
|
|
||||||
|
|
||||||
## Violations
|
|
||||||
|
|
||||||
The first time you are notified in writing that you have
|
|
||||||
violated any of these terms, or done anything with the software
|
|
||||||
not covered by your licenses, your licenses can nonetheless
|
|
||||||
continue if you come into full compliance with these terms,
|
|
||||||
and take practical steps to correct past violations, within
|
|
||||||
32 days of receiving notice. Otherwise, all your licenses
|
|
||||||
end immediately.
|
|
||||||
|
|
||||||
## No Liability
|
|
||||||
|
|
||||||
***As far as the law allows, the software comes as is, without
|
|
||||||
any warranty or condition, and the licensor will not be liable
|
|
||||||
to you for any damages arising out of these terms or the use
|
|
||||||
or nature of the software, under any kind of legal claim.***
|
|
||||||
|
|
||||||
## Definitions
|
|
||||||
|
|
||||||
The **licensor** is the individual or entity offering these
|
|
||||||
terms, and the **software** is the software the licensor makes
|
|
||||||
available under these terms.
|
|
||||||
|
|
||||||
**You** refers to the individual or entity agreeing to these
|
|
||||||
terms.
|
|
||||||
|
|
||||||
**Your company** is any legal entity, sole proprietorship,
|
|
||||||
or other kind of organization that you work for, plus all
|
|
||||||
organizations that have control over, are under the control of,
|
|
||||||
or are under common control with that organization. **Control**
|
|
||||||
means ownership of substantially all the assets of an entity,
|
|
||||||
or the power to direct its management and policies by vote,
|
|
||||||
contract, or otherwise. Control can be direct or indirect.
|
|
||||||
|
|
||||||
**Your licenses** are all the licenses granted to you for the
|
|
||||||
software under these terms.
|
|
||||||
|
|
||||||
**Use** means anything you do with the software requiring one
|
|
||||||
of your licenses.
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<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)
|
||||||
       
|
       
|
||||||
|
|
||||||
|
|
||||||
@@ -142,12 +142,16 @@ Transform your smart home experience with powerful AI assistance powered by mult
|
|||||||
- **Claude Haiku 4.5** - The fastest and most economical option in the series
|
- **Claude Haiku 4.5** - The fastest and most economical option in the series
|
||||||
|
|
||||||
#### DeepSeek Models
|
#### DeepSeek Models
|
||||||
- **DeepSeek-V3** - A general-purpose model for a wide range of tasks
|
- **deepseek-v4-flash** - A fast general-purpose model for a wide range of tasks (default)
|
||||||
- **DeepSeek-R1** - A specialized model focused on reasoning and coding
|
- **deepseek-v4-pro** - A more capable model for reasoning and coding
|
||||||
|
|
||||||
|
> The legacy model names `deepseek-chat` and `deepseek-reasoner` stop working on 2026-07-24. If your instance still uses one of them, switch the model in the integration options.
|
||||||
|
|
||||||
#### Google Gemini Models
|
#### Google Gemini Models
|
||||||
- **Gemini 3.1 Pro** - The newest and most advanced model available
|
- **gemini-3.5-flash** - Fast and cost-efficient, suitable for most tasks (default)
|
||||||
- **Gemini 3.1 Flash Lite** - Fastest and most cost-efficient model for high-volume workloads
|
- **Gemini 3.1 Pro** - The most advanced Gemini model available
|
||||||
|
|
||||||
|
> Google shut down `gemini-2.0-flash` on 2026-06-01 and retires the 2.5 family on 2026-10-16. If your instance uses one of those, switch the model in the integration options.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>🌐 Potentially Compatible Providers</summary>
|
<summary>🌐 Potentially Compatible Providers</summary>
|
||||||
@@ -199,8 +203,8 @@ If the integration is not found in the default repository:
|
|||||||
5. Click "Download"
|
5. Click "Download"
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
1. Download the latest release
|
1. Download `ha_text_ai.zip` from the latest release
|
||||||
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory
|
2. Extract the archive and copy the `ha_text_ai` folder into your `custom_components` directory
|
||||||
3. Restart Home Assistant
|
3. Restart Home Assistant
|
||||||
4. Add configuration via UI (Settings → Devices & Services → Add Integration)
|
4. Add configuration via UI (Settings → Devices & Services → Add Integration)
|
||||||
|
|
||||||
@@ -279,12 +283,13 @@ data:
|
|||||||
```yaml
|
```yaml
|
||||||
service: ha_text_ai.get_history
|
service: ha_text_ai.get_history
|
||||||
data:
|
data:
|
||||||
limit: 5 # optional, number of conversations to return (1-100)
|
limit: 5 # optional, number of conversations to return (values above 200 are clamped); omit to get the full stored history
|
||||||
filter_model: "gpt-4o" # optional, filter by specific AI model
|
filter_model: "gpt-4o" # optional, filter by specific AI model
|
||||||
start_date: "2025-02-01" # optional, filter conversations from this date
|
start_date: "2025-02-01" # optional, filter conversations from this date
|
||||||
include_metadata: false # optional, include tokens, response time, etc.
|
include_metadata: false # optional, include tokens, response time, etc.
|
||||||
sort_order: "newest" # optional, sort order: "newest" or "oldest"
|
sort_order: "newest" # optional, sort order: "newest" or "oldest"
|
||||||
instance: sensor.ha_text_ai_gpt
|
instance: sensor.ha_text_ai_gpt
|
||||||
|
response_variable: history_result # entries are in history_result.history
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Advanced Automation Examples with Response Variables
|
## 🚀 Advanced Automation Examples with Response Variables
|
||||||
@@ -653,7 +658,7 @@ DEALINGS IN THE SOFTWARE.
|
|||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
Author: SMKRV
|
Author: SMKRV
|
||||||
[PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0) - see [LICENSE](LICENSE) for details.
|
[MIT License](https://opensource.org/licenses/MIT) - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
## 💡 Support the Project
|
## 💡 Support the Project
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
The HA Text AI integration.
|
The HA Text AI integration.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -9,23 +9,22 @@ The HA Text AI integration.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
from homeassistant.const import CONF_API_KEY, CONF_NAME, EVENT_HOMEASSISTANT_CLOSE
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers import aiohttp_client
|
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .coordinator import HATextAICoordinator
|
from .coordinator import HATextAICoordinator
|
||||||
from .api_client import APIClient
|
from .api_client import APIClient
|
||||||
from .utils import normalize_name, safe_log_data, validate_endpoint
|
from .utils import create_pinned_session, normalize_name, safe_log_data, validate_endpoint
|
||||||
from .providers import get_default_endpoint, get_default_model, build_auth_headers
|
from .providers import get_default_endpoint, get_default_model, build_auth_headers
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -51,6 +50,8 @@ from .const import (
|
|||||||
CONF_MAX_HISTORY_SIZE,
|
CONF_MAX_HISTORY_SIZE,
|
||||||
CONF_ALLOW_LOCAL_NETWORK,
|
CONF_ALLOW_LOCAL_NETWORK,
|
||||||
DEFAULT_ALLOW_LOCAL_NETWORK,
|
DEFAULT_ALLOW_LOCAL_NETWORK,
|
||||||
|
CONF_DISABLE_THINKING,
|
||||||
|
DEFAULT_DISABLE_THINKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -69,6 +70,7 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
|||||||
vol.Optional("context_messages"): cv.positive_int,
|
vol.Optional("context_messages"): cv.positive_int,
|
||||||
vol.Optional("structured_output", default=False): cv.boolean,
|
vol.Optional("structured_output", default=False): cv.boolean,
|
||||||
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
|
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
|
||||||
|
vol.Optional("disable_thinking"): cv.boolean,
|
||||||
})
|
})
|
||||||
|
|
||||||
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||||
@@ -78,7 +80,11 @@ SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
|||||||
|
|
||||||
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||||
vol.Required("instance"): cv.string,
|
vol.Required("instance"): cv.string,
|
||||||
vol.Optional("limit"): cv.positive_int,
|
# No default and no schema max: omitting limit returns the full history
|
||||||
|
# (pre-2.5.0 behavior) and oversized values are clamped to
|
||||||
|
# ABSOLUTE_MAX_HISTORY_SIZE in history.async_get_history instead of
|
||||||
|
# failing the whole service call.
|
||||||
|
vol.Optional("limit"): vol.All(cv.positive_int, vol.Range(min=1)),
|
||||||
vol.Optional("filter_model"): cv.string,
|
vol.Optional("filter_model"): cv.string,
|
||||||
vol.Optional("start_date"): cv.string,
|
vol.Optional("start_date"): cv.string,
|
||||||
vol.Optional("include_metadata"): cv.boolean,
|
vol.Optional("include_metadata"): cv.boolean,
|
||||||
@@ -106,10 +112,23 @@ def get_coordinator_by_instance(hass: HomeAssistant, instance: str) -> HATextAIC
|
|||||||
|
|
||||||
raise HomeAssistantError(f"Instance {instance} not found")
|
raise HomeAssistantError(f"Instance {instance} not found")
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
"""Set up the Home Assistant Text AI component."""
|
"""Set up the Home Assistant Text AI component."""
|
||||||
# Initialize domain data storage
|
# Initialize domain data storage
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
_async_register_services(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _async_register_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Register domain services; safe to call again after unload.
|
||||||
|
|
||||||
|
Unloading the last config entry unregisters the services, and a config
|
||||||
|
entry reload (every options change does one) runs unload + setup_entry
|
||||||
|
without re-running async_setup — so setup_entry must be able to bring
|
||||||
|
the services back.
|
||||||
|
"""
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_ASK_QUESTION):
|
||||||
|
return
|
||||||
|
|
||||||
async def async_ask_question(call: ServiceCall) -> dict:
|
async def async_ask_question(call: ServiceCall) -> dict:
|
||||||
"""Handle ask_question service with response data."""
|
"""Handle ask_question service with response data."""
|
||||||
@@ -124,6 +143,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
|||||||
context_messages=call.data.get("context_messages"),
|
context_messages=call.data.get("context_messages"),
|
||||||
structured_output=call.data.get("structured_output", False),
|
structured_output=call.data.get("structured_output", False),
|
||||||
json_schema=call.data.get("json_schema"),
|
json_schema=call.data.get("json_schema"),
|
||||||
|
disable_thinking=call.data.get("disable_thinking"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return structured response data
|
# Return structured response data
|
||||||
@@ -162,22 +182,26 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
|||||||
await coordinator.async_clear_history()
|
await coordinator.async_clear_history()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error clearing history: %s", str(err))
|
_LOGGER.error("Error clearing history: %s", str(err))
|
||||||
raise HomeAssistantError(f"Failed to clear history: {str(err)}")
|
raise HomeAssistantError(f"Failed to clear history: {str(err)}") from err
|
||||||
|
|
||||||
async def async_get_history(call: ServiceCall) -> list:
|
async def async_get_history(call: ServiceCall) -> dict:
|
||||||
"""Handle get_history service."""
|
"""Handle get_history service."""
|
||||||
try:
|
try:
|
||||||
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
|
coordinator = get_coordinator_by_instance(hass, call.data["instance"])
|
||||||
return await coordinator.async_get_history(
|
history = await coordinator.async_get_history(
|
||||||
limit=call.data.get("limit"),
|
limit=call.data.get("limit"),
|
||||||
filter_model=call.data.get("filter_model"),
|
filter_model=call.data.get("filter_model"),
|
||||||
start_date=call.data.get("start_date"),
|
start_date=call.data.get("start_date"),
|
||||||
include_metadata=call.data.get("include_metadata", False),
|
include_metadata=call.data.get("include_metadata", False),
|
||||||
sort_order=call.data.get("sort_order", "newest")
|
sort_order=call.data.get("sort_order", "newest")
|
||||||
)
|
)
|
||||||
|
# HA requires action responses to be dicts. The bare list made
|
||||||
|
# every return_response call fail with a server error, so this
|
||||||
|
# path never worked before and the wrapper breaks no consumer.
|
||||||
|
return {"history": history}
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error getting history: %s", str(err))
|
_LOGGER.error("Error getting history: %s", str(err))
|
||||||
raise HomeAssistantError(f"Failed to get history: {str(err)}")
|
raise HomeAssistantError(f"Failed to get history: {str(err)}") from err
|
||||||
|
|
||||||
async def async_set_system_prompt(call: ServiceCall) -> None:
|
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||||
"""Handle set_system_prompt service."""
|
"""Handle set_system_prompt service."""
|
||||||
@@ -186,7 +210,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
|||||||
await coordinator.async_set_system_prompt(call.data["prompt"])
|
await coordinator.async_set_system_prompt(call.data["prompt"])
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error setting system prompt: %s", str(err))
|
_LOGGER.error("Error setting system prompt: %s", str(err))
|
||||||
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}")
|
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}") from err
|
||||||
|
|
||||||
# Register services
|
# Register services
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
@@ -219,8 +243,6 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
|||||||
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
|
schema=SERVICE_SCHEMA_SET_SYSTEM_PROMPT
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def async_check_api(session, endpoint: str, headers: dict, provider: str, api_timeout: int = DEFAULT_API_TIMEOUT) -> bool:
|
async def async_check_api(session, endpoint: str, headers: dict, provider: str, api_timeout: int = DEFAULT_API_TIMEOUT) -> bool:
|
||||||
"""Check API availability using provider registry configuration."""
|
"""Check API availability using provider registry configuration."""
|
||||||
try:
|
try:
|
||||||
@@ -240,7 +262,9 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
|
|||||||
check_url = f"{endpoint}{check_path}"
|
check_url = f"{endpoint}{check_path}"
|
||||||
|
|
||||||
async with asyncio.timeout(api_timeout):
|
async with asyncio.timeout(api_timeout):
|
||||||
async with session.get(check_url, headers=headers) as response:
|
async with session.get(
|
||||||
|
check_url, headers=headers, allow_redirects=False
|
||||||
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
return True
|
return True
|
||||||
elif response.status == 401:
|
elif response.status == 401:
|
||||||
@@ -260,6 +284,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Set up HA Text AI from a config entry."""
|
"""Set up HA Text AI from a config entry."""
|
||||||
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
|
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
try:
|
||||||
# Get provider from data or options (options takes precedence)
|
# Get provider from data or options (options takes precedence)
|
||||||
config = {**entry.data, **entry.options}
|
config = {**entry.data, **entry.options}
|
||||||
@@ -269,22 +294,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_LOGGER.error("API provider not specified")
|
_LOGGER.error("API provider not specified")
|
||||||
raise ConfigEntryNotReady("API provider is required")
|
raise ConfigEntryNotReady("API provider is required")
|
||||||
|
|
||||||
session = aiohttp_client.async_get_clientsession(hass)
|
|
||||||
|
|
||||||
model = config.get(CONF_MODEL, get_default_model(api_provider))
|
model = config.get(CONF_MODEL, get_default_model(api_provider))
|
||||||
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider))
|
raw_endpoint = config.get(CONF_API_ENDPOINT, get_default_endpoint(api_provider))
|
||||||
allow_local = config.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
allow_local = config.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
||||||
if allow_local:
|
if allow_local:
|
||||||
_LOGGER.warning(
|
_LOGGER.info(
|
||||||
"Local network mode enabled for endpoint %s — "
|
"Local network mode enabled for endpoint %s — "
|
||||||
"SSRF protection disabled, API credentials may be sent without TLS",
|
"SSRF protection relaxed for self-hosted proxies",
|
||||||
raw_endpoint,
|
raw_endpoint,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
endpoint = await validate_endpoint(hass, raw_endpoint, allow_local=allow_local)
|
endpoint, resolved_ips = await validate_endpoint(
|
||||||
|
hass, raw_endpoint, allow_local=allow_local
|
||||||
|
)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
_LOGGER.error("Invalid API endpoint: %s", err)
|
_LOGGER.error("Invalid API endpoint: %s", err)
|
||||||
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from err
|
raise ConfigEntryNotReady(f"Invalid API endpoint: {err}") from err
|
||||||
|
|
||||||
|
# Pinned session closes DNS-rebinding TOCTOU and isolates cookies
|
||||||
|
# from other integrations sharing the same endpoint hostname.
|
||||||
|
# The integration owns this session: APIClient.shutdown() closes it
|
||||||
|
# on unload, the except handler below closes it on failed setup.
|
||||||
|
session = create_pinned_session(endpoint, resolved_ips)
|
||||||
# API key can now be updated via options
|
# API key can now be updated via options
|
||||||
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
|
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
|
||||||
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
|
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
|
||||||
@@ -294,6 +325,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
|
temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
|
||||||
max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
|
max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
|
||||||
context_messages = config.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
|
context_messages = config.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
|
||||||
|
disable_thinking = config.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING)
|
||||||
|
|
||||||
headers = build_auth_headers(api_provider, api_key)
|
headers = build_auth_headers(api_provider, api_key)
|
||||||
|
|
||||||
@@ -324,6 +356,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
max_history_size=max_history_size,
|
max_history_size=max_history_size,
|
||||||
context_messages=context_messages,
|
context_messages=context_messages,
|
||||||
api_timeout=api_timeout,
|
api_timeout=api_timeout,
|
||||||
|
disable_thinking=disable_thinking,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize coordinator (directories, history, metrics)
|
# Initialize coordinator (directories, history, metrics)
|
||||||
@@ -335,6 +368,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
# A reload after the last entry was unloaded needs the services back.
|
||||||
|
_async_register_services(hass)
|
||||||
|
|
||||||
_LOGGER.debug("Stored coordinator in hass.data[%s][%s]", DOMAIN, entry.entry_id)
|
_LOGGER.debug("Stored coordinator in hass.data[%s][%s]", DOMAIN, entry.entry_id)
|
||||||
|
|
||||||
# Set up platforms
|
# Set up platforms
|
||||||
@@ -343,12 +379,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# Register update listener for options changes
|
# Register update listener for options changes
|
||||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||||
|
|
||||||
|
# HA Core stop does not unload entries, so close the dedicated
|
||||||
|
# session on the CLOSE event too; unload removes this listener
|
||||||
|
# and closes the session via APIClient.shutdown() instead.
|
||||||
|
async def _async_close_session_on_stop(_event) -> None:
|
||||||
|
if not session.closed:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
hass.bus.async_listen_once(
|
||||||
|
EVENT_HOMEASSISTANT_CLOSE, _async_close_session_on_stop
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.debug("Setup completed for %s", instance_name)
|
_LOGGER.debug("Setup completed for %s", instance_name)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.exception("Error setting up HA Text AI: %s", err)
|
_LOGGER.exception("Error setting up HA Text AI: %s", err)
|
||||||
|
if session is not None and not session.closed:
|
||||||
|
await session.close()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
@@ -356,7 +407,6 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|||||||
_LOGGER.info("Options updated for %s, reloading integration", entry.title)
|
_LOGGER.info("Options updated for %s, reloading integration", entry.title)
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
try:
|
try:
|
||||||
@@ -369,8 +419,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
await coordinator.async_shutdown()
|
await coordinator.async_shutdown()
|
||||||
|
|
||||||
|
# When removing the last config entry, also unregister services and
|
||||||
|
# clear the domain bucket so HA doesn't show stale services in the UI.
|
||||||
if not hass.data.get(DOMAIN):
|
if not hass.data.get(DOMAIN):
|
||||||
hass.data.pop(DOMAIN, None)
|
hass.data.pop(DOMAIN, None)
|
||||||
|
for service in (
|
||||||
|
SERVICE_ASK_QUESTION,
|
||||||
|
SERVICE_CLEAR_HISTORY,
|
||||||
|
SERVICE_GET_HISTORY,
|
||||||
|
SERVICE_SET_SYSTEM_PROMPT,
|
||||||
|
):
|
||||||
|
if hass.services.has_service(DOMAIN, service):
|
||||||
|
hass.services.async_remove(DOMAIN, service)
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
API Client for HA Text AI.
|
API Client for HA Text AI.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, Dict, List, Optional
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
from aiohttp import ClientSession, ClientTimeout
|
from aiohttp import ClientSession, ClientTimeout
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@@ -29,7 +31,6 @@ from .const import (
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class APIClient:
|
class APIClient:
|
||||||
"""API Client for OpenAI and Anthropic."""
|
"""API Client for OpenAI and Anthropic."""
|
||||||
|
|
||||||
@@ -37,11 +38,11 @@ class APIClient:
|
|||||||
self,
|
self,
|
||||||
session: ClientSession,
|
session: ClientSession,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
headers: Dict[str, str],
|
headers: dict[str, str],
|
||||||
api_provider: str,
|
api_provider: str,
|
||||||
model: str,
|
model: str,
|
||||||
api_timeout: int = DEFAULT_API_TIMEOUT,
|
api_timeout: int = DEFAULT_API_TIMEOUT,
|
||||||
api_key: Optional[str] = None,
|
api_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize API client."""
|
"""Initialize API client."""
|
||||||
self.session = session
|
self.session = session
|
||||||
@@ -89,12 +90,22 @@ class APIClient:
|
|||||||
async def _make_request(
|
async def _make_request(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
payload: Dict[str, Any],
|
payload: dict[str, Any],
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Make API request with retry logic for transient errors only."""
|
"""Make API request with retry logic for transient errors only.
|
||||||
|
|
||||||
|
Retries on:
|
||||||
|
- asyncio.TimeoutError
|
||||||
|
- HTTP 429 (rate limit) — honors Retry-After header when present
|
||||||
|
- HTTP 502/503/504 (upstream transient errors)
|
||||||
|
|
||||||
|
4xx (other than 429) return immediately — they are not retryable.
|
||||||
|
"""
|
||||||
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
|
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
|
||||||
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
|
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
|
||||||
|
|
||||||
|
retryable_5xx = {502, 503, 504}
|
||||||
|
|
||||||
for attempt in range(API_RETRY_COUNT):
|
for attempt in range(API_RETRY_COUNT):
|
||||||
try:
|
try:
|
||||||
async with self.session.post(
|
async with self.session.post(
|
||||||
@@ -102,6 +113,9 @@ class APIClient:
|
|||||||
json=payload,
|
json=payload,
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
|
# The session pins DNS to validated IPs; following a
|
||||||
|
# redirect would resolve a new host past that pin.
|
||||||
|
allow_redirects=False,
|
||||||
) as response:
|
) as response:
|
||||||
_LOGGER.debug("Response status: %s", response.status)
|
_LOGGER.debug("Response status: %s", response.status)
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
@@ -114,25 +128,41 @@ class APIClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
error_data = {"raw": await response.text()}
|
error_data = {"raw": await response.text()}
|
||||||
|
|
||||||
# Rate limit — retry with backoff
|
# Rate limit — retry with backoff, prefer Retry-After header
|
||||||
if response.status == 429:
|
if response.status == 429:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
|
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
|
||||||
)
|
)
|
||||||
if attempt < API_RETRY_COUNT - 1:
|
if attempt < API_RETRY_COUNT - 1:
|
||||||
await asyncio.sleep(2 ** attempt)
|
retry_after = self._parse_retry_after(
|
||||||
|
response.headers.get("Retry-After")
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_after or (2 ** attempt))
|
||||||
continue
|
continue
|
||||||
raise HomeAssistantError("API rate limit exceeded")
|
raise HomeAssistantError("API rate limit exceeded")
|
||||||
|
|
||||||
# Client/server errors — don't retry
|
# Upstream transient errors — retry with backoff
|
||||||
|
if response.status in retryable_5xx:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Upstream %d on attempt %d/%d",
|
||||||
|
response.status, attempt + 1, API_RETRY_COUNT,
|
||||||
|
)
|
||||||
|
if attempt < API_RETRY_COUNT - 1:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"Upstream error after retries: status {response.status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Other client/server errors — don't retry
|
||||||
truncated_error = str(error_data)[:512]
|
truncated_error = str(error_data)[:512]
|
||||||
_LOGGER.error("API error (status %d): %s", response.status, truncated_error)
|
_LOGGER.error("API error (status %d): %s", response.status, truncated_error)
|
||||||
raise HomeAssistantError(f"API error: status {response.status}")
|
raise HomeAssistantError(f"API error: status {response.status}")
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError as err:
|
||||||
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
|
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
|
||||||
if attempt == API_RETRY_COUNT - 1:
|
if attempt == API_RETRY_COUNT - 1:
|
||||||
raise HomeAssistantError("API request timed out")
|
raise HomeAssistantError("API request timed out") from err
|
||||||
await asyncio.sleep(2 ** attempt)
|
await asyncio.sleep(2 ** attempt)
|
||||||
except HomeAssistantError:
|
except HomeAssistantError:
|
||||||
raise
|
raise
|
||||||
@@ -147,15 +177,29 @@ class APIClient:
|
|||||||
|
|
||||||
raise HomeAssistantError("API request failed after all retries")
|
raise HomeAssistantError("API request failed after all retries")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_retry_after(value: str | None) -> float | None:
|
||||||
|
"""Parse Retry-After header (seconds). Caps at 60s to avoid long stalls."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
seconds = float(value.strip())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
if seconds <= 0:
|
||||||
|
return None
|
||||||
|
return min(seconds, 60.0)
|
||||||
|
|
||||||
async def create(
|
async def create(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
messages: List[Dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
structured_output: bool = False,
|
structured_output: bool = False,
|
||||||
json_schema: Optional[str] = None,
|
json_schema: str | None = None,
|
||||||
) -> Dict[str, Any]:
|
disable_thinking: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Create completion using appropriate API."""
|
"""Create completion using appropriate API."""
|
||||||
try:
|
try:
|
||||||
self._validate_parameters(temperature, max_tokens)
|
self._validate_parameters(temperature, max_tokens)
|
||||||
@@ -163,37 +207,129 @@ class APIClient:
|
|||||||
if self.api_provider == API_PROVIDER_ANTHROPIC:
|
if self.api_provider == API_PROVIDER_ANTHROPIC:
|
||||||
return await self._create_anthropic_completion(
|
return await self._create_anthropic_completion(
|
||||||
model, messages, temperature, max_tokens,
|
model, messages, temperature, max_tokens,
|
||||||
structured_output, json_schema
|
structured_output, json_schema, disable_thinking
|
||||||
)
|
)
|
||||||
elif self.api_provider == API_PROVIDER_DEEPSEEK:
|
elif self.api_provider == API_PROVIDER_DEEPSEEK:
|
||||||
return await self._create_deepseek_completion(
|
return await self._create_deepseek_completion(
|
||||||
model, messages, temperature, max_tokens,
|
model, messages, temperature, max_tokens,
|
||||||
structured_output, json_schema
|
structured_output, json_schema, disable_thinking
|
||||||
)
|
)
|
||||||
elif self.api_provider == API_PROVIDER_GEMINI:
|
elif self.api_provider == API_PROVIDER_GEMINI:
|
||||||
return await self._create_gemini_completion(
|
return await self._create_gemini_completion(
|
||||||
model, messages, temperature, max_tokens,
|
model, messages, temperature, max_tokens,
|
||||||
structured_output, json_schema
|
structured_output, json_schema, disable_thinking
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return await self._create_openai_completion(
|
return await self._create_openai_completion(
|
||||||
model, messages, temperature, max_tokens,
|
model, messages, temperature, max_tokens,
|
||||||
structured_output, json_schema
|
structured_output, json_schema, disable_thinking
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error("API request failed: %s", str(e))
|
_LOGGER.error("API request failed: %s", str(e))
|
||||||
raise HomeAssistantError(f"API request failed: {str(e)}")
|
raise HomeAssistantError(f"API request failed: {str(e)}") from e
|
||||||
|
|
||||||
|
# Non-reasoning variants whose names otherwise overlap with the
|
||||||
|
# reasoning prefix set (e.g. "gpt-5-chat-latest" is classic chat).
|
||||||
|
_OPENAI_NON_REASONING_PATTERNS: tuple[str, ...] = ("gpt-5-chat",)
|
||||||
|
_OPENAI_REASONING_REGEX = re.compile(
|
||||||
|
r"^(?:o\d+|gpt-[5-9](?:\.\d+)?)(?:[-_].*)?$"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_openai_reasoning_model(cls, model: str) -> bool:
|
||||||
|
"""Detect OpenAI reasoning models (o-series and GPT-5+ family).
|
||||||
|
|
||||||
|
Reasoning models require max_completion_tokens (not max_tokens),
|
||||||
|
do not accept custom temperature, and use "developer" role instead
|
||||||
|
of "system". Uses a regex so future o5/gpt-6 releases are caught
|
||||||
|
without code change. Explicitly excludes chat-variants
|
||||||
|
(e.g. gpt-5-chat-latest) which are classic chat models.
|
||||||
|
"""
|
||||||
|
if not model:
|
||||||
|
return False
|
||||||
|
m = model.strip().lower()
|
||||||
|
# OpenRouter-style "openai/o3" prefix — strip provider namespace.
|
||||||
|
if "/" in m:
|
||||||
|
m = m.rsplit("/", 1)[-1]
|
||||||
|
for non_reasoning in cls._OPENAI_NON_REASONING_PATTERNS:
|
||||||
|
if m.startswith(non_reasoning):
|
||||||
|
return False
|
||||||
|
return bool(cls._OPENAI_REASONING_REGEX.match(m))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_system_to_developer(
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Rename role "system" to "developer" for OpenAI reasoning models."""
|
||||||
|
return [
|
||||||
|
{**m, "role": "developer"} if m.get("role") == "system" else m
|
||||||
|
for m in messages
|
||||||
|
]
|
||||||
|
|
||||||
|
# Matches /no_think only as a standalone soft-switch token (word-bounded),
|
||||||
|
# not when users discuss the concept ("discuss /no_think semantics").
|
||||||
|
_NO_THINK_TOKEN_RE = re.compile(r"(?:^|\s)/no_think(?:\s|$)")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _apply_no_think_tag(
|
||||||
|
cls,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Append Qwen-style /no_think soft switch to the last user message.
|
||||||
|
|
||||||
|
Why: Qwen3 reasoning models treat "/no_think" in the last user turn as a
|
||||||
|
request to skip thinking. Non-Qwen models ignore the trailing token
|
||||||
|
harmlessly, so this is safe to apply to all OpenAI-compatible backends.
|
||||||
|
Uses word-boundary regex for dedup so that user content mentioning
|
||||||
|
"/no_think" mid-sentence isn't mistaken for an existing soft switch.
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return messages
|
||||||
|
patched = [m.copy() for m in messages]
|
||||||
|
for i in range(len(patched) - 1, -1, -1):
|
||||||
|
if patched[i].get("role") == "user":
|
||||||
|
content = patched[i].get("content", "")
|
||||||
|
if not cls._NO_THINK_TOKEN_RE.search(content):
|
||||||
|
patched[i]["content"] = f"{content.rstrip()} /no_think".lstrip()
|
||||||
|
break
|
||||||
|
return patched
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_think_blocks(text: str) -> str:
|
||||||
|
"""Remove <think>...</think> reasoning blocks from model output.
|
||||||
|
|
||||||
|
Why: Some reasoning models (DeepSeek-R1, Qwen-Thinking) emit chain-of-thought
|
||||||
|
wrapped in <think> tags even when thinking is nominally disabled. Strip them
|
||||||
|
so the final answer stays clean. Handles nested blocks via iterative
|
||||||
|
replacement, and drops dangling opening tags when a response is
|
||||||
|
truncated mid-block.
|
||||||
|
"""
|
||||||
|
if not text or "<think>" not in text:
|
||||||
|
return text
|
||||||
|
pattern = re.compile(r"<think>.*?</think>", flags=re.DOTALL)
|
||||||
|
cleaned = text
|
||||||
|
# Iterative pass: each iteration peels one layer of nested tags.
|
||||||
|
# Bounded to 10 iterations to avoid pathological inputs.
|
||||||
|
for _ in range(10):
|
||||||
|
new = pattern.sub("", cleaned)
|
||||||
|
if new == cleaned:
|
||||||
|
break
|
||||||
|
cleaned = new
|
||||||
|
# If a truncated response left a dangling <think> open, drop the rest
|
||||||
|
# from that marker onward to avoid leaking partial reasoning.
|
||||||
|
if "<think>" in cleaned:
|
||||||
|
cleaned = cleaned.split("<think>", 1)[0]
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _apply_structured_output(
|
def _apply_structured_output(
|
||||||
payload: Dict[str, Any],
|
payload: dict[str, Any],
|
||||||
structured_output: bool,
|
structured_output: bool,
|
||||||
json_schema: Optional[str],
|
json_schema: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply OpenAI-compatible structured output to payload in-place."""
|
"""Apply OpenAI-compatible structured output to payload in-place."""
|
||||||
if not (structured_output and json_schema):
|
if not (structured_output and json_schema):
|
||||||
return
|
return
|
||||||
import json
|
|
||||||
try:
|
try:
|
||||||
schema = json.loads(json_schema)
|
schema = json.loads(json_schema)
|
||||||
payload["response_format"] = {
|
payload["response_format"] = {
|
||||||
@@ -211,28 +347,58 @@ class APIClient:
|
|||||||
async def _create_deepseek_completion(
|
async def _create_deepseek_completion(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
messages: List[Dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
structured_output: bool = False,
|
structured_output: bool = False,
|
||||||
json_schema: Optional[str] = None,
|
json_schema: str | None = None,
|
||||||
) -> Dict[str, Any]:
|
disable_thinking: bool = False,
|
||||||
"""Create completion using DeepSeek API."""
|
) -> dict[str, Any]:
|
||||||
|
"""Create completion using DeepSeek API.
|
||||||
|
|
||||||
|
DeepSeek-reasoner (R1) is a reasoning model: it ignores /no_think
|
||||||
|
(thinking is always on by design) and emits reasoning_content as a
|
||||||
|
separate field alongside content. We skip the no_think append for
|
||||||
|
this model and preserve reasoning_content in the response payload
|
||||||
|
so it's available for logging/debug.
|
||||||
|
|
||||||
|
DeepSeek V4+ (deepseek-v4-flash/-pro) selects thinking mode via a
|
||||||
|
top-level "thinking" request parameter instead of the model name,
|
||||||
|
so /no_think does not apply there.
|
||||||
|
"""
|
||||||
url = f"{self.endpoint}/chat/completions"
|
url = f"{self.endpoint}/chat/completions"
|
||||||
|
m_lower = model.lower()
|
||||||
|
is_reasoner = "reasoner" in m_lower
|
||||||
|
is_v4plus = re.search(r"deepseek-v[4-9]", m_lower) is not None
|
||||||
|
final_messages = (
|
||||||
|
self._apply_no_think_tag(messages)
|
||||||
|
if (disable_thinking and not is_reasoner and not is_v4plus)
|
||||||
|
else messages
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": final_messages,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
}
|
}
|
||||||
|
if disable_thinking and is_v4plus:
|
||||||
|
payload["thinking"] = {"type": "disabled"}
|
||||||
self._apply_structured_output(payload, structured_output, json_schema)
|
self._apply_structured_output(payload, structured_output, json_schema)
|
||||||
|
|
||||||
data = await self._make_request(url, payload)
|
data = await self._make_request(url, payload)
|
||||||
|
message = data["choices"][0]["message"]
|
||||||
|
content = message.get("content", "")
|
||||||
|
reasoning = message.get("reasoning_content")
|
||||||
|
if disable_thinking and not is_reasoner:
|
||||||
|
content = self._strip_think_blocks(content)
|
||||||
return {
|
return {
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"message": {"content": data["choices"][0]["message"]["content"]},
|
"message": {
|
||||||
|
"content": content,
|
||||||
|
**({"reasoning_content": reasoning} if reasoning else {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"usage": {
|
"usage": {
|
||||||
@@ -245,27 +411,59 @@ class APIClient:
|
|||||||
async def _create_openai_completion(
|
async def _create_openai_completion(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
messages: List[Dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
structured_output: bool = False,
|
structured_output: bool = False,
|
||||||
json_schema: Optional[str] = None,
|
json_schema: str | None = None,
|
||||||
) -> Dict[str, Any]:
|
disable_thinking: bool = False,
|
||||||
"""Create completion using OpenAI API."""
|
) -> dict[str, Any]:
|
||||||
|
"""Create completion using OpenAI API.
|
||||||
|
|
||||||
|
Reasoning models (o-series, gpt-5 family) require a different payload
|
||||||
|
shape: max_completion_tokens instead of max_tokens, no custom
|
||||||
|
temperature, and role "developer" instead of "system". When
|
||||||
|
disable_thinking=True for a reasoning model we set reasoning_effort
|
||||||
|
to "low" to minimize hidden CoT tokens. For classic chat models the
|
||||||
|
Qwen-style /no_think soft switch is appended instead.
|
||||||
|
"""
|
||||||
url = f"{self.endpoint}/chat/completions"
|
url = f"{self.endpoint}/chat/completions"
|
||||||
payload = {
|
is_reasoning = self._is_openai_reasoning_model(model)
|
||||||
"model": model,
|
|
||||||
"messages": messages,
|
if is_reasoning:
|
||||||
"temperature": temperature,
|
prepared_messages = self._convert_system_to_developer(messages)
|
||||||
"max_tokens": max_tokens,
|
payload: dict[str, Any] = {
|
||||||
}
|
"model": model,
|
||||||
|
"messages": prepared_messages,
|
||||||
|
"max_completion_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
if disable_thinking:
|
||||||
|
# gpt-5+ supports "minimal" (cheapest, lowest-CoT). o-series
|
||||||
|
# rejects "minimal" and accepts low/medium/high — fall back to "low".
|
||||||
|
effort = "minimal" if model.lower().startswith(("gpt-5", "gpt5")) else "low"
|
||||||
|
payload["reasoning_effort"] = effort
|
||||||
|
else:
|
||||||
|
prepared_messages = (
|
||||||
|
self._apply_no_think_tag(messages) if disable_thinking else messages
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": prepared_messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
self._apply_structured_output(payload, structured_output, json_schema)
|
self._apply_structured_output(payload, structured_output, json_schema)
|
||||||
|
|
||||||
data = await self._make_request(url, payload)
|
data = await self._make_request(url, payload)
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
# Strip <think> blocks only for classic chat models. Reasoning models
|
||||||
|
# never emit the tags in user-facing content.
|
||||||
|
if disable_thinking and not is_reasoning:
|
||||||
|
content = self._strip_think_blocks(content)
|
||||||
return {
|
return {
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"message": {"content": data["choices"][0]["message"]["content"]},
|
"message": {"content": content},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"usage": {
|
"usage": {
|
||||||
@@ -278,12 +476,13 @@ class APIClient:
|
|||||||
async def _create_anthropic_completion(
|
async def _create_anthropic_completion(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
messages: List[Dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
structured_output: bool = False,
|
structured_output: bool = False,
|
||||||
json_schema: Optional[str] = None,
|
json_schema: str | None = None,
|
||||||
) -> Dict[str, Any]:
|
disable_thinking: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Create completion using Anthropic API."""
|
"""Create completion using Anthropic API."""
|
||||||
url = f"{self.endpoint}/v1/messages"
|
url = f"{self.endpoint}/v1/messages"
|
||||||
|
|
||||||
@@ -298,35 +497,56 @@ class APIClient:
|
|||||||
else:
|
else:
|
||||||
filtered_messages.append(msg)
|
filtered_messages.append(msg)
|
||||||
|
|
||||||
# For Anthropic, add structured output instruction to system prompt
|
# For Anthropic, add structured output instruction to system prompt.
|
||||||
|
# Validate schema is well-formed JSON before concatenation: untrusted
|
||||||
|
# schema strings (built from templates/webhook data) could otherwise
|
||||||
|
# break out of the JSON fence and rewrite the system instruction.
|
||||||
if structured_output and json_schema:
|
if structured_output and json_schema:
|
||||||
schema_instruction = (
|
try:
|
||||||
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
|
json.loads(json_schema)
|
||||||
f"this JSON Schema:\n{json_schema}\n"
|
except json.JSONDecodeError as err:
|
||||||
f"Do not include any text before or after the JSON. "
|
_LOGGER.warning(
|
||||||
f"Do not wrap the JSON in markdown code blocks."
|
"Anthropic: invalid JSON schema, ignoring structured_output: %s", err
|
||||||
)
|
)
|
||||||
if system_prompt:
|
|
||||||
system_prompt += schema_instruction
|
|
||||||
else:
|
else:
|
||||||
system_prompt = schema_instruction.strip()
|
schema_instruction = (
|
||||||
_LOGGER.debug("Anthropic structured output enabled via system prompt")
|
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
|
||||||
|
f"this JSON Schema:\n{json_schema}\n"
|
||||||
|
f"Do not include any text before or after the JSON. "
|
||||||
|
f"Do not wrap the JSON in markdown code blocks."
|
||||||
|
)
|
||||||
|
if system_prompt:
|
||||||
|
system_prompt += schema_instruction
|
||||||
|
else:
|
||||||
|
system_prompt = schema_instruction.strip()
|
||||||
|
_LOGGER.debug("Anthropic structured output enabled via system prompt")
|
||||||
|
|
||||||
|
# Anthropic accepts temperature in [0, 1], not [0, 2] like OpenAI.
|
||||||
|
# Clip silently to avoid a 400 when a user-set config exceeds the cap.
|
||||||
|
clipped_temp = min(1.0, max(0.0, float(temperature)))
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": filtered_messages,
|
"messages": filtered_messages,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"temperature": temperature,
|
"temperature": clipped_temp,
|
||||||
}
|
}
|
||||||
|
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
payload["system"] = system_prompt
|
payload["system"] = system_prompt
|
||||||
|
|
||||||
data = await self._make_request(url, payload)
|
data = await self._make_request(url, payload)
|
||||||
|
# Anthropic returns an array of content blocks; if extended thinking
|
||||||
|
# is ever enabled the first block may be type="thinking". Find the
|
||||||
|
# first text-type block instead of hardcoding index [0].
|
||||||
|
content = ""
|
||||||
|
for block in data.get("content", []):
|
||||||
|
if block.get("type") == "text":
|
||||||
|
content = block.get("text", "")
|
||||||
|
break
|
||||||
return {
|
return {
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"message": {"content": data["content"][0]["text"]},
|
"message": {"content": content},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"usage": {
|
"usage": {
|
||||||
@@ -339,12 +559,13 @@ class APIClient:
|
|||||||
async def _create_gemini_completion(
|
async def _create_gemini_completion(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
messages: List[Dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
structured_output: bool = False,
|
structured_output: bool = False,
|
||||||
json_schema: Optional[str] = None,
|
json_schema: str | None = None,
|
||||||
) -> Dict[str, Any]:
|
disable_thinking: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Create completion using Gemini API with google-genai library.
|
"""Create completion using Gemini API with google-genai library.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -395,7 +616,6 @@ class APIClient:
|
|||||||
parsed_schema = None
|
parsed_schema = None
|
||||||
if structured_output and json_schema:
|
if structured_output and json_schema:
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
parsed_schema = json.loads(json_schema)
|
parsed_schema = json.loads(json_schema)
|
||||||
_LOGGER.debug("Gemini structured output enabled with schema")
|
_LOGGER.debug("Gemini structured output enabled with schema")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
@@ -418,6 +638,30 @@ class APIClient:
|
|||||||
config.response_mime_type = "application/json"
|
config.response_mime_type = "application/json"
|
||||||
config.response_schema = parsed_schema
|
config.response_schema = parsed_schema
|
||||||
|
|
||||||
|
# Disable thinking. Gemini 3.x+ replaced the numeric
|
||||||
|
# thinking_budget with a semantic thinking_level; Pro
|
||||||
|
# variants do not accept MINIMAL, their floor is LOW.
|
||||||
|
# Gemini 2.5: Flash accepts thinking_budget=0 (fully off),
|
||||||
|
# Pro rejects 0 and requires at least 128 tokens.
|
||||||
|
# 2.0 and earlier ignore the field.
|
||||||
|
if disable_thinking:
|
||||||
|
m_lower = model.lower()
|
||||||
|
try:
|
||||||
|
if re.search(r"gemini-[3-9]", m_lower):
|
||||||
|
level = "LOW" if "pro" in m_lower else "MINIMAL"
|
||||||
|
config.thinking_config = types.ThinkingConfig(
|
||||||
|
thinking_level=level
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
budget = 128 if "2.5-pro" in m_lower else 0
|
||||||
|
config.thinking_config = types.ThinkingConfig(
|
||||||
|
thinking_budget=budget
|
||||||
|
)
|
||||||
|
except (AttributeError, TypeError, ValueError) as err:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"ThinkingConfig not supported by this google-genai version: %s", err
|
||||||
|
)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
config = await asyncio.to_thread(create_config)
|
config = await asyncio.to_thread(create_config)
|
||||||
@@ -491,6 +735,9 @@ class APIClient:
|
|||||||
|
|
||||||
response_text, usage = await asyncio.to_thread(extract_response)
|
response_text, usage = await asyncio.to_thread(extract_response)
|
||||||
|
|
||||||
|
if disable_thinking:
|
||||||
|
response_text = self._strip_think_blocks(response_text)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"choices": [{
|
"choices": [{
|
||||||
"message": {
|
"message": {
|
||||||
@@ -502,13 +749,19 @@ class APIClient:
|
|||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
_LOGGER.error("Google Gemini library not installed: %s", e)
|
_LOGGER.error("Google Gemini library not installed: %s", e)
|
||||||
raise HomeAssistantError("Missing dependency: google-genai. Please install it.")
|
raise HomeAssistantError(
|
||||||
|
"Missing dependency: google-genai. Please install it."
|
||||||
|
) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error("Gemini API error: %s", e)
|
_LOGGER.error("Gemini API error: %s", e)
|
||||||
raise HomeAssistantError("Gemini API request failed")
|
raise HomeAssistantError(f"Gemini API request failed: {e}") from e
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Shutdown API client."""
|
"""Shutdown API client and close its dedicated session."""
|
||||||
_LOGGER.debug("Shutting down API client")
|
_LOGGER.debug("Shutting down API client")
|
||||||
self._closed = True
|
self._closed = True
|
||||||
# Do NOT close the shared Home Assistant session
|
# The session is dedicated to this config entry (pinned resolver,
|
||||||
|
# isolated cookie jar), so it must be closed here to release the
|
||||||
|
# connector; nothing else owns it.
|
||||||
|
if self.session is not None and not self.session.closed:
|
||||||
|
await self.session.close()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Config flow for HA text AI integration.
|
Config flow for HA text AI integration.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -9,14 +9,13 @@ Config flow for HA text AI integration.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.config_entries import ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlowResult
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@@ -56,16 +55,22 @@ from .const import (
|
|||||||
MAX_HISTORY_SIZE,
|
MAX_HISTORY_SIZE,
|
||||||
CONF_ALLOW_LOCAL_NETWORK,
|
CONF_ALLOW_LOCAL_NETWORK,
|
||||||
DEFAULT_ALLOW_LOCAL_NETWORK,
|
DEFAULT_ALLOW_LOCAL_NETWORK,
|
||||||
|
CONF_DISABLE_THINKING,
|
||||||
|
DEFAULT_DISABLE_THINKING,
|
||||||
)
|
)
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .utils import normalize_name, safe_log_data, validate_endpoint
|
from .utils import (
|
||||||
|
create_pinned_session,
|
||||||
|
normalize_name,
|
||||||
|
safe_log_data,
|
||||||
|
validate_endpoint,
|
||||||
|
)
|
||||||
from .providers import get_default_endpoint, get_default_model, build_auth_headers
|
from .providers import get_default_endpoint, get_default_model, build_auth_headers
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _build_parameter_schema(data: dict[str, Any]) -> dict:
|
||||||
def _build_parameter_schema(data: Dict[str, Any]) -> dict:
|
|
||||||
"""Build shared parameter schema fields used by both ConfigFlow and OptionsFlow."""
|
"""Build shared parameter schema fields used by both ConfigFlow and OptionsFlow."""
|
||||||
return {
|
return {
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
@@ -92,9 +97,12 @@ def _build_parameter_schema(data: Dict[str, Any]) -> dict:
|
|||||||
CONF_MAX_HISTORY_SIZE,
|
CONF_MAX_HISTORY_SIZE,
|
||||||
default=data.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
|
default=data.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
|
||||||
): vol.All(vol.Coerce(int), vol.Range(min=MIN_HISTORY_SIZE, max=MAX_HISTORY_SIZE)),
|
): vol.All(vol.Coerce(int), vol.Range(min=MIN_HISTORY_SIZE, max=MAX_HISTORY_SIZE)),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_DISABLE_THINKING,
|
||||||
|
default=data.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
|
||||||
|
): bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for HA text AI."""
|
"""Handle a config flow for HA text AI."""
|
||||||
|
|
||||||
@@ -106,7 +114,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self._data = {}
|
self._data = {}
|
||||||
self._provider = None
|
self._provider = None
|
||||||
|
|
||||||
async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
@@ -125,13 +133,15 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return await self.async_step_provider()
|
return await self.async_step_provider()
|
||||||
|
|
||||||
def _build_provider_schema(
|
def _build_provider_schema(
|
||||||
self, data: Optional[Dict[str, Any]] = None
|
self, data: dict[str, Any] | None = None
|
||||||
) -> vol.Schema:
|
) -> vol.Schema:
|
||||||
"""Build provider configuration schema with optional defaults from data."""
|
"""Build provider configuration schema with optional defaults from data."""
|
||||||
defaults = data or {}
|
defaults = data or {}
|
||||||
schema_dict = {
|
schema_dict = {
|
||||||
vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, DEFAULT_INSTANCE_NAME)): str,
|
vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, DEFAULT_INSTANCE_NAME)): str,
|
||||||
vol.Required(CONF_API_KEY): str,
|
vol.Required(CONF_API_KEY): selector.TextSelector(
|
||||||
|
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||||
|
),
|
||||||
vol.Required(CONF_MODEL, default=defaults.get(CONF_MODEL, get_default_model(self._provider))): str,
|
vol.Required(CONF_MODEL, default=defaults.get(CONF_MODEL, get_default_model(self._provider))): str,
|
||||||
vol.Required(CONF_API_ENDPOINT, default=defaults.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
|
vol.Required(CONF_API_ENDPOINT, default=defaults.get(CONF_API_ENDPOINT, get_default_endpoint(self._provider))): str,
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
@@ -142,7 +152,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
schema_dict.update(_build_parameter_schema(defaults))
|
schema_dict.update(_build_parameter_schema(defaults))
|
||||||
return vol.Schema(schema_dict)
|
return vol.Schema(schema_dict)
|
||||||
|
|
||||||
async def async_step_provider(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
async def async_step_provider(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Handle provider configuration step."""
|
"""Handle provider configuration step."""
|
||||||
self._errors = {}
|
self._errors = {}
|
||||||
|
|
||||||
@@ -221,7 +231,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
async def _async_validate_api(self, user_input: Dict[str, Any]) -> bool:
|
async def _async_validate_api(self, user_input: dict[str, Any]) -> bool:
|
||||||
"""Validate API connection using provider registry."""
|
"""Validate API connection using provider registry."""
|
||||||
try:
|
try:
|
||||||
if CONF_API_KEY not in user_input:
|
if CONF_API_KEY not in user_input:
|
||||||
@@ -231,7 +241,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
||||||
endpoint = await validate_endpoint(
|
endpoint, resolved_ips = await validate_endpoint(
|
||||||
self.hass, user_input[CONF_API_ENDPOINT], allow_local=allow_local
|
self.hass, user_input[CONF_API_ENDPOINT], allow_local=allow_local
|
||||||
)
|
)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
@@ -245,28 +255,35 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
session = async_get_clientsession(self.hass)
|
|
||||||
headers = build_auth_headers(self._provider, user_input[CONF_API_KEY])
|
headers = build_auth_headers(self._provider, user_input[CONF_API_KEY])
|
||||||
|
|
||||||
from .providers import get_provider_config
|
from .providers import get_provider_config
|
||||||
check_path = get_provider_config(self._provider).get("check_path", "/models")
|
check_path = get_provider_config(self._provider).get("check_path", "/models")
|
||||||
check_url = f"{endpoint}{check_path}"
|
check_url = f"{endpoint}{check_path}"
|
||||||
|
|
||||||
async with session.get(check_url, headers=headers) as response:
|
# Pinned session ensures the reachability check goes to the same
|
||||||
if response.status == 401:
|
# IP that will later be used by api_client (no DNS rebinding).
|
||||||
self._errors["base"] = "invalid_auth"
|
session = create_pinned_session(endpoint, resolved_ips)
|
||||||
return False
|
try:
|
||||||
elif response.status != 200:
|
async with session.get(
|
||||||
self._errors["base"] = "cannot_connect"
|
check_url, headers=headers, allow_redirects=False
|
||||||
return False
|
) as response:
|
||||||
return True
|
if response.status == 401:
|
||||||
|
self._errors["base"] = "invalid_auth"
|
||||||
|
return False
|
||||||
|
elif response.status != 200:
|
||||||
|
self._errors["base"] = "cannot_connect"
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("API validation error: %s", str(err))
|
_LOGGER.error("API validation error: %s", str(err))
|
||||||
self._errors["base"] = "cannot_connect"
|
self._errors["base"] = "cannot_connect"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _create_entry(self, user_input: Dict[str, Any]) -> ConfigFlowResult:
|
async def _create_entry(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||||
"""Create the config entry with unique_id deduplication."""
|
"""Create the config entry with unique_id deduplication."""
|
||||||
instance_name = user_input[CONF_NAME]
|
instance_name = user_input[CONF_NAME]
|
||||||
normalized_name = normalize_name(instance_name)
|
normalized_name = normalize_name(instance_name)
|
||||||
@@ -290,6 +307,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES),
|
CONF_CONTEXT_MESSAGES: user_input.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES),
|
||||||
CONF_MAX_HISTORY_SIZE: user_input.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
|
CONF_MAX_HISTORY_SIZE: user_input.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY),
|
||||||
CONF_ALLOW_LOCAL_NETWORK: user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
|
CONF_ALLOW_LOCAL_NETWORK: user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK),
|
||||||
|
CONF_DISABLE_THINKING: user_input.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING),
|
||||||
}
|
}
|
||||||
|
|
||||||
_LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
|
_LOGGER.debug("Creating config entry with data: %s", safe_log_data(entry_data))
|
||||||
@@ -305,7 +323,6 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return OptionsFlowHandler()
|
return OptionsFlowHandler()
|
||||||
|
|
||||||
|
|
||||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
"""Handle options flow."""
|
"""Handle options flow."""
|
||||||
|
|
||||||
@@ -317,7 +334,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
endpoint = await validate_endpoint(self.hass, endpoint, allow_local=allow_local)
|
endpoint, resolved_ips = await validate_endpoint(
|
||||||
|
self.hass, endpoint, allow_local=allow_local
|
||||||
|
)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
_LOGGER.error("Endpoint validation failed: %s", err)
|
_LOGGER.error("Endpoint validation failed: %s", err)
|
||||||
self._errors["base"] = "cannot_connect"
|
self._errors["base"] = "cannot_connect"
|
||||||
@@ -326,32 +345,37 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
if provider == API_PROVIDER_GEMINI:
|
if provider == API_PROVIDER_GEMINI:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
session = async_get_clientsession(self.hass)
|
|
||||||
headers = build_auth_headers(provider, api_key)
|
headers = build_auth_headers(provider, api_key)
|
||||||
|
|
||||||
from .providers import get_provider_config
|
from .providers import get_provider_config
|
||||||
check_path = get_provider_config(provider).get("check_path", "/models")
|
check_path = get_provider_config(provider).get("check_path", "/models")
|
||||||
check_url = f"{endpoint}{check_path}"
|
check_url = f"{endpoint}{check_path}"
|
||||||
|
|
||||||
async with session.get(check_url, headers=headers) as response:
|
session = create_pinned_session(endpoint, resolved_ips)
|
||||||
if response.status == 401:
|
try:
|
||||||
self._errors["base"] = "invalid_auth"
|
async with session.get(
|
||||||
return False
|
check_url, headers=headers, allow_redirects=False
|
||||||
elif response.status != 200:
|
) as response:
|
||||||
self._errors["base"] = "cannot_connect"
|
if response.status == 401:
|
||||||
return False
|
self._errors["base"] = "invalid_auth"
|
||||||
return True
|
return False
|
||||||
|
elif response.status != 200:
|
||||||
|
self._errors["base"] = "cannot_connect"
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("API validation error: %s", str(err))
|
_LOGGER.error("API validation error: %s", str(err))
|
||||||
self._errors["base"] = "cannot_connect"
|
self._errors["base"] = "cannot_connect"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Handle provider selection step."""
|
"""Handle provider selection step."""
|
||||||
if not hasattr(self, "_errors"):
|
if not hasattr(self, "_errors"):
|
||||||
self._errors: dict[str, str] = {}
|
self._errors: dict[str, str] = {}
|
||||||
self._selected_provider: Optional[str] = None
|
self._selected_provider: str | None = None
|
||||||
current_data = {**self.config_entry.data, **self.config_entry.options}
|
current_data = {**self.config_entry.data, **self.config_entry.options}
|
||||||
current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI)
|
current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI)
|
||||||
|
|
||||||
@@ -377,7 +401,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_settings(self, user_input: Optional[Dict[str, Any]] = None) -> ConfigFlowResult:
|
async def async_step_settings(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
"""Handle settings configuration step."""
|
"""Handle settings configuration step."""
|
||||||
self._errors = {}
|
self._errors = {}
|
||||||
current_data = {**self.config_entry.data, **self.config_entry.options}
|
current_data = {**self.config_entry.data, **self.config_entry.options}
|
||||||
@@ -398,7 +422,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
api_key = user_input.get(CONF_API_KEY, "").strip()
|
api_key = user_input.get(CONF_API_KEY, "").strip()
|
||||||
endpoint = user_input.get(CONF_API_ENDPOINT, default_endpoint)
|
endpoint = user_input.get(CONF_API_ENDPOINT, default_endpoint)
|
||||||
|
|
||||||
# Require API key re-entry when endpoint or provider changed
|
# Require API key re-entry when endpoint or provider changed.
|
||||||
|
# Why: reusing a stored key after provider/endpoint change could
|
||||||
|
# ship credentials to a different service (e.g. OpenAI key to
|
||||||
|
# api.anthropic.com). Always force explicit re-entry.
|
||||||
stored_endpoint = current_data.get(CONF_API_ENDPOINT, "")
|
stored_endpoint = current_data.get(CONF_API_ENDPOINT, "")
|
||||||
endpoint_changed = endpoint != stored_endpoint
|
endpoint_changed = endpoint != stored_endpoint
|
||||||
if not api_key and (provider_changed or endpoint_changed):
|
if not api_key and (provider_changed or endpoint_changed):
|
||||||
@@ -418,8 +445,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fall back to stored key if not re-entered and endpoint unchanged
|
# Fall back to stored key only when neither provider nor endpoint changed.
|
||||||
if not api_key:
|
# Defensive: never silently reuse stored key across providers.
|
||||||
|
if not api_key and not provider_changed and not endpoint_changed:
|
||||||
api_key = current_data.get(CONF_API_KEY, "")
|
api_key = current_data.get(CONF_API_KEY, "")
|
||||||
|
|
||||||
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
allow_local = user_input.get(CONF_ALLOW_LOCAL_NETWORK, DEFAULT_ALLOW_LOCAL_NETWORK)
|
||||||
@@ -464,8 +492,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
def _get_settings_schema(
|
def _get_settings_schema(
|
||||||
self,
|
self,
|
||||||
provider: str,
|
provider: str,
|
||||||
current_data: Dict[str, Any],
|
current_data: dict[str, Any],
|
||||||
user_input: Optional[Dict[str, Any]],
|
user_input: dict[str, Any] | None,
|
||||||
default_endpoint: str,
|
default_endpoint: str,
|
||||||
default_model: str,
|
default_model: str,
|
||||||
) -> vol.Schema:
|
) -> vol.Schema:
|
||||||
@@ -473,7 +501,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
data = user_input or current_data
|
data = user_input or current_data
|
||||||
|
|
||||||
schema_dict = {
|
schema_dict = {
|
||||||
vol.Optional(CONF_API_KEY, default=""): str,
|
vol.Optional(CONF_API_KEY, default=""): selector.TextSelector(
|
||||||
|
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||||
|
),
|
||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_API_ENDPOINT,
|
CONF_API_ENDPOINT,
|
||||||
default=data.get(CONF_API_ENDPOINT, default_endpoint),
|
default=data.get(CONF_API_ENDPOINT, default_endpoint),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Constants for the HA text AI integration.
|
Constants for the HA text AI integration.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -29,7 +29,7 @@ API_PROVIDERS: Final = [
|
|||||||
API_PROVIDER_GEMINI
|
API_PROVIDER_GEMINI
|
||||||
]
|
]
|
||||||
|
|
||||||
VERSION: Final = "2.4.1"
|
VERSION: Final = "2.5.1"
|
||||||
|
|
||||||
# Default endpoints
|
# Default endpoints
|
||||||
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
|
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
|
||||||
@@ -50,6 +50,7 @@ CONF_CONTEXT_MESSAGES: Final = "context_messages"
|
|||||||
CONF_STRUCTURED_OUTPUT: Final = "structured_output"
|
CONF_STRUCTURED_OUTPUT: Final = "structured_output"
|
||||||
CONF_JSON_SCHEMA: Final = "json_schema"
|
CONF_JSON_SCHEMA: Final = "json_schema"
|
||||||
CONF_ALLOW_LOCAL_NETWORK: Final = "allow_local_network"
|
CONF_ALLOW_LOCAL_NETWORK: Final = "allow_local_network"
|
||||||
|
CONF_DISABLE_THINKING: Final = "disable_thinking"
|
||||||
|
|
||||||
ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100)
|
ABSOLUTE_MAX_HISTORY_SIZE: Final = 200 # Hard cap; UI allows max MAX_HISTORY_SIZE (100)
|
||||||
MAX_ATTRIBUTE_SIZE = 4 * 1024
|
MAX_ATTRIBUTE_SIZE = 4 * 1024
|
||||||
@@ -57,8 +58,11 @@ MAX_HISTORY_FILE_SIZE = 1 * 1024 * 1024
|
|||||||
# Default values
|
# Default values
|
||||||
DEFAULT_MODEL: Final = "gpt-4o-mini"
|
DEFAULT_MODEL: Final = "gpt-4o-mini"
|
||||||
DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
|
DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
|
||||||
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat"
|
# deepseek-chat/deepseek-reasoner are discontinued 2026-07-24; V4 models
|
||||||
DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash"
|
# select thinking mode via a request parameter instead of the model name.
|
||||||
|
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-v4-flash"
|
||||||
|
# gemini-2.0-flash was shut down 2026-06-01; 2.5-flash follows 2026-10-16.
|
||||||
|
DEFAULT_GEMINI_MODEL: Final = "gemini-3.5-flash"
|
||||||
DEFAULT_TEMPERATURE: Final = 0.1
|
DEFAULT_TEMPERATURE: Final = 0.1
|
||||||
DEFAULT_MAX_TOKENS: Final = 1000
|
DEFAULT_MAX_TOKENS: Final = 1000
|
||||||
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
DEFAULT_REQUEST_INTERVAL: Final = 1.0
|
||||||
@@ -69,6 +73,7 @@ DEFAULT_NAME_PREFIX = "ha_text_ai"
|
|||||||
DEFAULT_INSTANCE_NAME: Final = "my_assistant"
|
DEFAULT_INSTANCE_NAME: Final = "my_assistant"
|
||||||
DEFAULT_CONTEXT_MESSAGES: Final = 5
|
DEFAULT_CONTEXT_MESSAGES: Final = 5
|
||||||
DEFAULT_ALLOW_LOCAL_NETWORK: Final = False
|
DEFAULT_ALLOW_LOCAL_NETWORK: Final = False
|
||||||
|
DEFAULT_DISABLE_THINKING: Final = False
|
||||||
MIN_CONTEXT_MESSAGES: Final = 1
|
MIN_CONTEXT_MESSAGES: Final = 1
|
||||||
MAX_CONTEXT_MESSAGES: Final = 20
|
MAX_CONTEXT_MESSAGES: Final = 20
|
||||||
MIN_HISTORY_SIZE: Final = 1
|
MIN_HISTORY_SIZE: Final = 1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
The HA Text AI coordinator.
|
The HA Text AI coordinator.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -12,7 +12,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -23,6 +23,7 @@ from homeassistant.util import dt as dt_util
|
|||||||
from .const import (
|
from .const import (
|
||||||
DEFAULT_API_TIMEOUT,
|
DEFAULT_API_TIMEOUT,
|
||||||
DEFAULT_CONTEXT_MESSAGES,
|
DEFAULT_CONTEXT_MESSAGES,
|
||||||
|
DEFAULT_DISABLE_THINKING,
|
||||||
DEFAULT_MAX_HISTORY,
|
DEFAULT_MAX_HISTORY,
|
||||||
DEFAULT_MAX_TOKENS,
|
DEFAULT_MAX_TOKENS,
|
||||||
DEFAULT_TEMPERATURE,
|
DEFAULT_TEMPERATURE,
|
||||||
@@ -39,7 +40,6 @@ from .utils import normalize_name
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HATextAICoordinator(DataUpdateCoordinator):
|
class HATextAICoordinator(DataUpdateCoordinator):
|
||||||
"""Home Assistant Text AI Conversation Coordinator."""
|
"""Home Assistant Text AI Conversation Coordinator."""
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
max_history_size: int = DEFAULT_MAX_HISTORY,
|
max_history_size: int = DEFAULT_MAX_HISTORY,
|
||||||
context_messages: int = DEFAULT_CONTEXT_MESSAGES,
|
context_messages: int = DEFAULT_CONTEXT_MESSAGES,
|
||||||
api_timeout: int = DEFAULT_API_TIMEOUT,
|
api_timeout: int = DEFAULT_API_TIMEOUT,
|
||||||
|
disable_thinking: bool = DEFAULT_DISABLE_THINKING,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize coordinator."""
|
"""Initialize coordinator."""
|
||||||
self.instance_name = instance_name
|
self.instance_name = instance_name
|
||||||
@@ -89,6 +90,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
self.temperature = temperature
|
self.temperature = temperature
|
||||||
self.max_tokens = max_tokens
|
self.max_tokens = max_tokens
|
||||||
self.api_timeout = api_timeout
|
self.api_timeout = api_timeout
|
||||||
|
self.disable_thinking = disable_thinking
|
||||||
|
|
||||||
# Concurrency control
|
# Concurrency control
|
||||||
self._request_lock = asyncio.Lock()
|
self._request_lock = asyncio.Lock()
|
||||||
@@ -98,9 +100,9 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
self._is_rate_limited = False
|
self._is_rate_limited = False
|
||||||
self._is_maintenance = False
|
self._is_maintenance = False
|
||||||
self.endpoint_status = "ready"
|
self.endpoint_status = "ready"
|
||||||
self._system_prompt: Optional[str] = None
|
self._system_prompt: str | None = None
|
||||||
|
|
||||||
self._last_response: Dict[str, Any] = {
|
self._last_response: dict[str, Any] = {
|
||||||
"timestamp": dt_util.utcnow().isoformat(),
|
"timestamp": dt_util.utcnow().isoformat(),
|
||||||
"question": "",
|
"question": "",
|
||||||
"response": "",
|
"response": "",
|
||||||
@@ -129,7 +131,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
# Convenience accessors for backward compatibility
|
# Convenience accessors for backward compatibility
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def _conversation_history(self) -> List[Dict[str, Any]]:
|
def _conversation_history(self) -> list[dict[str, Any]]:
|
||||||
return self._history.conversation_history
|
return self._history.conversation_history
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -152,12 +154,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
# Last response
|
# Last response
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def last_response(self) -> Dict[str, Any]:
|
def last_response(self) -> dict[str, Any]:
|
||||||
"""Get the last response."""
|
"""Get the last response."""
|
||||||
return self._last_response
|
return self._last_response
|
||||||
|
|
||||||
@last_response.setter
|
@last_response.setter
|
||||||
def last_response(self, value: Dict[str, Any]) -> None:
|
def last_response(self, value: dict[str, Any]) -> None:
|
||||||
self._last_response = value
|
self._last_response = value
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -170,7 +172,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.error("Error updating HA state for %s: %s", self.instance_name, err)
|
_LOGGER.error("Error updating HA state for %s: %s", self.instance_name, err)
|
||||||
|
|
||||||
async def _async_update_data(self) -> Dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Update coordinator data."""
|
"""Update coordinator data."""
|
||||||
try:
|
try:
|
||||||
current_state = self._get_current_state()
|
current_state = self._get_current_state()
|
||||||
@@ -206,13 +208,14 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
async def async_ask_question(
|
async def async_ask_question(
|
||||||
self,
|
self,
|
||||||
question: str,
|
question: str,
|
||||||
model: Optional[str] = None,
|
model: str | None = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: float | None = None,
|
||||||
max_tokens: Optional[int] = None,
|
max_tokens: int | None = None,
|
||||||
system_prompt: Optional[str] = None,
|
system_prompt: str | None = None,
|
||||||
context_messages: Optional[int] = None,
|
context_messages: int | None = None,
|
||||||
structured_output: bool = False,
|
structured_output: bool = False,
|
||||||
json_schema: Optional[str] = None,
|
json_schema: str | None = None,
|
||||||
|
disable_thinking: bool | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Process question with context management."""
|
"""Process question with context management."""
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
@@ -228,6 +231,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
temp_temperature = temperature if temperature is not None else self.temperature
|
temp_temperature = temperature if temperature is not None else self.temperature
|
||||||
temp_max_tokens = max_tokens if max_tokens is not None else self.max_tokens
|
temp_max_tokens = max_tokens if max_tokens is not None else self.max_tokens
|
||||||
temp_system_prompt = system_prompt if system_prompt is not None else self._system_prompt
|
temp_system_prompt = system_prompt if system_prompt is not None else self._system_prompt
|
||||||
|
temp_disable_thinking = disable_thinking if disable_thinking is not None else self.disable_thinking
|
||||||
|
|
||||||
start_time = dt_util.utcnow()
|
start_time = dt_util.utcnow()
|
||||||
|
|
||||||
@@ -250,6 +254,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
max_tokens=temp_max_tokens,
|
max_tokens=temp_max_tokens,
|
||||||
structured_output=structured_output,
|
structured_output=structured_output,
|
||||||
json_schema=json_schema,
|
json_schema=json_schema,
|
||||||
|
disable_thinking=temp_disable_thinking,
|
||||||
)
|
)
|
||||||
|
|
||||||
latency = (dt_util.utcnow() - start_time).total_seconds()
|
latency = (dt_util.utcnow() - start_time).total_seconds()
|
||||||
@@ -263,7 +268,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
if error_details.get("is_connection_error"):
|
if error_details.get("is_connection_error"):
|
||||||
self.endpoint_status = "unavailable"
|
self.endpoint_status = "unavailable"
|
||||||
self.last_response = error_details
|
self.last_response = error_details
|
||||||
raise HomeAssistantError(f"Failed to process question: {err}")
|
raise HomeAssistantError(f"Failed to process question: {err}") from err
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._is_processing = False
|
self._is_processing = False
|
||||||
@@ -273,11 +278,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
self,
|
self,
|
||||||
question: str,
|
question: str,
|
||||||
model: str,
|
model: str,
|
||||||
messages: List[Dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
temperature: float,
|
temperature: float,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
structured_output: bool = False,
|
structured_output: bool = False,
|
||||||
json_schema: Optional[str] = None,
|
json_schema: str | None = None,
|
||||||
|
disable_thinking: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send request to AI provider and return structured response.
|
"""Send request to AI provider and return structured response.
|
||||||
|
|
||||||
@@ -292,6 +298,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
structured_output=structured_output,
|
structured_output=structured_output,
|
||||||
json_schema=json_schema,
|
json_schema=json_schema,
|
||||||
|
disable_thinking=disable_thinking,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reset error state on success
|
# Reset error state on success
|
||||||
@@ -340,12 +347,12 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
|
|
||||||
async def async_get_history(
|
async def async_get_history(
|
||||||
self,
|
self,
|
||||||
limit: Optional[int] = None,
|
limit: int | None = None,
|
||||||
filter_model: Optional[str] = None,
|
filter_model: str | None = None,
|
||||||
start_date: Optional[str] = None,
|
start_date: str | None = None,
|
||||||
include_metadata: bool = False,
|
include_metadata: bool = False,
|
||||||
sort_order: str = "newest",
|
sort_order: str = "newest",
|
||||||
) -> List[Dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get conversation history with optional filtering."""
|
"""Get conversation history with optional filtering."""
|
||||||
return await self._history.async_get_history(
|
return await self._history.async_get_history(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -375,7 +382,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
return STATE_ERROR
|
return STATE_ERROR
|
||||||
return STATE_READY
|
return STATE_READY
|
||||||
|
|
||||||
def _get_safe_initial_state(self) -> Dict[str, Any]:
|
def _get_safe_initial_state(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"state": STATE_ERROR,
|
"state": STATE_ERROR,
|
||||||
"metrics": {},
|
"metrics": {},
|
||||||
@@ -395,7 +402,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
"normalized_name": self.normalized_name,
|
"normalized_name": self.normalized_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_sanitized_last_response(self) -> Dict[str, Any]:
|
def _get_sanitized_last_response(self) -> dict[str, Any]:
|
||||||
"""Get sanitized version of last response with truncation."""
|
"""Get sanitized version of last response with truncation."""
|
||||||
response = self.last_response.copy()
|
response = self.last_response.copy()
|
||||||
|
|
||||||
@@ -414,7 +421,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
def _calculate_uptime(self) -> float:
|
def _calculate_uptime(self) -> float:
|
||||||
return (dt_util.utcnow() - self._start_time).total_seconds()
|
return (dt_util.utcnow() - self._start_time).total_seconds()
|
||||||
|
|
||||||
def _get_truncated_system_prompt(self) -> Optional[str]:
|
def _get_truncated_system_prompt(self) -> str | None:
|
||||||
if not self._system_prompt:
|
if not self._system_prompt:
|
||||||
return None
|
return None
|
||||||
if len(self._system_prompt) <= 4096:
|
if len(self._system_prompt) <= 4096:
|
||||||
@@ -422,7 +429,7 @@ class HATextAICoordinator(DataUpdateCoordinator):
|
|||||||
return self._system_prompt[:4096] + TRUNCATION_INDICATOR
|
return self._system_prompt[:4096] + TRUNCATION_INDICATOR
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_update_data(data: Dict[str, Any]) -> None:
|
def _validate_update_data(data: dict[str, Any]) -> None:
|
||||||
for key in ("state", "metrics", "last_response"):
|
for key in ("state", "metrics", "last_response"):
|
||||||
if key not in data:
|
if key not in data:
|
||||||
raise ValueError(f"Missing required key: {key}")
|
raise ValueError(f"Missing required key: {key}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
History management for HA Text AI integration.
|
History management for HA Text AI integration.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -14,7 +14,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ MAX_ARCHIVE_FILES = 3
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AsyncFileHandler:
|
class AsyncFileHandler:
|
||||||
"""Async context manager for file operations."""
|
"""Async context manager for file operations."""
|
||||||
|
|
||||||
@@ -49,6 +48,16 @@ class AsyncFileHandler:
|
|||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
await self.file.close()
|
await self.file.close()
|
||||||
|
|
||||||
|
def _assert_not_symlink(path: str) -> None:
|
||||||
|
"""Refuse to operate on a path that resolves to a symlink.
|
||||||
|
|
||||||
|
Why: another component or an attacker with filesystem access could
|
||||||
|
replace our history file with a symlink pointing at arbitrary disk
|
||||||
|
locations. Then os.remove or shutil.move would hit the target
|
||||||
|
instead of our managed file. Check before destructive ops.
|
||||||
|
"""
|
||||||
|
if os.path.islink(path):
|
||||||
|
raise OSError(f"Refusing to operate on symlink: {path}")
|
||||||
|
|
||||||
class HistoryManager:
|
class HistoryManager:
|
||||||
"""Manages conversation history for an instance."""
|
"""Manages conversation history for an instance."""
|
||||||
@@ -72,10 +81,10 @@ class HistoryManager:
|
|||||||
history_dir, f"{normalized_name}_history.json"
|
history_dir, f"{normalized_name}_history.json"
|
||||||
)
|
)
|
||||||
self._max_history_file_size = MAX_HISTORY_FILE_SIZE
|
self._max_history_file_size = MAX_HISTORY_FILE_SIZE
|
||||||
self._conversation_history: List[Dict[str, Any]] = []
|
self._conversation_history: list[dict[str, Any]] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def conversation_history(self) -> List[Dict[str, Any]]:
|
def conversation_history(self) -> list[dict[str, Any]]:
|
||||||
return self._conversation_history
|
return self._conversation_history
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -242,6 +251,9 @@ class HistoryManager:
|
|||||||
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json",
|
f"{self.normalized_name}_history_{dt_util.utcnow().strftime('%Y%m%d_%H%M%S')}.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
_assert_not_symlink, self._history_file
|
||||||
|
)
|
||||||
await self.hass.async_add_executor_job(
|
await self.hass.async_add_executor_job(
|
||||||
shutil.move, self._history_file, archive_file
|
shutil.move, self._history_file, archive_file
|
||||||
)
|
)
|
||||||
@@ -280,6 +292,9 @@ class HistoryManager:
|
|||||||
archives = await self.hass.async_add_executor_job(find_archives)
|
archives = await self.hass.async_add_executor_job(find_archives)
|
||||||
if len(archives) > MAX_ARCHIVE_FILES:
|
if len(archives) > MAX_ARCHIVE_FILES:
|
||||||
for old_file in archives[:-MAX_ARCHIVE_FILES]:
|
for old_file in archives[:-MAX_ARCHIVE_FILES]:
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
_assert_not_symlink, old_file
|
||||||
|
)
|
||||||
await self.hass.async_add_executor_job(os.remove, old_file)
|
await self.hass.async_add_executor_job(os.remove, old_file)
|
||||||
_LOGGER.debug("Removed old archive: %s", old_file)
|
_LOGGER.debug("Removed old archive: %s", old_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -359,6 +374,9 @@ class HistoryManager:
|
|||||||
try:
|
try:
|
||||||
self._conversation_history = []
|
self._conversation_history = []
|
||||||
if await self._file_exists(self._history_file):
|
if await self._file_exists(self._history_file):
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
_assert_not_symlink, self._history_file
|
||||||
|
)
|
||||||
await self.hass.async_add_executor_job(os.remove, self._history_file)
|
await self.hass.async_add_executor_job(os.remove, self._history_file)
|
||||||
_LOGGER.info("History for %s cleared", self.instance_name)
|
_LOGGER.info("History for %s cleared", self.instance_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -367,13 +385,13 @@ class HistoryManager:
|
|||||||
|
|
||||||
async def async_get_history(
|
async def async_get_history(
|
||||||
self,
|
self,
|
||||||
limit: Optional[int] = None,
|
limit: int | None = None,
|
||||||
filter_model: Optional[str] = None,
|
filter_model: str | None = None,
|
||||||
start_date: Optional[str] = None,
|
start_date: str | None = None,
|
||||||
include_metadata: bool = False,
|
include_metadata: bool = False,
|
||||||
sort_order: str = "newest",
|
sort_order: str = "newest",
|
||||||
default_model: str = "",
|
default_model: str = "",
|
||||||
) -> List[Dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get conversation history with optional filtering and sorting."""
|
"""Get conversation history with optional filtering and sorting."""
|
||||||
try:
|
try:
|
||||||
history = self._conversation_history.copy()
|
history = self._conversation_history.copy()
|
||||||
@@ -404,8 +422,11 @@ class HistoryManager:
|
|||||||
else:
|
else:
|
||||||
history.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
history.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||||
|
|
||||||
|
# Clamp limit to ABSOLUTE_MAX_HISTORY_SIZE to prevent pathological
|
||||||
|
# caller requests from producing multi-MB service payloads.
|
||||||
if limit and limit > 0:
|
if limit and limit > 0:
|
||||||
history = history[:limit]
|
effective_limit = min(int(limit), ABSOLUTE_MAX_HISTORY_SIZE)
|
||||||
|
history = history[:effective_limit]
|
||||||
|
|
||||||
if include_metadata:
|
if include_metadata:
|
||||||
enriched = []
|
enriched = []
|
||||||
@@ -426,7 +447,7 @@ class HistoryManager:
|
|||||||
_LOGGER.error("Error getting history: %s", e)
|
_LOGGER.error("Error getting history: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_limited_history(self, max_display: int = 5) -> Dict[str, Any]:
|
def get_limited_history(self, max_display: int = 5) -> dict[str, Any]:
|
||||||
"""Get limited conversation history for sensor attributes.
|
"""Get limited conversation history for sensor attributes.
|
||||||
|
|
||||||
Returns last `max_display` entries with truncated text for HA state.
|
Returns last `max_display` entries with truncated text for HA state.
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
"issue_tracker": "https://github.com/smkrv/ha-text-ai/issues",
|
||||||
"loggers": ["custom_components.ha_text_ai"],
|
"loggers": ["custom_components.ha_text_ai"],
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aiofiles>=23.0.0",
|
"aiofiles>=23.0.0,<25.0.0",
|
||||||
"google-genai>=1.16.0"
|
"google-genai>=1.16.0,<2.0.0"
|
||||||
],
|
],
|
||||||
"single_config_entry": false,
|
"single_config_entry": false,
|
||||||
"version": "2.4.1"
|
"version": "2.5.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Metrics management for HA Text AI integration.
|
Metrics management for HA Text AI integration.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@@ -21,7 +21,7 @@ from homeassistant.util import dt as dt_util
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_METRICS: Dict[str, Any] = {
|
DEFAULT_METRICS: dict[str, Any] = {
|
||||||
"total_tokens": 0,
|
"total_tokens": 0,
|
||||||
"prompt_tokens": 0,
|
"prompt_tokens": 0,
|
||||||
"completion_tokens": 0,
|
"completion_tokens": 0,
|
||||||
@@ -33,7 +33,6 @@ DEFAULT_METRICS: Dict[str, Any] = {
|
|||||||
"min_latency": 0,
|
"min_latency": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MetricsManager:
|
class MetricsManager:
|
||||||
"""Manages performance metrics for an instance."""
|
"""Manages performance metrics for an instance."""
|
||||||
|
|
||||||
@@ -46,10 +45,10 @@ class MetricsManager:
|
|||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.instance_name = instance_name
|
self.instance_name = instance_name
|
||||||
self._metrics_file = metrics_file
|
self._metrics_file = metrics_file
|
||||||
self._performance_metrics: Dict[str, Any] = DEFAULT_METRICS.copy()
|
self._performance_metrics: dict[str, Any] = DEFAULT_METRICS.copy()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def metrics(self) -> Dict[str, Any]:
|
def metrics(self) -> dict[str, Any]:
|
||||||
return self._performance_metrics
|
return self._performance_metrics
|
||||||
|
|
||||||
async def async_initialize(self) -> None:
|
async def async_initialize(self) -> None:
|
||||||
@@ -57,7 +56,7 @@ class MetricsManager:
|
|||||||
loaded = await self._load_metrics()
|
loaded = await self._load_metrics()
|
||||||
self._performance_metrics = loaded or DEFAULT_METRICS.copy()
|
self._performance_metrics = loaded or DEFAULT_METRICS.copy()
|
||||||
|
|
||||||
async def _load_metrics(self) -> Dict[str, Any] | None:
|
async def _load_metrics(self) -> dict[str, Any] | None:
|
||||||
try:
|
try:
|
||||||
exists = await self.hass.async_add_executor_job(
|
exists = await self.hass.async_add_executor_job(
|
||||||
os.path.exists, self._metrics_file
|
os.path.exists, self._metrics_file
|
||||||
@@ -108,7 +107,7 @@ class MetricsManager:
|
|||||||
|
|
||||||
await self._save_metrics()
|
await self._save_metrics()
|
||||||
|
|
||||||
async def get_current_metrics(self) -> Dict[str, Any]:
|
async def get_current_metrics(self) -> dict[str, Any]:
|
||||||
"""Get current performance metrics."""
|
"""Get current performance metrics."""
|
||||||
return self._performance_metrics.copy()
|
return self._performance_metrics.copy()
|
||||||
|
|
||||||
@@ -116,24 +115,37 @@ class MetricsManager:
|
|||||||
self,
|
self,
|
||||||
error: Exception,
|
error: Exception,
|
||||||
model: str,
|
model: str,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Record an error in metrics and return error details."""
|
"""Record an error in metrics and return error details."""
|
||||||
self._performance_metrics["total_errors"] += 1
|
self._performance_metrics["total_errors"] += 1
|
||||||
self._performance_metrics["failed_requests"] += 1
|
self._performance_metrics["failed_requests"] += 1
|
||||||
await self._save_metrics()
|
await self._save_metrics()
|
||||||
|
|
||||||
error_msg = str(error)
|
error_msg = str(error)
|
||||||
# Strip URLs, API keys, tokens, and query parameters from error messages
|
# Strip URLs, API keys, tokens, and query parameters from error messages.
|
||||||
|
# Patterns use word boundaries and explicit length bounds so that
|
||||||
|
# overly greedy matches don't accidentally swallow adjacent text.
|
||||||
error_msg = re.sub(r'https?://\S+', '[URL]', error_msg)
|
error_msg = re.sub(r'https?://\S+', '[URL]', error_msg)
|
||||||
error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg)
|
error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg)
|
||||||
error_msg = re.sub(r'AIza[A-Za-z0-9_-]+', '***', error_msg)
|
# Google API key: fixed prefix + 30+ url-safe chars, bounded by non-key char.
|
||||||
error_msg = re.sub(r'Bearer\s+\S+', 'Bearer ***', error_msg)
|
error_msg = re.sub(
|
||||||
error_msg = re.sub(r'sk-[A-Za-z0-9_-]{20,}', '***', error_msg)
|
r'AIza[A-Za-z0-9_\-]{30,}(?=[^A-Za-z0-9_\-]|$)', '***', error_msg
|
||||||
error_msg = re.sub(r'x-api-key:\s*\S+', 'x-api-key: ***', error_msg, flags=re.IGNORECASE)
|
)
|
||||||
|
# Anthropic / OpenAI / DeepSeek format: "sk-..." (anchors on word boundary).
|
||||||
|
error_msg = re.sub(r'\bsk-[A-Za-z0-9_\-]{20,}\b', '***', error_msg)
|
||||||
|
# Bearer tokens: header-style and JSON-embedded ("Bearer xxx").
|
||||||
|
error_msg = re.sub(r'[Bb]earer\s+[A-Za-z0-9_\-\.=]+', 'Bearer ***', error_msg)
|
||||||
|
# x-api-key header in any case, both raw and JSON-serialized forms.
|
||||||
|
error_msg = re.sub(
|
||||||
|
r'"?x-api-key"?\s*[:=]\s*"?[A-Za-z0-9_\-\.]+"?',
|
||||||
|
'x-api-key: ***',
|
||||||
|
error_msg,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
if len(error_msg) > 256:
|
if len(error_msg) > 256:
|
||||||
error_msg = error_msg[:256] + "..."
|
error_msg = error_msg[:256] + "..."
|
||||||
|
|
||||||
error_details: Dict[str, Any] = {
|
error_details: dict[str, Any] = {
|
||||||
"timestamp": dt_util.utcnow().isoformat(),
|
"timestamp": dt_util.utcnow().isoformat(),
|
||||||
"model": model,
|
"model": model,
|
||||||
"instance": self.instance_name,
|
"instance": self.instance_name,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Provider registry for HA Text AI integration.
|
|||||||
Centralizes provider-specific configuration to avoid dispatch duplication
|
Centralizes provider-specific configuration to avoid dispatch duplication
|
||||||
across __init__.py, config_flow.py, and api_client.py.
|
across __init__.py, config_flow.py, and api_client.py.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -62,7 +62,6 @@ PROVIDER_REGISTRY: dict[str, dict[str, Any]] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_provider_config(provider: str) -> dict[str, Any]:
|
def get_provider_config(provider: str) -> dict[str, Any]:
|
||||||
"""Get full provider configuration.
|
"""Get full provider configuration.
|
||||||
|
|
||||||
@@ -73,17 +72,14 @@ def get_provider_config(provider: str) -> dict[str, Any]:
|
|||||||
raise ValueError(f"Unknown API provider: {provider}")
|
raise ValueError(f"Unknown API provider: {provider}")
|
||||||
return PROVIDER_REGISTRY[provider]
|
return PROVIDER_REGISTRY[provider]
|
||||||
|
|
||||||
|
|
||||||
def get_default_endpoint(provider: str) -> str:
|
def get_default_endpoint(provider: str) -> str:
|
||||||
"""Get default API endpoint for a provider."""
|
"""Get default API endpoint for a provider."""
|
||||||
return get_provider_config(provider)["default_endpoint"]
|
return get_provider_config(provider)["default_endpoint"]
|
||||||
|
|
||||||
|
|
||||||
def get_default_model(provider: str) -> str:
|
def get_default_model(provider: str) -> str:
|
||||||
"""Get default model for a provider."""
|
"""Get default model for a provider."""
|
||||||
return get_provider_config(provider)["default_model"]
|
return get_provider_config(provider)["default_model"]
|
||||||
|
|
||||||
|
|
||||||
def build_auth_headers(provider: str, api_key: str) -> dict[str, str]:
|
def build_auth_headers(provider: str, api_key: str) -> dict[str, str]:
|
||||||
"""Build authentication headers for a provider."""
|
"""Build authentication headers for a provider."""
|
||||||
config = get_provider_config(provider)
|
config = get_provider_config(provider)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Sensor platform for HA Text AI.
|
Sensor platform for HA Text AI.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
@@ -10,14 +10,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
@@ -79,7 +79,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
_ATTR_TEXT_LIMIT = 2048
|
_ATTR_TEXT_LIMIT = 2048
|
||||||
_ATTR_PROMPT_LIMIT = 512
|
_ATTR_PROMPT_LIMIT = 512
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
@@ -161,6 +160,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
manufacturer="Community",
|
manufacturer="Community",
|
||||||
model=f"{model} ({api_provider} provider)",
|
model=f"{model} ({api_provider} provider)",
|
||||||
sw_version=VERSION,
|
sw_version=VERSION,
|
||||||
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
@@ -184,7 +184,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
return None
|
return None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _sanitize_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
|
def _sanitize_attributes(self, attributes: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Sanitize all attributes for JSON serialization."""
|
"""Sanitize all attributes for JSON serialization."""
|
||||||
sanitized = {
|
sanitized = {
|
||||||
key: self._sanitize_value(value)
|
key: self._sanitize_value(value)
|
||||||
@@ -230,7 +230,7 @@ class HATextAISensor(CoordinatorEntity, SensorEntity):
|
|||||||
return ENTITY_ICON
|
return ENTITY_ICON
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> Dict[str, Any]:
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
"""Return entity specific state attributes."""
|
"""Return entity specific state attributes."""
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ ask_question:
|
|||||||
text:
|
text:
|
||||||
multiline: true
|
multiline: true
|
||||||
|
|
||||||
|
disable_thinking:
|
||||||
|
name: Disable Thinking
|
||||||
|
description: >-
|
||||||
|
Disable model thinking/reasoning for this request.
|
||||||
|
Overrides the integration-level setting when provided.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
boolean: {}
|
||||||
|
|
||||||
clear_history:
|
clear_history:
|
||||||
name: Clear History
|
name: Clear History
|
||||||
description: >-
|
description: >-
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "API request timeout in seconds (5-600)",
|
"api_timeout": "API request timeout in seconds (5-600)",
|
||||||
"context_messages": "Number of context messages to retain (1-20)",
|
"context_messages": "Number of context messages to retain (1-20)",
|
||||||
"max_history_size": "Maximum conversation history size (1-100)",
|
"max_history_size": "Maximum conversation history size (1-100)",
|
||||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||||
|
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "API request timeout in seconds (5-600)",
|
"api_timeout": "API request timeout in seconds (5-600)",
|
||||||
"context_messages": "Number of context messages to retain (1-20)",
|
"context_messages": "Number of context messages to retain (1-20)",
|
||||||
"max_history_size": "Maximum conversation history size (1-100)",
|
"max_history_size": "Maximum conversation history size (1-100)",
|
||||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
"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)",
|
"api_timeout": "API request timeout in seconds (5-600)",
|
||||||
"context_messages": "Number of previous messages to include in context (1-20)",
|
"context_messages": "Number of previous messages to include in context (1-20)",
|
||||||
"max_history_size": "Maximum conversation history size (1-100)",
|
"max_history_size": "Maximum conversation history size (1-100)",
|
||||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
"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": {
|
"json_schema": {
|
||||||
"name": "JSON Schema",
|
"name": "JSON Schema",
|
||||||
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
|
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Disable Thinking",
|
||||||
|
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
||||||
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
|
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
|
||||||
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
||||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
|
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
|
||||||
|
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
||||||
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
|
"context_messages": "Anzahl der zu behaltenden Kontextnachrichten (1-20)",
|
||||||
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
||||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
|
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)",
|
||||||
|
"disable_thinking": "Thinking/Reasoning-Modus deaktivieren (Qwen /no_think, think-Blöcke entfernen, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"name_exists": "Eine Instanz mit diesem Namen existiert bereits",
|
"name_exists": "Eine Instanz mit diesem Namen existiert bereits",
|
||||||
"invalid_name": "Ungültiger Instanzname",
|
"invalid_name": "Ungültiger Instanzname",
|
||||||
"invalid_auth": "Authentifizierung fehlgeschlagen - überprüfen Sie Ihren API-Schlüssel",
|
"invalid_auth": "Authentifizierung fehlgeschlagen - überprüfen Sie Ihren API-Schlüssel",
|
||||||
|
"api_key_required": "API-Schlüssel ist erforderlich, wenn Anbieter oder Endpunkt geändert wird",
|
||||||
"invalid_api_key": "Ungültiger API-Schlüssel - bitte überprüfen Sie Ihre Anmeldeinformationen",
|
"invalid_api_key": "Ungültiger API-Schlüssel - bitte überprüfen Sie Ihre Anmeldeinformationen",
|
||||||
"cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen",
|
"cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen",
|
||||||
"invalid_model": "Ausgewähltes Modell ist nicht verfügbar",
|
"invalid_model": "Ausgewähltes Modell ist nicht verfügbar",
|
||||||
@@ -57,7 +60,6 @@
|
|||||||
"invalid_instance": "Ungültige Instanz angegeben",
|
"invalid_instance": "Ungültige Instanz angegeben",
|
||||||
"unknown": "Unerwarteter Fehler aufgetreten",
|
"unknown": "Unerwarteter Fehler aufgetreten",
|
||||||
"empty": "Name darf nicht leer sein",
|
"empty": "Name darf nicht leer sein",
|
||||||
"invalid_characters": "Name darf nur Buchstaben, Zahlen, Leerzeichen, Unterstriche und Bindestriche enthalten",
|
|
||||||
"name_too_long": "Name darf höchstens 50 Zeichen lang sein"
|
"name_too_long": "Name darf höchstens 50 Zeichen lang sein"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
"api_timeout": "API-Anfrage Timeout in Sekunden (5-600)",
|
||||||
"context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)",
|
"context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)",
|
||||||
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
"max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)",
|
||||||
"allow_local_network": "Lokale Netzwerkendpunkte erlauben (für selbst gehostete Proxys)"
|
"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": {
|
"json_schema": {
|
||||||
"name": "JSON-Schema",
|
"name": "JSON-Schema",
|
||||||
"description": "JSON-Schema, das die Struktur der erwarteten Antwort definiert. Erforderlich wenn structured_output aktiviert ist."
|
"description": "JSON-Schema, das die Struktur der erwarteten Antwort definiert. Erforderlich wenn structured_output aktiviert ist."
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Thinking deaktivieren",
|
||||||
|
"description": "Thinking/Reasoning-Modus für diese Anfrage deaktivieren. Überschreibt die Integrationseinstellung."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,8 +218,7 @@
|
|||||||
"rate_limited": "Rate limitiert",
|
"rate_limited": "Rate limitiert",
|
||||||
"maintenance": "Wartung",
|
"maintenance": "Wartung",
|
||||||
"initializing": "Initialisierung",
|
"initializing": "Initialisierung",
|
||||||
"retrying": "Wiederholen",
|
"retrying": "Wiederholen"
|
||||||
"queued": "In der Warteschlange"
|
|
||||||
},
|
},
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"question": {
|
"question": {
|
||||||
@@ -304,6 +310,21 @@
|
|||||||
},
|
},
|
||||||
"min_latency": {
|
"min_latency": {
|
||||||
"name": "Minimale Latenz"
|
"name": "Minimale Latenz"
|
||||||
|
},
|
||||||
|
"last_model": {
|
||||||
|
"name": "Zuletzt verwendetes Modell"
|
||||||
|
},
|
||||||
|
"last_timestamp": {
|
||||||
|
"name": "Zeitpunkt der letzten Antwort"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"name": "Instanzname"
|
||||||
|
},
|
||||||
|
"normalized_name": {
|
||||||
|
"name": "Normalisierter Name"
|
||||||
|
},
|
||||||
|
"conversation_history": {
|
||||||
|
"name": "Konversationsverlauf"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "API request timeout in seconds (5-600)",
|
"api_timeout": "API request timeout in seconds (5-600)",
|
||||||
"context_messages": "Number of context messages to retain (1-20)",
|
"context_messages": "Number of context messages to retain (1-20)",
|
||||||
"max_history_size": "Maximum conversation history size (1-100)",
|
"max_history_size": "Maximum conversation history size (1-100)",
|
||||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)",
|
||||||
|
"disable_thinking": "Disable thinking/reasoning mode (Qwen /no_think, strips think blocks, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "API request timeout in seconds (5-600)",
|
"api_timeout": "API request timeout in seconds (5-600)",
|
||||||
"context_messages": "Number of context messages to retain (1-20)",
|
"context_messages": "Number of context messages to retain (1-20)",
|
||||||
"max_history_size": "Maximum conversation history size (1-100)",
|
"max_history_size": "Maximum conversation history size (1-100)",
|
||||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
"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)",
|
"api_timeout": "API request timeout in seconds (5-600)",
|
||||||
"context_messages": "Number of previous messages to include in context (1-20)",
|
"context_messages": "Number of previous messages to include in context (1-20)",
|
||||||
"max_history_size": "Maximum conversation history size (1-100)",
|
"max_history_size": "Maximum conversation history size (1-100)",
|
||||||
"allow_local_network": "Allow local network endpoints (for self-hosted proxies)"
|
"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": {
|
"json_schema": {
|
||||||
"name": "JSON Schema",
|
"name": "JSON Schema",
|
||||||
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
|
"description": "JSON Schema defining the structure of the expected response. Required when structured_output is enabled."
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Disable Thinking",
|
||||||
|
"description": "Disable model thinking/reasoning for this request. Overrides the integration-level setting."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
||||||
"context_messages": "Número de mensajes de contexto a retener (1-20)",
|
"context_messages": "Número de mensajes de contexto a retener (1-20)",
|
||||||
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
||||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
|
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
|
||||||
|
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
||||||
"context_messages": "Número de mensajes de contexto a retener (1-20)",
|
"context_messages": "Número de mensajes de contexto a retener (1-20)",
|
||||||
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
||||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
|
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)",
|
||||||
|
"disable_thinking": "Desactivar modo thinking/reasoning (Qwen /no_think, elimina bloques think, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"name_exists": "Ya existe una instancia con este nombre",
|
"name_exists": "Ya existe una instancia con este nombre",
|
||||||
"invalid_name": "Nombre de instancia no válido",
|
"invalid_name": "Nombre de instancia no válido",
|
||||||
"invalid_auth": "La autenticación falló - verifica tu clave API",
|
"invalid_auth": "La autenticación falló - verifica tu clave API",
|
||||||
|
"api_key_required": "Se requiere la clave API al cambiar de proveedor o endpoint",
|
||||||
"invalid_api_key": "Clave API no válida - verifica tus credenciales",
|
"invalid_api_key": "Clave API no válida - verifica tus credenciales",
|
||||||
"cannot_connect": "Error al conectar con el servicio de API",
|
"cannot_connect": "Error al conectar con el servicio de API",
|
||||||
"invalid_model": "El modelo seleccionado no está disponible",
|
"invalid_model": "El modelo seleccionado no está disponible",
|
||||||
@@ -57,7 +60,6 @@
|
|||||||
"invalid_instance": "Instancia no válida especificada",
|
"invalid_instance": "Instancia no válida especificada",
|
||||||
"unknown": "Ocurrió un error inesperado",
|
"unknown": "Ocurrió un error inesperado",
|
||||||
"empty": "El nombre no puede estar vacío",
|
"empty": "El nombre no puede estar vacío",
|
||||||
"invalid_characters": "El nombre solo puede contener letras, números, espacios, guiones bajos y guiones",
|
|
||||||
"name_too_long": "El nombre debe tener 50 caracteres o menos"
|
"name_too_long": "El nombre debe tener 50 caracteres o menos"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
"api_timeout": "Tiempo de espera de solicitud API en segundos (5-600)",
|
||||||
"context_messages": "Número de mensajes anteriores a incluir en el contexto (1-20)",
|
"context_messages": "Número de mensajes anteriores a incluir en el contexto (1-20)",
|
||||||
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
"max_history_size": "Tamaño máximo del historial de conversación (1-100)",
|
||||||
"allow_local_network": "Permitir endpoints de red local (para proxies autoalojados)"
|
"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": {
|
"json_schema": {
|
||||||
"name": "Esquema JSON",
|
"name": "Esquema JSON",
|
||||||
"description": "Esquema JSON que define la estructura de la respuesta esperada. Requerido cuando structured_output está habilitado."
|
"description": "Esquema JSON que define la estructura de la respuesta esperada. Requerido cuando structured_output está habilitado."
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Desactivar Thinking",
|
||||||
|
"description": "Desactivar el modo thinking/reasoning para esta solicitud. Anula la configuración de la integración."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,8 +218,7 @@
|
|||||||
"rate_limited": "Limitado por tasa",
|
"rate_limited": "Limitado por tasa",
|
||||||
"maintenance": "Mantenimiento",
|
"maintenance": "Mantenimiento",
|
||||||
"initializing": "Inicializando",
|
"initializing": "Inicializando",
|
||||||
"retrying": "Reintentando",
|
"retrying": "Reintentando"
|
||||||
"queued": "En cola"
|
|
||||||
},
|
},
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"question": {
|
"question": {
|
||||||
@@ -304,6 +310,21 @@
|
|||||||
},
|
},
|
||||||
"min_latency": {
|
"min_latency": {
|
||||||
"name": "Latencia Mínima"
|
"name": "Latencia Mínima"
|
||||||
|
},
|
||||||
|
"last_model": {
|
||||||
|
"name": "Último modelo utilizado"
|
||||||
|
},
|
||||||
|
"last_timestamp": {
|
||||||
|
"name": "Hora de la última respuesta"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"name": "Nombre de instancia"
|
||||||
|
},
|
||||||
|
"normalized_name": {
|
||||||
|
"name": "Nombre normalizado"
|
||||||
|
},
|
||||||
|
"conversation_history": {
|
||||||
|
"name": "Historial de conversación"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
||||||
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
|
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
|
||||||
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
||||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
|
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
|
||||||
|
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
||||||
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
|
"context_messages": "रखने के लिए संदर्भ संदेशों की संख्या (1-20)",
|
||||||
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
||||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
|
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
|
||||||
|
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"name_exists": "इस नाम के साथ एक उदाहरण पहले से मौजूद है",
|
"name_exists": "इस नाम के साथ एक उदाहरण पहले से मौजूद है",
|
||||||
"invalid_name": "अमान्य उदाहरण नाम",
|
"invalid_name": "अमान्य उदाहरण नाम",
|
||||||
"invalid_auth": "प्रमाणीकरण विफल - अपनी एपीआई कुंजी की जांच करें",
|
"invalid_auth": "प्रमाणीकरण विफल - अपनी एपीआई कुंजी की जांच करें",
|
||||||
|
"api_key_required": "प्रदाता या endpoint बदलते समय API कुंजी आवश्यक है",
|
||||||
"invalid_api_key": "अमान्य एपीआई कुंजी - कृपया अपनी क्रेडेंशियल्स की पुष्टि करें",
|
"invalid_api_key": "अमान्य एपीआई कुंजी - कृपया अपनी क्रेडेंशियल्स की पुष्टि करें",
|
||||||
"cannot_connect": "एपीआई सेवा से कनेक्ट करने में विफल",
|
"cannot_connect": "एपीआई सेवा से कनेक्ट करने में विफल",
|
||||||
"invalid_model": "चुना हुआ मॉडल उपलब्ध नहीं है",
|
"invalid_model": "चुना हुआ मॉडल उपलब्ध नहीं है",
|
||||||
@@ -57,7 +60,6 @@
|
|||||||
"invalid_instance": "अमान्य उदाहरण निर्दिष्ट किया गया",
|
"invalid_instance": "अमान्य उदाहरण निर्दिष्ट किया गया",
|
||||||
"unknown": "अप्रत्याशित त्रुटि हुई",
|
"unknown": "अप्रत्याशित त्रुटि हुई",
|
||||||
"empty": "नाम खाली नहीं हो सकता",
|
"empty": "नाम खाली नहीं हो सकता",
|
||||||
"invalid_characters": "नाम में केवल अक्षर, अंक, रिक्त स्थान, अंडरस्कोर और हाइफन हो सकते हैं",
|
|
||||||
"name_too_long": "नाम 50 अक्षरों या उससे कम होना चाहिए"
|
"name_too_long": "नाम 50 अक्षरों या उससे कम होना चाहिए"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
"api_timeout": "एपीआई अनुरोध टाइमआउट सेकंड में (5-600)",
|
||||||
"context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)",
|
"context_messages": "संदर्भ में शामिल करने के लिए पिछले संदेशों की संख्या (1-20)",
|
||||||
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
"max_history_size": "अधिकतम बातचीत इतिहास आकार (1-100)",
|
||||||
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)"
|
"allow_local_network": "स्थानीय नेटवर्क एंडपॉइंट की अनुमति दें (सेल्फ-होस्टेड प्रॉक्सी के लिए)",
|
||||||
|
"disable_thinking": "thinking/reasoning मोड बंद करें (Qwen /no_think, think ब्लॉक हटाता है, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,10 @@
|
|||||||
"json_schema": {
|
"json_schema": {
|
||||||
"name": "JSON स्कीमा",
|
"name": "JSON स्कीमा",
|
||||||
"description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।"
|
"description": "अपेक्षित प्रतिक्रिया की संरचना को परिभाषित करने वाला JSON स्कीमा। structured_output सक्षम होने पर आवश्यक।"
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Thinking बंद करें",
|
||||||
|
"description": "इस अनुरोध के लिए thinking/reasoning मोड बंद करें। एकीकरण सेटिंग को ओवरराइड करता है।"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,8 +218,7 @@
|
|||||||
"rate_limited": "रेट सीमित",
|
"rate_limited": "रेट सीमित",
|
||||||
"maintenance": "रखरखाव",
|
"maintenance": "रखरखाव",
|
||||||
"initializing": "प्रारंभिककरण",
|
"initializing": "प्रारंभिककरण",
|
||||||
"retrying": "पुनः प्रयास कर रहा है",
|
"retrying": "पुनः प्रयास कर रहा है"
|
||||||
"queued": "क्यू में"
|
|
||||||
},
|
},
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"question": {
|
"question": {
|
||||||
@@ -304,9 +310,24 @@
|
|||||||
},
|
},
|
||||||
"min_latency": {
|
"min_latency": {
|
||||||
"name": "न्यूनतम विलंबता"
|
"name": "न्यूनतम विलंबता"
|
||||||
|
},
|
||||||
|
"last_model": {
|
||||||
|
"name": "अंतिम उपयोग किया गया मॉडल"
|
||||||
|
},
|
||||||
|
"last_timestamp": {
|
||||||
|
"name": "अंतिम प्रतिक्रिया समय"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"name": "इंस्टेंस नाम"
|
||||||
|
},
|
||||||
|
"normalized_name": {
|
||||||
|
"name": "सामान्यीकृत नाम"
|
||||||
|
},
|
||||||
|
"conversation_history": {
|
||||||
|
"name": "वार्तालाप इतिहास"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
||||||
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
|
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
|
||||||
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
||||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
|
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
|
||||||
|
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
||||||
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
|
"context_messages": "Numero di messaggi di contesto da mantenere (1-20)",
|
||||||
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
||||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
|
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)",
|
||||||
|
"disable_thinking": "Disabilita la modalità thinking/reasoning (Qwen /no_think, rimuove i blocchi think, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"name_exists": "Esiste già un'istanza con questo nome",
|
"name_exists": "Esiste già un'istanza con questo nome",
|
||||||
"invalid_name": "Nome dell'istanza non valido",
|
"invalid_name": "Nome dell'istanza non valido",
|
||||||
"invalid_auth": "Autenticazione fallita - controlla la tua chiave API",
|
"invalid_auth": "Autenticazione fallita - controlla la tua chiave API",
|
||||||
|
"api_key_required": "La chiave API è obbligatoria quando si cambia provider o endpoint",
|
||||||
"invalid_api_key": "Chiave API non valida - verifica le tue credenziali",
|
"invalid_api_key": "Chiave API non valida - verifica le tue credenziali",
|
||||||
"cannot_connect": "Impossibile connettersi al servizio API",
|
"cannot_connect": "Impossibile connettersi al servizio API",
|
||||||
"invalid_model": "Il modello selezionato non è disponibile",
|
"invalid_model": "Il modello selezionato non è disponibile",
|
||||||
@@ -57,7 +60,6 @@
|
|||||||
"invalid_instance": "Istanze specificata non valida",
|
"invalid_instance": "Istanze specificata non valida",
|
||||||
"unknown": "Si è verificato un errore imprevisto",
|
"unknown": "Si è verificato un errore imprevisto",
|
||||||
"empty": "Il nome non può essere vuoto",
|
"empty": "Il nome non può essere vuoto",
|
||||||
"invalid_characters": "Il nome può contenere solo lettere, numeri, spazi, trattini bassi e trattini",
|
|
||||||
"name_too_long": "Il nome deve essere lungo 50 caratteri o meno"
|
"name_too_long": "Il nome deve essere lungo 50 caratteri o meno"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
"api_timeout": "Timeout della richiesta API in secondi (5-600)",
|
||||||
"context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)",
|
"context_messages": "Numero di messaggi precedenti da includere nel contesto (1-20)",
|
||||||
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
"max_history_size": "Dimensione massima della cronologia delle conversazioni (1-100)",
|
||||||
"allow_local_network": "Consenti endpoint di rete locale (per proxy self-hosted)"
|
"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": {
|
"json_schema": {
|
||||||
"name": "Schema JSON",
|
"name": "Schema JSON",
|
||||||
"description": "Schema JSON che definisce la struttura della risposta attesa. Richiesto quando structured_output è abilitato."
|
"description": "Schema JSON che definisce la struttura della risposta attesa. Richiesto quando structured_output è abilitato."
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Disabilita Thinking",
|
||||||
|
"description": "Disabilita la modalità thinking/reasoning per questa richiesta. Sovrascrive l'impostazione dell'integrazione."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,8 +218,7 @@
|
|||||||
"rate_limited": "Limite di frequenza",
|
"rate_limited": "Limite di frequenza",
|
||||||
"maintenance": "Manutenzione",
|
"maintenance": "Manutenzione",
|
||||||
"initializing": "Inizializzazione",
|
"initializing": "Inizializzazione",
|
||||||
"retrying": "Riprova",
|
"retrying": "Riprova"
|
||||||
"queued": "In coda"
|
|
||||||
},
|
},
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"question": {
|
"question": {
|
||||||
@@ -304,6 +310,21 @@
|
|||||||
},
|
},
|
||||||
"min_latency": {
|
"min_latency": {
|
||||||
"name": "Latenza minima"
|
"name": "Latenza minima"
|
||||||
|
},
|
||||||
|
"last_model": {
|
||||||
|
"name": "Ultimo modello utilizzato"
|
||||||
|
},
|
||||||
|
"last_timestamp": {
|
||||||
|
"name": "Ora dell'ultima risposta"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"name": "Nome istanza"
|
||||||
|
},
|
||||||
|
"normalized_name": {
|
||||||
|
"name": "Nome normalizzato"
|
||||||
|
},
|
||||||
|
"conversation_history": {
|
||||||
|
"name": "Cronologia conversazione"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
||||||
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
|
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
|
||||||
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
||||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
|
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)",
|
||||||
|
"disable_thinking": "Отключить режим thinking/reasoning (Qwen /no_think, срезание блоков think, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
||||||
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
|
"context_messages": "Количество сохраняемых контекстных сообщений (1-20)",
|
||||||
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
||||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
|
"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)",
|
"api_timeout": "Таймаут API-запроса в секундах (5-600)",
|
||||||
"context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)",
|
"context_messages": "Количество предыдущих сообщений для включения в контекст (1-20)",
|
||||||
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
"max_history_size": "Максимальный размер истории разговора (1-100)",
|
||||||
"allow_local_network": "Разрешить локальные сетевые адреса (для self-hosted прокси)"
|
"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": {
|
"json_schema": {
|
||||||
"name": "JSON Schema",
|
"name": "JSON Schema",
|
||||||
"description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output."
|
"description": "JSON-схема, определяющая структуру ожидаемого ответа. Обязательна при включении structured_output."
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Отключить thinking",
|
||||||
|
"description": "Отключить режим thinking/reasoning для этого запроса. Переопределяет настройку интеграции."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
||||||
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
|
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
|
||||||
"max_history_size": "Максимална величина историје разговора (1-100)",
|
"max_history_size": "Максимална величина историје разговора (1-100)",
|
||||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
|
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
|
||||||
|
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
||||||
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
|
"context_messages": "Број контекстуалних порука које треба задржати (1-20)",
|
||||||
"max_history_size": "Максимална величина историје разговора (1-100)",
|
"max_history_size": "Максимална величина историје разговора (1-100)",
|
||||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
|
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)",
|
||||||
|
"disable_thinking": "Онемогући thinking/reasoning режим (Qwen /no_think, уклања think блокове, Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"name_exists": "Инстанца са овим именом већ постоји",
|
"name_exists": "Инстанца са овим именом већ постоји",
|
||||||
"invalid_name": "Неважеће име инстанце",
|
"invalid_name": "Неважеће име инстанце",
|
||||||
"invalid_auth": "Аутентификација није успела - проверите ваш API кључ",
|
"invalid_auth": "Аутентификација није успела - проверите ваш API кључ",
|
||||||
|
"api_key_required": "API кључ је обавезан при промени провајдера или endpoint-а",
|
||||||
"invalid_api_key": "Неважећи API кључ - молимо проверите ваше акредитиве",
|
"invalid_api_key": "Неважећи API кључ - молимо проверите ваше акредитиве",
|
||||||
"cannot_connect": "Неуспело повезивање са API сервисом",
|
"cannot_connect": "Неуспело повезивање са API сервисом",
|
||||||
"invalid_model": "Изабрани модел није доступан",
|
"invalid_model": "Изабрани модел није доступан",
|
||||||
@@ -57,7 +60,6 @@
|
|||||||
"invalid_instance": "Неважећа инстанца је назначена",
|
"invalid_instance": "Неважећа инстанца је назначена",
|
||||||
"unknown": "Дошло је до неочекиване грешке",
|
"unknown": "Дошло је до неочекиване грешке",
|
||||||
"empty": "Име не може бити празно",
|
"empty": "Име не може бити празно",
|
||||||
"invalid_characters": "Име може садржавати само слова, цифре, размаке, подцрта и цртице",
|
|
||||||
"name_too_long": "Име мора бити 50 знакова или мање"
|
"name_too_long": "Име мора бити 50 знакова или мање"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
"api_timeout": "Временско ограничење API захтева у секундама (5-600)",
|
||||||
"context_messages": "Број претходних порука које треба укључити у контекст (1-20)",
|
"context_messages": "Број претходних порука које треба укључити у контекст (1-20)",
|
||||||
"max_history_size": "Максимална величина историје разговора (1-100)",
|
"max_history_size": "Максимална величина историје разговора (1-100)",
|
||||||
"allow_local_network": "Дозволи локалне мрежне адресе (за self-hosted проксије)"
|
"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": {
|
"json_schema": {
|
||||||
"name": "JSON шема",
|
"name": "JSON шема",
|
||||||
"description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен."
|
"description": "JSON шема која дефинише структуру очекиваног одговора. Обавезна када је structured_output омогућен."
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "Онемогући thinking",
|
||||||
|
"description": "Онемогући thinking/reasoning режим за овај захтев. Надмашује поставку интеграције."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,8 +218,7 @@
|
|||||||
"rate_limited": "Ограничење захтева",
|
"rate_limited": "Ограничење захтева",
|
||||||
"maintenance": "Одржавање",
|
"maintenance": "Одржавање",
|
||||||
"initializing": "Инициализује се",
|
"initializing": "Инициализује се",
|
||||||
"retrying": "Покушава поново",
|
"retrying": "Покушава поново"
|
||||||
"queued": "У реду"
|
|
||||||
},
|
},
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"question": {
|
"question": {
|
||||||
@@ -304,9 +310,24 @@
|
|||||||
},
|
},
|
||||||
"min_latency": {
|
"min_latency": {
|
||||||
"name": "Минимална латенција"
|
"name": "Минимална латенција"
|
||||||
|
},
|
||||||
|
"last_model": {
|
||||||
|
"name": "Последњи коришћени модел"
|
||||||
|
},
|
||||||
|
"last_timestamp": {
|
||||||
|
"name": "Време последњег одговора"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"name": "Назив инстанце"
|
||||||
|
},
|
||||||
|
"normalized_name": {
|
||||||
|
"name": "Нормализовани назив"
|
||||||
|
},
|
||||||
|
"conversation_history": {
|
||||||
|
"name": "Историја разговора"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"api_timeout": "API请求超时时间(5-600秒)",
|
"api_timeout": "API请求超时时间(5-600秒)",
|
||||||
"context_messages": "保留的上下文消息数量(1-20)",
|
"context_messages": "保留的上下文消息数量(1-20)",
|
||||||
"max_history_size": "最大对话历史大小(1-100)",
|
"max_history_size": "最大对话历史大小(1-100)",
|
||||||
"allow_local_network": "允许本地网络端点(用于自托管代理)"
|
"allow_local_network": "允许本地网络端点(用于自托管代理)",
|
||||||
|
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"api_timeout": "API请求超时时间(5-600秒)",
|
"api_timeout": "API请求超时时间(5-600秒)",
|
||||||
"context_messages": "保留的上下文消息数量(1-20)",
|
"context_messages": "保留的上下文消息数量(1-20)",
|
||||||
"max_history_size": "最大对话历史大小(1-100)",
|
"max_history_size": "最大对话历史大小(1-100)",
|
||||||
"allow_local_network": "允许本地网络端点(用于自托管代理)"
|
"allow_local_network": "允许本地网络端点(用于自托管代理)",
|
||||||
|
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"name_exists": "具有此名称的实例已存在",
|
"name_exists": "具有此名称的实例已存在",
|
||||||
"invalid_name": "无效的实例名称",
|
"invalid_name": "无效的实例名称",
|
||||||
"invalid_auth": "身份验证失败 - 检查您的API密钥",
|
"invalid_auth": "身份验证失败 - 检查您的API密钥",
|
||||||
|
"api_key_required": "更改提供商或端点时需要输入 API 密钥",
|
||||||
"invalid_api_key": "无效的API密钥 - 请验证您的凭据",
|
"invalid_api_key": "无效的API密钥 - 请验证您的凭据",
|
||||||
"cannot_connect": "无法连接到API服务",
|
"cannot_connect": "无法连接到API服务",
|
||||||
"invalid_model": "所选模型不可用",
|
"invalid_model": "所选模型不可用",
|
||||||
@@ -57,7 +60,6 @@
|
|||||||
"invalid_instance": "指定的实例无效",
|
"invalid_instance": "指定的实例无效",
|
||||||
"unknown": "发生意外错误",
|
"unknown": "发生意外错误",
|
||||||
"empty": "名称不能为空",
|
"empty": "名称不能为空",
|
||||||
"invalid_characters": "名称只能包含字母、数字、空格、下划线和连字符",
|
|
||||||
"name_too_long": "名称必须少于50个字符"
|
"name_too_long": "名称必须少于50个字符"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
"api_timeout": "API请求超时时间(5-600秒)",
|
"api_timeout": "API请求超时时间(5-600秒)",
|
||||||
"context_messages": "要包含在上下文中的先前消息数量(1-20)",
|
"context_messages": "要包含在上下文中的先前消息数量(1-20)",
|
||||||
"max_history_size": "最大对话历史大小(1-100)",
|
"max_history_size": "最大对话历史大小(1-100)",
|
||||||
"allow_local_network": "允许本地网络端点(用于自托管代理)"
|
"allow_local_network": "允许本地网络端点(用于自托管代理)",
|
||||||
|
"disable_thinking": "禁用思考/推理模式(Qwen /no_think,移除 think 块,Gemini 2.5 thinking_budget=0)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,10 @@
|
|||||||
"json_schema": {
|
"json_schema": {
|
||||||
"name": "JSON模式",
|
"name": "JSON模式",
|
||||||
"description": "定义预期响应结构的JSON模式。启用structured_output时必需。"
|
"description": "定义预期响应结构的JSON模式。启用structured_output时必需。"
|
||||||
|
},
|
||||||
|
"disable_thinking": {
|
||||||
|
"name": "禁用思考",
|
||||||
|
"description": "为此请求禁用思考/推理模式。覆盖集成级别的设置。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,8 +218,7 @@
|
|||||||
"rate_limited": "速率限制",
|
"rate_limited": "速率限制",
|
||||||
"maintenance": "维护中",
|
"maintenance": "维护中",
|
||||||
"initializing": "初始化中",
|
"initializing": "初始化中",
|
||||||
"retrying": "重试中",
|
"retrying": "重试中"
|
||||||
"queued": "排队中"
|
|
||||||
},
|
},
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"question": {
|
"question": {
|
||||||
@@ -304,9 +310,24 @@
|
|||||||
},
|
},
|
||||||
"min_latency": {
|
"min_latency": {
|
||||||
"name": "最小延迟"
|
"name": "最小延迟"
|
||||||
|
},
|
||||||
|
"last_model": {
|
||||||
|
"name": "最近使用的模型"
|
||||||
|
},
|
||||||
|
"last_timestamp": {
|
||||||
|
"name": "最近响应时间"
|
||||||
|
},
|
||||||
|
"instance_name": {
|
||||||
|
"name": "实例名称"
|
||||||
|
},
|
||||||
|
"normalized_name": {
|
||||||
|
"name": "规范化名称"
|
||||||
|
},
|
||||||
|
"conversation_history": {
|
||||||
|
"name": "对话历史"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
Utility functions for HA Text AI integration.
|
Utility functions for HA Text AI integration.
|
||||||
|
|
||||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
@license: MIT (https://opensource.org/licenses/MIT)
|
||||||
@author: SMKRV
|
@author: SMKRV
|
||||||
@github: https://github.com/smkrv/ha-text-ai
|
@github: https://github.com/smkrv/ha-text-ai
|
||||||
@source: https://github.com/smkrv/ha-text-ai
|
@source: https://github.com/smkrv/ha-text-ai
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp.abc import AbstractResolver
|
||||||
|
|
||||||
from homeassistant.const import CONF_API_KEY
|
from homeassistant.const import CONF_API_KEY
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
def normalize_name(name: str) -> str:
|
def normalize_name(name: str) -> str:
|
||||||
"""Normalize name to conform to HA naming convention using underscores."""
|
"""Normalize name to conform to HA naming convention using underscores.
|
||||||
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
|
|
||||||
normalized = '_'.join(filter(None, normalized.split('_')))
|
|
||||||
return normalized.lower()
|
|
||||||
|
|
||||||
|
If the input collapses to an empty string (all non-alphanumeric or
|
||||||
|
all underscores), fall back to a short hash of the original so that
|
||||||
|
downstream entity IDs never end with a trailing underscore.
|
||||||
|
"""
|
||||||
|
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
|
||||||
|
normalized = '_'.join(filter(None, normalized.split('_'))).lower()
|
||||||
|
if not normalized:
|
||||||
|
digest = hashlib.sha256(name.encode("utf-8", errors="replace")).hexdigest()[:8]
|
||||||
|
normalized = f"instance_{digest}"
|
||||||
|
return normalized
|
||||||
|
|
||||||
def safe_log_data(
|
def safe_log_data(
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
@@ -31,11 +44,9 @@ def safe_log_data(
|
|||||||
"""Filter sensitive keys from data for safe logging."""
|
"""Filter sensitive keys from data for safe logging."""
|
||||||
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
|
return {k: "***" if k in sensitive_keys else v for k, v in data.items()}
|
||||||
|
|
||||||
|
|
||||||
class _RestrictedIPError(ValueError):
|
class _RestrictedIPError(ValueError):
|
||||||
"""Raised when an IP address is in a restricted range."""
|
"""Raised when an IP address is in a restricted range."""
|
||||||
|
|
||||||
|
|
||||||
def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
"""Check if an IP address is in a restricted range."""
|
"""Check if an IP address is in a restricted range."""
|
||||||
return (
|
return (
|
||||||
@@ -47,14 +58,143 @@ def _check_ip_restricted(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) ->
|
|||||||
or addr.is_unspecified
|
or addr.is_unspecified
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _is_cloud_metadata_or_unsafe(
|
||||||
|
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||||
|
) -> bool:
|
||||||
|
"""Block link-local and cloud instance-metadata addresses.
|
||||||
|
|
||||||
async def validate_endpoint(hass: HomeAssistant, endpoint: str, *, allow_local: bool = False) -> str:
|
These must be blocked even in allow_local_network mode:
|
||||||
"""Validate API endpoint URL for security.
|
- IPv4 link-local (169.254.0.0/16) covers AWS/GCP/Azure IMDS 169.254.169.254.
|
||||||
|
- IPv6 link-local (fe80::/10).
|
||||||
|
- Multicast and unspecified addresses.
|
||||||
|
Without this check, a self-hosted HA running on a cloud VM could be
|
||||||
|
tricked into exfiltrating cloud credentials via IMDS.
|
||||||
|
"""
|
||||||
|
return addr.is_multicast or addr.is_unspecified or addr.is_link_local
|
||||||
|
|
||||||
|
class _PinnedResolver(AbstractResolver):
|
||||||
|
"""aiohttp resolver that returns pre-validated IPs for a single hostname.
|
||||||
|
|
||||||
|
Why: prevents DNS-rebinding attacks. After validate_endpoint has
|
||||||
|
confirmed the hostname resolves to a safe IP, we pin that IP in the
|
||||||
|
resolver used by the aiohttp session. aiohttp then skips its own
|
||||||
|
DNS lookup on each request and uses the pinned IP, closing the
|
||||||
|
TOCTOU gap between validation and actual HTTP call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
pinned: dict[str, list[tuple[str, int]]],
|
||||||
|
) -> None:
|
||||||
|
self._pinned = pinned
|
||||||
|
|
||||||
|
async def resolve(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 0,
|
||||||
|
family: int = socket.AF_INET,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
entries = self._pinned.get(host.lower())
|
||||||
|
if entries is None:
|
||||||
|
# Every request on a pinned session must target the validated
|
||||||
|
# host. Resolving anything else means a request escaped the pin
|
||||||
|
# (a new call site or a config bug) — fail closed rather than
|
||||||
|
# fall back to live, unvalidated DNS.
|
||||||
|
raise OSError(f"Refusing to resolve unpinned host: {host}")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"hostname": host,
|
||||||
|
"host": ip,
|
||||||
|
"port": port or default_port,
|
||||||
|
"family": _family_for(ip),
|
||||||
|
"proto": 0,
|
||||||
|
"flags": 0,
|
||||||
|
}
|
||||||
|
for ip, default_port in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Nothing to close; the resolver holds only the pinned map."""
|
||||||
|
|
||||||
|
def _family_for(ip: str) -> int:
|
||||||
|
"""Return AF_INET or AF_INET6 based on the IP literal."""
|
||||||
|
try:
|
||||||
|
return socket.AF_INET6 if ":" in ip else socket.AF_INET
|
||||||
|
except Exception:
|
||||||
|
return socket.AF_INET
|
||||||
|
|
||||||
|
async def resolve_hostname_ips(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
hostname: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Resolve hostname to all its IPs via the event-loop-safe executor.
|
||||||
|
|
||||||
|
Returns a list of IP strings (may contain both IPv4 and IPv6).
|
||||||
|
Raises ValueError on resolution failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
addrinfos = await hass.async_add_executor_job(
|
||||||
|
socket.getaddrinfo, hostname, None
|
||||||
|
)
|
||||||
|
except socket.gaierror as err:
|
||||||
|
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||||
|
ips = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||||
|
ip = sockaddr[0]
|
||||||
|
if ip not in seen:
|
||||||
|
seen.add(ip)
|
||||||
|
ips.append(ip)
|
||||||
|
if not ips:
|
||||||
|
raise ValueError(f"No IPs for hostname: {hostname}")
|
||||||
|
return ips
|
||||||
|
|
||||||
|
def create_pinned_session(
|
||||||
|
endpoint: str,
|
||||||
|
resolved_ips: list[str],
|
||||||
|
) -> aiohttp.ClientSession:
|
||||||
|
"""Create an isolated aiohttp session with pinned DNS and no cookie jar.
|
||||||
|
|
||||||
|
Addresses two issues at once:
|
||||||
|
- DNS rebinding: aiohttp will reuse the pinned IPs from validate_endpoint
|
||||||
|
rather than re-resolving the hostname on each request.
|
||||||
|
- Cookie pollution: DummyCookieJar prevents cookies from leaking between
|
||||||
|
this integration and other HA components sharing the same domain.
|
||||||
|
|
||||||
|
Built directly on aiohttp: HA's async_create_clientsession always
|
||||||
|
injects its own pooled connector and rejects a caller-supplied one,
|
||||||
|
so a custom resolver cannot go through the helper. The caller owns
|
||||||
|
the session and must close it. Requests must pass
|
||||||
|
allow_redirects=False so a 3xx response cannot route past the pinned
|
||||||
|
resolver to an unvalidated host.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(endpoint)
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
pinned: dict[str, list[tuple[str, int]]] = {
|
||||||
|
hostname: [(ip, port) for ip in resolved_ips]
|
||||||
|
}
|
||||||
|
connector = aiohttp.TCPConnector(resolver=_PinnedResolver(pinned))
|
||||||
|
return aiohttp.ClientSession(
|
||||||
|
connector=connector,
|
||||||
|
cookie_jar=aiohttp.DummyCookieJar(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_endpoint(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
endpoint: str,
|
||||||
|
*,
|
||||||
|
allow_local: bool = False,
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""Validate API endpoint URL for security and pin resolved IPs.
|
||||||
|
|
||||||
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection).
|
Ensures HTTPS-only and blocks private/reserved IP ranges (SSRF protection).
|
||||||
When allow_local is True, permits private IPs and HTTP scheme for self-hosted proxies.
|
When allow_local is True, permits private IPs and HTTP scheme for self-hosted proxies.
|
||||||
Uses async DNS resolution to avoid blocking the event loop.
|
Uses async DNS resolution to avoid blocking the event loop.
|
||||||
Returns the validated endpoint stripped of trailing slashes.
|
|
||||||
|
Returns: (validated_endpoint_without_trailing_slash, resolved_ips).
|
||||||
|
The resolved IPs are intended for pinning in aiohttp resolver via
|
||||||
|
create_pinned_session(), closing the DNS-rebinding TOCTOU gap.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the endpoint fails validation.
|
ValueError: If the endpoint fails validation.
|
||||||
@@ -72,54 +212,47 @@ async def validate_endpoint(hass: HomeAssistant, endpoint: str, *, allow_local:
|
|||||||
if not hostname:
|
if not hostname:
|
||||||
raise ValueError("Invalid endpoint URL: no hostname")
|
raise ValueError("Invalid endpoint URL: no hostname")
|
||||||
|
|
||||||
if allow_local:
|
resolved_ips: list[str] = []
|
||||||
# Even in local mode, block multicast and unspecified addresses
|
|
||||||
|
# Collect and check all resolved IPs (or IP literal directly).
|
||||||
|
def _collect(ips: list[str]) -> None:
|
||||||
|
for ip in ips:
|
||||||
|
if ip not in resolved_ips:
|
||||||
|
resolved_ips.append(ip)
|
||||||
|
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(hostname)
|
||||||
_is_ip_literal = True
|
_is_ip_literal = True
|
||||||
try:
|
except ValueError:
|
||||||
addr = ipaddress.ip_address(hostname)
|
addr = None
|
||||||
except ValueError:
|
_is_ip_literal = False
|
||||||
_is_ip_literal = False
|
|
||||||
|
|
||||||
if _is_ip_literal:
|
if _is_ip_literal:
|
||||||
if addr.is_multicast or addr.is_unspecified:
|
_collect([hostname])
|
||||||
raise ValueError("Multicast and unspecified addresses are not allowed")
|
|
||||||
else:
|
|
||||||
# Not an IP literal — resolve and check
|
|
||||||
try:
|
|
||||||
addrinfos = await hass.async_add_executor_job(
|
|
||||||
socket.getaddrinfo, hostname, None
|
|
||||||
)
|
|
||||||
for family, _type, _proto, _canonname, sockaddr in addrinfos:
|
|
||||||
resolved = ipaddress.ip_address(sockaddr[0])
|
|
||||||
if resolved.is_multicast or resolved.is_unspecified:
|
|
||||||
raise ValueError(
|
|
||||||
"Hostname resolves to multicast/unspecified address"
|
|
||||||
)
|
|
||||||
except socket.gaierror as err:
|
|
||||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
|
||||||
else:
|
else:
|
||||||
# Full SSRF protection — block private/reserved IPs
|
|
||||||
try:
|
try:
|
||||||
addr = ipaddress.ip_address(hostname)
|
addrinfos = await hass.async_add_executor_job(
|
||||||
if _check_ip_restricted(addr):
|
socket.getaddrinfo, hostname, None
|
||||||
raise _RestrictedIPError("Private/reserved IP addresses are not allowed")
|
)
|
||||||
except _RestrictedIPError:
|
except socket.gaierror as err:
|
||||||
raise
|
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
||||||
except ValueError:
|
_collect([sockaddr[0] for (*_, sockaddr) in addrinfos])
|
||||||
# Not an IP literal — resolve hostname and check all resolved IPs
|
|
||||||
# to prevent DNS rebinding attacks
|
|
||||||
try:
|
|
||||||
addrinfos = await hass.async_add_executor_job(
|
|
||||||
socket.getaddrinfo, hostname, None
|
|
||||||
)
|
|
||||||
for family, _type, _proto, _canonname, sockaddr in addrinfos:
|
|
||||||
ip_str = sockaddr[0]
|
|
||||||
resolved_addr = ipaddress.ip_address(ip_str)
|
|
||||||
if _check_ip_restricted(resolved_addr):
|
|
||||||
raise ValueError(
|
|
||||||
"Hostname resolves to a restricted IP range"
|
|
||||||
)
|
|
||||||
except socket.gaierror as err:
|
|
||||||
raise ValueError(f"Cannot resolve hostname: {hostname}") from err
|
|
||||||
|
|
||||||
return endpoint.rstrip("/")
|
if not resolved_ips:
|
||||||
|
raise ValueError(f"No IPs resolved for hostname: {hostname}")
|
||||||
|
|
||||||
|
# Validate each resolved IP against the selected policy.
|
||||||
|
for ip_str in resolved_ips:
|
||||||
|
ip_obj = ipaddress.ip_address(ip_str)
|
||||||
|
if allow_local:
|
||||||
|
if _is_cloud_metadata_or_unsafe(ip_obj):
|
||||||
|
raise ValueError(
|
||||||
|
"Link-local/metadata/multicast addresses are not allowed"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if _check_ip_restricted(ip_obj):
|
||||||
|
raise _RestrictedIPError(
|
||||||
|
"Private/reserved IP addresses are not allowed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return endpoint.rstrip("/"), resolved_ips
|
||||||
|
|||||||
Reference in New Issue
Block a user