mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 07:03:58 +08:00
fix: v2.5.1 security/ML/reliability patch
Security: - H1 DNS rebinding TOCTOU: validate_endpoint returns (endpoint, resolved_ips); create_pinned_session builds an aiohttp session with a custom resolver that returns only the pre-validated IPs, closing the re-resolve gap. - H2/M3 Shared session cookie pollution: integration no longer uses HA shared clientsession; isolated session with DummyCookieJar prevents cross-integration cookie leaks. - Cloud metadata / link-local block added to allow_local_network mode to prevent IMDS exfiltration on cloud VMs. - L3 hard cap extended to history.async_get_history (not only service schema). ML/LLM correctness: - _is_openai_reasoning_model uses regex with gpt-5-chat* blacklist and handles OpenRouter-style openai/ prefix. Future o5/gpt-6 auto-recognized. - reasoning_effort=minimal for gpt-5 family, low for o-series. - Gemini 2.5 Pro gets thinking_budget=128 (Pro rejects 0, flash accepts 0). - Anthropic extracts first type=text block instead of hardcoded content[0]. - /no_think dedup uses word-boundary regex instead of substring. - DeepSeek-reasoner detected: skips /no_think; preserves reasoning_content. Reliability: - Exception chaining added to Gemini-block re-raises. - Top-level imports for json/re/hashlib (no more lazy stdlib imports). - allow_local_network logged at INFO, not WARNING on every setup. Code style: - PEP 604 type hints across all .py files (dict/list/| None). No breaking changes for users. Internal API: validate_endpoint return type changed from str to tuple[str, list[str]].
This commit is contained in:
@@ -9,7 +9,7 @@ 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
|
||||||
|
|
||||||
@@ -20,12 +20,11 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME
|
|||||||
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,
|
||||||
@@ -110,7 +109,7 @@ 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, {})
|
||||||
@@ -274,22 +273,26 @@ 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.
|
||||||
|
session = create_pinned_session(hass, 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)
|
||||||
@@ -363,7 +366,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:
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ API Client for 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,8 +90,8 @@ 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:
|
Retries on:
|
||||||
@@ -174,7 +175,7 @@ class APIClient:
|
|||||||
raise HomeAssistantError("API request failed after all retries")
|
raise HomeAssistantError("API request failed after all retries")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
def _parse_retry_after(value: str | None) -> float | None:
|
||||||
"""Parse Retry-After header (seconds). Caps at 60s to avoid long stalls."""
|
"""Parse Retry-After header (seconds). Caps at 60s to avoid long stalls."""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -189,13 +190,13 @@ class APIClient:
|
|||||||
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,
|
||||||
disable_thinking: bool = False,
|
disable_thinking: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> 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)
|
||||||
@@ -224,43 +225,60 @@ class APIClient:
|
|||||||
_LOGGER.error("API request failed: %s", str(e))
|
_LOGGER.error("API request failed: %s", str(e))
|
||||||
raise HomeAssistantError(f"API request failed: {str(e)}") from e
|
raise HomeAssistantError(f"API request failed: {str(e)}") from e
|
||||||
|
|
||||||
@staticmethod
|
# Non-reasoning variants whose names otherwise overlap with the
|
||||||
def _is_openai_reasoning_model(model: str) -> bool:
|
# reasoning prefix set (e.g. "gpt-5-chat-latest" is classic chat).
|
||||||
"""Detect OpenAI reasoning models (o-series and GPT-5 family).
|
_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),
|
Reasoning models require max_completion_tokens (not max_tokens),
|
||||||
do not accept custom temperature, and use "developer" role instead
|
do not accept custom temperature, and use "developer" role instead
|
||||||
of "system". Cutoff: models released 2025-09 and later are all
|
of "system". Uses a regex so future o5/gpt-6 releases are caught
|
||||||
reasoning-by-default (o3, o4-mini, gpt-5, gpt-5-mini, gpt-5-nano).
|
without code change. Explicitly excludes chat-variants
|
||||||
|
(e.g. gpt-5-chat-latest) which are classic chat models.
|
||||||
"""
|
"""
|
||||||
if not model:
|
if not model:
|
||||||
return False
|
return False
|
||||||
m = model.lower().lstrip()
|
m = model.strip().lower()
|
||||||
# Match bare model names and dated variants (o3-2025-04-16 etc.)
|
# OpenRouter-style "openai/o3" prefix — strip provider namespace.
|
||||||
return (
|
if "/" in m:
|
||||||
m.startswith(("o1", "o3", "o4-mini", "o4"))
|
m = m.rsplit("/", 1)[-1]
|
||||||
or m.startswith(("gpt-5", "gpt5"))
|
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
|
@staticmethod
|
||||||
def _convert_system_to_developer(
|
def _convert_system_to_developer(
|
||||||
messages: List[Dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
) -> List[Dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
"""Rename role "system" to "developer" for OpenAI reasoning models."""
|
"""Rename role "system" to "developer" for OpenAI reasoning models."""
|
||||||
return [
|
return [
|
||||||
{**m, "role": "developer"} if m.get("role") == "system" else m
|
{**m, "role": "developer"} if m.get("role") == "system" else m
|
||||||
for m in messages
|
for m in messages
|
||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
# 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(
|
def _apply_no_think_tag(
|
||||||
messages: List[Dict[str, str]],
|
cls,
|
||||||
) -> List[Dict[str, str]]:
|
messages: list[dict[str, str]],
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
"""Append Qwen-style /no_think soft switch to the last user message.
|
"""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
|
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
|
request to skip thinking. Non-Qwen models ignore the trailing token
|
||||||
harmlessly, so this is safe to apply to all OpenAI-compatible backends.
|
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:
|
if not messages:
|
||||||
return messages
|
return messages
|
||||||
@@ -268,8 +286,8 @@ class APIClient:
|
|||||||
for i in range(len(patched) - 1, -1, -1):
|
for i in range(len(patched) - 1, -1, -1):
|
||||||
if patched[i].get("role") == "user":
|
if patched[i].get("role") == "user":
|
||||||
content = patched[i].get("content", "")
|
content = patched[i].get("content", "")
|
||||||
if "/no_think" not in content:
|
if not cls._NO_THINK_TOKEN_RE.search(content):
|
||||||
patched[i]["content"] = f"{content} /no_think".strip()
|
patched[i]["content"] = f"{content.rstrip()} /no_think".lstrip()
|
||||||
break
|
break
|
||||||
return patched
|
return patched
|
||||||
|
|
||||||
@@ -285,7 +303,6 @@ class APIClient:
|
|||||||
"""
|
"""
|
||||||
if not text or "<think>" not in text:
|
if not text or "<think>" not in text:
|
||||||
return text
|
return text
|
||||||
import re
|
|
||||||
pattern = re.compile(r"<think>.*?</think>", flags=re.DOTALL)
|
pattern = re.compile(r"<think>.*?</think>", flags=re.DOTALL)
|
||||||
cleaned = text
|
cleaned = text
|
||||||
# Iterative pass: each iteration peels one layer of nested tags.
|
# Iterative pass: each iteration peels one layer of nested tags.
|
||||||
@@ -303,14 +320,13 @@ class APIClient:
|
|||||||
|
|
||||||
@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"] = {
|
||||||
@@ -328,16 +344,28 @@ 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,
|
||||||
disable_thinking: bool = False,
|
disable_thinking: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create completion using DeepSeek API."""
|
"""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.
|
||||||
|
"""
|
||||||
url = f"{self.endpoint}/chat/completions"
|
url = f"{self.endpoint}/chat/completions"
|
||||||
final_messages = self._apply_no_think_tag(messages) if disable_thinking else messages
|
is_reasoner = "reasoner" in model.lower()
|
||||||
|
final_messages = (
|
||||||
|
self._apply_no_think_tag(messages)
|
||||||
|
if (disable_thinking and not is_reasoner)
|
||||||
|
else messages
|
||||||
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": final_messages,
|
"messages": final_messages,
|
||||||
@@ -348,13 +376,18 @@ class APIClient:
|
|||||||
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"]
|
message = data["choices"][0]["message"]
|
||||||
if disable_thinking:
|
content = message.get("content", "")
|
||||||
|
reasoning = message.get("reasoning_content")
|
||||||
|
if disable_thinking and not is_reasoner:
|
||||||
content = self._strip_think_blocks(content)
|
content = self._strip_think_blocks(content)
|
||||||
return {
|
return {
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"message": {"content": content},
|
"message": {
|
||||||
|
"content": content,
|
||||||
|
**({"reasoning_content": reasoning} if reasoning else {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"usage": {
|
"usage": {
|
||||||
@@ -367,13 +400,13 @@ 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,
|
||||||
disable_thinking: bool = False,
|
disable_thinking: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create completion using OpenAI API.
|
"""Create completion using OpenAI API.
|
||||||
|
|
||||||
Reasoning models (o-series, gpt-5 family) require a different payload
|
Reasoning models (o-series, gpt-5 family) require a different payload
|
||||||
@@ -388,13 +421,16 @@ class APIClient:
|
|||||||
|
|
||||||
if is_reasoning:
|
if is_reasoning:
|
||||||
prepared_messages = self._convert_system_to_developer(messages)
|
prepared_messages = self._convert_system_to_developer(messages)
|
||||||
payload: Dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": prepared_messages,
|
"messages": prepared_messages,
|
||||||
"max_completion_tokens": max_tokens,
|
"max_completion_tokens": max_tokens,
|
||||||
}
|
}
|
||||||
if disable_thinking:
|
if disable_thinking:
|
||||||
payload["reasoning_effort"] = "low"
|
# 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:
|
else:
|
||||||
prepared_messages = (
|
prepared_messages = (
|
||||||
self._apply_no_think_tag(messages) if disable_thinking else messages
|
self._apply_no_think_tag(messages) if disable_thinking else messages
|
||||||
@@ -429,13 +465,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,
|
||||||
disable_thinking: bool = False,
|
disable_thinking: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> 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"
|
||||||
|
|
||||||
@@ -455,10 +491,9 @@ class APIClient:
|
|||||||
# schema strings (built from templates/webhook data) could otherwise
|
# schema strings (built from templates/webhook data) could otherwise
|
||||||
# break out of the JSON fence and rewrite the system instruction.
|
# break out of the JSON fence and rewrite the system instruction.
|
||||||
if structured_output and json_schema:
|
if structured_output and json_schema:
|
||||||
import json as _json
|
|
||||||
try:
|
try:
|
||||||
_json.loads(json_schema)
|
json.loads(json_schema)
|
||||||
except _json.JSONDecodeError as err:
|
except json.JSONDecodeError as err:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Anthropic: invalid JSON schema, ignoring structured_output: %s", err
|
"Anthropic: invalid JSON schema, ignoring structured_output: %s", err
|
||||||
)
|
)
|
||||||
@@ -489,10 +524,14 @@ class APIClient:
|
|||||||
payload["system"] = system_prompt
|
payload["system"] = system_prompt
|
||||||
|
|
||||||
data = await self._make_request(url, payload)
|
data = await self._make_request(url, payload)
|
||||||
# Anthropic returns text in "content" array; extended thinking arrives
|
# Anthropic returns an array of content blocks; if extended thinking
|
||||||
# as a separate thinking-type block (not <think> tags), so no strip
|
# is ever enabled the first block may be type="thinking". Find the
|
||||||
# is required. Extended thinking is opt-in — we simply never request it.
|
# first text-type block instead of hardcoding index [0].
|
||||||
content = data["content"][0]["text"]
|
content = ""
|
||||||
|
for block in data.get("content", []):
|
||||||
|
if block.get("type") == "text":
|
||||||
|
content = block.get("text", "")
|
||||||
|
break
|
||||||
return {
|
return {
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
@@ -509,13 +548,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,
|
||||||
disable_thinking: bool = False,
|
disable_thinking: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create completion using Gemini API with google-genai library.
|
"""Create completion using Gemini API with google-genai library.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -566,7 +605,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:
|
||||||
@@ -589,10 +627,14 @@ 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 for Gemini 2.5+ models (ignored by 2.0 and earlier)
|
# Disable thinking for Gemini 2.5+ models. Flash variants accept
|
||||||
|
# thinking_budget=0 (fully off); Pro variants reject 0 and require
|
||||||
|
# at least 128 tokens of thinking. 2.0 and earlier ignore the field.
|
||||||
if disable_thinking:
|
if disable_thinking:
|
||||||
|
m_lower = model.lower()
|
||||||
|
budget = 128 if "2.5-pro" in m_lower else 0
|
||||||
try:
|
try:
|
||||||
config.thinking_config = types.ThinkingConfig(thinking_budget=0)
|
config.thinking_config = types.ThinkingConfig(thinking_budget=budget)
|
||||||
except (AttributeError, TypeError) as err:
|
except (AttributeError, TypeError) as err:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"ThinkingConfig not supported by this google-genai version: %s", err
|
"ThinkingConfig not supported by this google-genai version: %s", err
|
||||||
@@ -685,10 +727,12 @@ 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."""
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -61,13 +60,17 @@ from .const import (
|
|||||||
)
|
)
|
||||||
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(
|
||||||
@@ -100,7 +103,6 @@ def _build_parameter_schema(data: Dict[str, Any]) -> dict:
|
|||||||
): bool,
|
): 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."""
|
||||||
|
|
||||||
@@ -112,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(
|
||||||
@@ -131,7 +133,7 @@ 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 {}
|
||||||
@@ -150,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 = {}
|
||||||
|
|
||||||
@@ -229,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:
|
||||||
@@ -239,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:
|
||||||
@@ -253,7 +255,9 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
session = async_get_clientsession(self.hass)
|
# Pinned session ensures the reachability check goes to the same
|
||||||
|
# IP that will later be used by api_client (no DNS rebinding).
|
||||||
|
session = create_pinned_session(self.hass, endpoint, resolved_ips)
|
||||||
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
|
||||||
@@ -274,7 +278,7 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
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)
|
||||||
@@ -314,7 +318,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."""
|
||||||
|
|
||||||
@@ -326,7 +329,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"
|
||||||
@@ -335,7 +340,7 @@ 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)
|
session = create_pinned_session(self.hass, endpoint, resolved_ips)
|
||||||
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
|
||||||
@@ -356,11 +361,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
|||||||
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)
|
||||||
|
|
||||||
@@ -386,7 +391,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}
|
||||||
@@ -477,8 +482,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:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ API_PROVIDERS: Final = [
|
|||||||
API_PROVIDER_GEMINI
|
API_PROVIDER_GEMINI
|
||||||
]
|
]
|
||||||
|
|
||||||
VERSION: Final = "2.5.0"
|
VERSION: Final = "2.5.1"
|
||||||
|
|
||||||
# Default endpoints
|
# Default endpoints
|
||||||
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
|
DEFAULT_OPENAI_ENDPOINT: Final = "https://api.openai.com/v1"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -40,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."""
|
||||||
|
|
||||||
@@ -101,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": "",
|
||||||
@@ -132,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
|
||||||
@@ -155,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
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -173,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()
|
||||||
@@ -209,14 +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: Optional[bool] = 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:
|
||||||
@@ -279,11 +278,11 @@ 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,
|
disable_thinking: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send request to AI provider and return structured response.
|
"""Send request to AI provider and return structured response.
|
||||||
@@ -348,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,
|
||||||
@@ -383,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": {},
|
||||||
@@ -403,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()
|
||||||
|
|
||||||
@@ -422,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:
|
||||||
@@ -430,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}")
|
||||||
|
|||||||
@@ -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,7 +48,6 @@ 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:
|
def _assert_not_symlink(path: str) -> None:
|
||||||
"""Refuse to operate on a path that resolves to a symlink.
|
"""Refuse to operate on a path that resolves to a symlink.
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@ def _assert_not_symlink(path: str) -> None:
|
|||||||
if os.path.islink(path):
|
if os.path.islink(path):
|
||||||
raise OSError(f"Refusing to operate on symlink: {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."""
|
||||||
|
|
||||||
@@ -84,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
|
||||||
@@ -388,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()
|
||||||
@@ -425,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 = []
|
||||||
@@ -447,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.
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
"google-genai>=1.16.0,<2.0.0"
|
"google-genai>=1.16.0,<2.0.0"
|
||||||
],
|
],
|
||||||
"single_config_entry": false,
|
"single_config_entry": false,
|
||||||
"version": "2.5.0"
|
"version": "2.5.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +115,7 @@ 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
|
||||||
@@ -146,7 +145,7 @@ class MetricsManager:
|
|||||||
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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,
|
||||||
@@ -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,
|
||||||
@@ -185,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)
|
||||||
@@ -231,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 {}
|
||||||
|
|||||||
@@ -8,14 +8,22 @@ Utility functions for HA Text AI integration.
|
|||||||
"""
|
"""
|
||||||
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 aiohttp.resolver import DefaultResolver
|
||||||
|
|
||||||
from homeassistant.const import CONF_API_KEY
|
from homeassistant.const import CONF_API_KEY
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||||
|
|
||||||
|
_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.
|
||||||
@@ -27,12 +35,10 @@ def normalize_name(name: str) -> str:
|
|||||||
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
|
normalized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name)
|
||||||
normalized = '_'.join(filter(None, normalized.split('_'))).lower()
|
normalized = '_'.join(filter(None, normalized.split('_'))).lower()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
import hashlib
|
|
||||||
digest = hashlib.sha256(name.encode("utf-8", errors="replace")).hexdigest()[:8]
|
digest = hashlib.sha256(name.encode("utf-8", errors="replace")).hexdigest()[:8]
|
||||||
normalized = f"instance_{digest}"
|
normalized = f"instance_{digest}"
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def safe_log_data(
|
def safe_log_data(
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
sensitive_keys: tuple[str, ...] = (CONF_API_KEY,),
|
sensitive_keys: tuple[str, ...] = (CONF_API_KEY,),
|
||||||
@@ -40,11 +46,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 (
|
||||||
@@ -56,14 +60,136 @@ 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]]],
|
||||||
|
fallback: AbstractResolver | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._pinned = pinned
|
||||||
|
self._fallback = fallback or DefaultResolver()
|
||||||
|
|
||||||
|
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:
|
||||||
|
return await self._fallback.resolve(host, port, family)
|
||||||
|
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:
|
||||||
|
await self._fallback.close()
|
||||||
|
|
||||||
|
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(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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 async_create_clientsession(
|
||||||
|
hass,
|
||||||
|
cookie_jar=aiohttp.DummyCookieJar(),
|
||||||
|
connector=connector,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -81,54 +207,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