Files
ha-text-ai/custom_components/ha_text_ai/metrics.py
T
SMKRV 6e635f7e2f 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]].
2026-04-17 01:58:16 +03:00

177 lines
6.3 KiB
Python

"""
Metrics management for HA Text AI integration.
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
"""
from __future__ import annotations
import json
import logging
import os
import re
import traceback
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
DEFAULT_METRICS: dict[str, Any] = {
"total_tokens": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"successful_requests": 0,
"failed_requests": 0,
"total_errors": 0,
"average_latency": 0,
"max_latency": 0,
"min_latency": 0,
}
class MetricsManager:
"""Manages performance metrics for an instance."""
def __init__(
self,
hass: HomeAssistant,
instance_name: str,
metrics_file: str,
) -> None:
self.hass = hass
self.instance_name = instance_name
self._metrics_file = metrics_file
self._performance_metrics: dict[str, Any] = DEFAULT_METRICS.copy()
@property
def metrics(self) -> dict[str, Any]:
return self._performance_metrics
async def async_initialize(self) -> None:
"""Load metrics from storage or create defaults."""
loaded = await self._load_metrics()
self._performance_metrics = loaded or DEFAULT_METRICS.copy()
async def _load_metrics(self) -> dict[str, Any] | None:
try:
exists = await self.hass.async_add_executor_job(
os.path.exists, self._metrics_file
)
if exists:
def read_metrics():
with open(self._metrics_file, "r") as f:
try:
return json.load(f)
except json.JSONDecodeError:
_LOGGER.warning("Metrics file corrupted, creating new")
return None
return await self.hass.async_add_executor_job(read_metrics)
except Exception as e:
_LOGGER.warning("Failed to load metrics: %s", e)
return None
async def _save_metrics(self) -> None:
try:
def write_metrics():
with open(self._metrics_file, "w") as f:
json.dump(self._performance_metrics, f)
await self.hass.async_add_executor_job(write_metrics)
except Exception as e:
_LOGGER.warning("Failed to save metrics: %s", e)
async def update_metrics(self, latency: float, response: dict) -> None:
"""Update performance metrics after a successful request."""
metrics = self._performance_metrics
tokens = response.get("tokens", {})
metrics["total_tokens"] += tokens.get("total", 0)
metrics["prompt_tokens"] += tokens.get("prompt", 0)
metrics["completion_tokens"] += tokens.get("completion", 0)
metrics["successful_requests"] += 1
metrics["average_latency"] = (
(metrics["average_latency"] * (metrics["successful_requests"] - 1) + latency)
/ metrics["successful_requests"]
)
metrics["max_latency"] = max(metrics["max_latency"], latency)
if metrics["min_latency"] == 0:
metrics["min_latency"] = latency
else:
metrics["min_latency"] = min(metrics["min_latency"], latency)
await self._save_metrics()
async def get_current_metrics(self) -> dict[str, Any]:
"""Get current performance metrics."""
return self._performance_metrics.copy()
async def handle_error(
self,
error: Exception,
model: str,
) -> dict[str, Any]:
"""Record an error in metrics and return error details."""
self._performance_metrics["total_errors"] += 1
self._performance_metrics["failed_requests"] += 1
await self._save_metrics()
error_msg = str(error)
# Strip URLs, API keys, tokens, and query parameters from error messages.
# Patterns use word boundaries and explicit length bounds so that
# overly greedy matches don't accidentally swallow adjacent text.
error_msg = re.sub(r'https?://\S+', '[URL]', error_msg)
error_msg = re.sub(r'[?&]key=[^\s&]+', '?key=***', error_msg)
# Google API key: fixed prefix + 30+ url-safe chars, bounded by non-key char.
error_msg = re.sub(
r'AIza[A-Za-z0-9_\-]{30,}(?=[^A-Za-z0-9_\-]|$)', '***', error_msg
)
# Anthropic / OpenAI / DeepSeek format: "sk-..." (anchors on word boundary).
error_msg = re.sub(r'\bsk-[A-Za-z0-9_\-]{20,}\b', '***', error_msg)
# Bearer tokens: header-style and JSON-embedded ("Bearer xxx").
error_msg = re.sub(r'[Bb]earer\s+[A-Za-z0-9_\-\.=]+', 'Bearer ***', error_msg)
# x-api-key header in any case, both raw and JSON-serialized forms.
error_msg = re.sub(
r'"?x-api-key"?\s*[:=]\s*"?[A-Za-z0-9_\-\.]+"?',
'x-api-key: ***',
error_msg,
flags=re.IGNORECASE,
)
if len(error_msg) > 256:
error_msg = error_msg[:256] + "..."
error_details: dict[str, Any] = {
"timestamp": dt_util.utcnow().isoformat(),
"model": model,
"instance": self.instance_name,
"error_message": error_msg,
"error_type": type(error).__name__,
"traceback": traceback.format_exc()
if _LOGGER.isEnabledFor(logging.DEBUG)
else None,
}
error_mapping = {
HomeAssistantError: {"is_ha_error": True},
ConnectionError: {"is_connection_error": True},
TimeoutError: {"is_timeout": True},
PermissionError: {"is_permission_denied": True},
ValueError: {"is_validation_error": True},
}
for error_type, error_flags in error_mapping.items():
if isinstance(error, error_type):
error_details.update(error_flags)
break
_LOGGER.error("AI Processing Error: %s", error_details)
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Full Error Traceback: %s", error_details.get("traceback"))
return error_details