feat: v2.5.0 - disable_thinking, reasoning models, MIT license, security hardening

Features:
- disable_thinking toggle (issue #11) with per-provider semantics:
  OpenAI classic gets /no_think soft-switch, OpenAI reasoning gets
  reasoning_effort=low, DeepSeek gets both, Anthropic no-op (thinking
  opt-in), Gemini 2.5+ gets thinking_budget=0
- OpenAI reasoning model support (o1/o3/o4-mini/gpt-5 family):
  max_completion_tokens, developer role, reasoning_effort
- Per-request disable_thinking override in ask_question service
- Provider-specific temperature clip (Anthropic 0-1, others 0-2)

Security:
- Require API key re-entry on provider change (OptionsFlow)
- Validate Anthropic json_schema before system-prompt concatenation
- Symlink protection in history file operations
- Hardened secret redaction regexes (sk-*, AIza*, x-api-key)
- get_history service: hard cap on limit (default 10, max 100)

Reliability:
- Retry on 502/503/504 in addition to 429/timeout
- Honor Retry-After header on 429
- Exception chaining (raise ... from err)
- _strip_think_blocks handles nested/dangling tags
- normalize_name sha256 fallback for empty-collapse inputs

UI/housekeeping:
- api_key uses TextSelector(type=PASSWORD)
- DeviceInfo entry_type=SERVICE
- Services unregister on last entry unload
- Dependabot for GitHub Actions
- persist-credentials=false in checkout steps
- manifest requirements pinned with upper bounds
- Ignore docs/plans/ (working artifacts)

License: PolyForm Noncommercial 1.0.0 -> MIT

No breaking changes: service response shape, sensor attributes,
entity IDs and on-disk formats unchanged.
This commit is contained in:
SMKRV
2026-04-17 01:30:57 +03:00
parent 1e2ff81d07
commit 5af733b1b0
27 changed files with 475 additions and 226 deletions
+218 -35
View File
@@ -1,7 +1,7 @@
"""
API Client for HA Text AI.
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
@license: MIT (https://opensource.org/licenses/MIT)
@author: SMKRV
@github: https://github.com/smkrv/ha-text-ai
@source: https://github.com/smkrv/ha-text-ai
@@ -91,10 +91,20 @@ class APIClient:
url: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Make API request with retry logic for transient errors only."""
"""Make API request with retry logic for transient errors only.
Retries on:
- asyncio.TimeoutError
- HTTP 429 (rate limit) — honors Retry-After header when present
- HTTP 502/503/504 (upstream transient errors)
4xx (other than 429) return immediately — they are not retryable.
"""
safe_payload = {k: v for k, v in payload.items() if k not in ['messages', 'system']}
_LOGGER.debug("API Request: URL=%s, Safe payload: %s", url, safe_payload)
retryable_5xx = {502, 503, 504}
for attempt in range(API_RETRY_COUNT):
try:
async with self.session.post(
@@ -114,25 +124,41 @@ class APIClient:
except Exception:
error_data = {"raw": await response.text()}
# Rate limit — retry with backoff
# Rate limit — retry with backoff, prefer Retry-After header
if response.status == 429:
_LOGGER.warning(
"Rate limit on attempt %d/%d", attempt + 1, API_RETRY_COUNT
)
if attempt < API_RETRY_COUNT - 1:
await asyncio.sleep(2 ** attempt)
retry_after = self._parse_retry_after(
response.headers.get("Retry-After")
)
await asyncio.sleep(retry_after or (2 ** attempt))
continue
raise HomeAssistantError("API rate limit exceeded")
# Client/server errors — don't retry
# Upstream transient errors — retry with backoff
if response.status in retryable_5xx:
_LOGGER.warning(
"Upstream %d on attempt %d/%d",
response.status, attempt + 1, API_RETRY_COUNT,
)
if attempt < API_RETRY_COUNT - 1:
await asyncio.sleep(2 ** attempt)
continue
raise HomeAssistantError(
f"Upstream error after retries: status {response.status}"
)
# Other client/server errors — don't retry
truncated_error = str(error_data)[:512]
_LOGGER.error("API error (status %d): %s", response.status, truncated_error)
raise HomeAssistantError(f"API error: status {response.status}")
except asyncio.TimeoutError:
except asyncio.TimeoutError as err:
_LOGGER.warning("Timeout on attempt %d/%d", attempt + 1, API_RETRY_COUNT)
if attempt == API_RETRY_COUNT - 1:
raise HomeAssistantError("API request timed out")
raise HomeAssistantError("API request timed out") from err
await asyncio.sleep(2 ** attempt)
except HomeAssistantError:
raise
@@ -147,6 +173,19 @@ class APIClient:
raise HomeAssistantError("API request failed after all retries")
@staticmethod
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
"""Parse Retry-After header (seconds). Caps at 60s to avoid long stalls."""
if not value:
return None
try:
seconds = float(value.strip())
except (ValueError, AttributeError):
return None
if seconds <= 0:
return None
return min(seconds, 60.0)
async def create(
self,
model: str,
@@ -155,6 +194,7 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using appropriate API."""
try:
@@ -163,26 +203,103 @@ class APIClient:
if self.api_provider == API_PROVIDER_ANTHROPIC:
return await self._create_anthropic_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
elif self.api_provider == API_PROVIDER_DEEPSEEK:
return await self._create_deepseek_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
elif self.api_provider == API_PROVIDER_GEMINI:
return await self._create_gemini_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
else:
return await self._create_openai_completion(
model, messages, temperature, max_tokens,
structured_output, json_schema
structured_output, json_schema, disable_thinking
)
except Exception as e:
_LOGGER.error("API request failed: %s", str(e))
raise HomeAssistantError(f"API request failed: {str(e)}")
raise HomeAssistantError(f"API request failed: {str(e)}") from e
@staticmethod
def _is_openai_reasoning_model(model: str) -> bool:
"""Detect OpenAI reasoning models (o-series and GPT-5 family).
Reasoning models require max_completion_tokens (not max_tokens),
do not accept custom temperature, and use "developer" role instead
of "system". Cutoff: models released 2025-09 and later are all
reasoning-by-default (o3, o4-mini, gpt-5, gpt-5-mini, gpt-5-nano).
"""
if not model:
return False
m = model.lower().lstrip()
# Match bare model names and dated variants (o3-2025-04-16 etc.)
return (
m.startswith(("o1", "o3", "o4-mini", "o4"))
or m.startswith(("gpt-5", "gpt5"))
)
@staticmethod
def _convert_system_to_developer(
messages: List[Dict[str, str]],
) -> List[Dict[str, str]]:
"""Rename role "system" to "developer" for OpenAI reasoning models."""
return [
{**m, "role": "developer"} if m.get("role") == "system" else m
for m in messages
]
@staticmethod
def _apply_no_think_tag(
messages: List[Dict[str, str]],
) -> List[Dict[str, str]]:
"""Append Qwen-style /no_think soft switch to the last user message.
Why: Qwen3 reasoning models treat "/no_think" in the last user turn as a
request to skip thinking. Non-Qwen models ignore the trailing token
harmlessly, so this is safe to apply to all OpenAI-compatible backends.
"""
if not messages:
return messages
patched = [m.copy() for m in messages]
for i in range(len(patched) - 1, -1, -1):
if patched[i].get("role") == "user":
content = patched[i].get("content", "")
if "/no_think" not in content:
patched[i]["content"] = f"{content} /no_think".strip()
break
return patched
@staticmethod
def _strip_think_blocks(text: str) -> str:
"""Remove <think>...</think> reasoning blocks from model output.
Why: Some reasoning models (DeepSeek-R1, Qwen-Thinking) emit chain-of-thought
wrapped in <think> tags even when thinking is nominally disabled. Strip them
so the final answer stays clean. Handles nested blocks via iterative
replacement, and drops dangling opening tags when a response is
truncated mid-block.
"""
if not text or "<think>" not in text:
return text
import re
pattern = re.compile(r"<think>.*?</think>", flags=re.DOTALL)
cleaned = text
# Iterative pass: each iteration peels one layer of nested tags.
# Bounded to 10 iterations to avoid pathological inputs.
for _ in range(10):
new = pattern.sub("", cleaned)
if new == cleaned:
break
cleaned = new
# If a truncated response left a dangling <think> open, drop the rest
# from that marker onward to avoid leaking partial reasoning.
if "<think>" in cleaned:
cleaned = cleaned.split("<think>", 1)[0]
return cleaned.strip()
@staticmethod
def _apply_structured_output(
@@ -216,12 +333,14 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using DeepSeek API."""
url = f"{self.endpoint}/chat/completions"
final_messages = self._apply_no_think_tag(messages) if disable_thinking else messages
payload = {
"model": model,
"messages": messages,
"messages": final_messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
@@ -229,10 +348,13 @@ class APIClient:
self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload)
content = data["choices"][0]["message"]["content"]
if disable_thinking:
content = self._strip_think_blocks(content)
return {
"choices": [
{
"message": {"content": data["choices"][0]["message"]["content"]},
"message": {"content": content},
}
],
"usage": {
@@ -250,22 +372,51 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using OpenAI API."""
"""Create completion using OpenAI API.
Reasoning models (o-series, gpt-5 family) require a different payload
shape: max_completion_tokens instead of max_tokens, no custom
temperature, and role "developer" instead of "system". When
disable_thinking=True for a reasoning model we set reasoning_effort
to "low" to minimize hidden CoT tokens. For classic chat models the
Qwen-style /no_think soft switch is appended instead.
"""
url = f"{self.endpoint}/chat/completions"
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
is_reasoning = self._is_openai_reasoning_model(model)
if is_reasoning:
prepared_messages = self._convert_system_to_developer(messages)
payload: Dict[str, Any] = {
"model": model,
"messages": prepared_messages,
"max_completion_tokens": max_tokens,
}
if disable_thinking:
payload["reasoning_effort"] = "low"
else:
prepared_messages = (
self._apply_no_think_tag(messages) if disable_thinking else messages
)
payload = {
"model": model,
"messages": prepared_messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload)
content = data["choices"][0]["message"]["content"]
# Strip <think> blocks only for classic chat models. Reasoning models
# never emit the tags in user-facing content.
if disable_thinking and not is_reasoning:
content = self._strip_think_blocks(content)
return {
"choices": [
{
"message": {"content": data["choices"][0]["message"]["content"]},
"message": {"content": content},
}
],
"usage": {
@@ -283,6 +434,7 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using Anthropic API."""
url = f"{self.endpoint}/v1/messages"
@@ -298,35 +450,53 @@ class APIClient:
else:
filtered_messages.append(msg)
# For Anthropic, add structured output instruction to system prompt
# For Anthropic, add structured output instruction to system prompt.
# Validate schema is well-formed JSON before concatenation: untrusted
# schema strings (built from templates/webhook data) could otherwise
# break out of the JSON fence and rewrite the system instruction.
if structured_output and json_schema:
schema_instruction = (
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
f"this JSON Schema:\n{json_schema}\n"
f"Do not include any text before or after the JSON. "
f"Do not wrap the JSON in markdown code blocks."
)
if system_prompt:
system_prompt += schema_instruction
import json as _json
try:
_json.loads(json_schema)
except _json.JSONDecodeError as err:
_LOGGER.warning(
"Anthropic: invalid JSON schema, ignoring structured_output: %s", err
)
else:
system_prompt = schema_instruction.strip()
_LOGGER.debug("Anthropic structured output enabled via system prompt")
schema_instruction = (
f"\n\nIMPORTANT: You MUST respond ONLY with valid JSON that matches "
f"this JSON Schema:\n{json_schema}\n"
f"Do not include any text before or after the JSON. "
f"Do not wrap the JSON in markdown code blocks."
)
if system_prompt:
system_prompt += schema_instruction
else:
system_prompt = schema_instruction.strip()
_LOGGER.debug("Anthropic structured output enabled via system prompt")
# Anthropic accepts temperature in [0, 1], not [0, 2] like OpenAI.
# Clip silently to avoid a 400 when a user-set config exceeds the cap.
clipped_temp = min(1.0, max(0.0, float(temperature)))
payload = {
"model": model,
"messages": filtered_messages,
"max_tokens": max_tokens,
"temperature": temperature,
"temperature": clipped_temp,
}
if system_prompt:
payload["system"] = system_prompt
data = await self._make_request(url, payload)
# Anthropic returns text in "content" array; extended thinking arrives
# as a separate thinking-type block (not <think> tags), so no strip
# is required. Extended thinking is opt-in — we simply never request it.
content = data["content"][0]["text"]
return {
"choices": [
{
"message": {"content": data["content"][0]["text"]},
"message": {"content": content},
}
],
"usage": {
@@ -344,6 +514,7 @@ class APIClient:
max_tokens: int,
structured_output: bool = False,
json_schema: Optional[str] = None,
disable_thinking: bool = False,
) -> Dict[str, Any]:
"""Create completion using Gemini API with google-genai library.
@@ -418,6 +589,15 @@ class APIClient:
config.response_mime_type = "application/json"
config.response_schema = parsed_schema
# Disable thinking for Gemini 2.5+ models (ignored by 2.0 and earlier)
if disable_thinking:
try:
config.thinking_config = types.ThinkingConfig(thinking_budget=0)
except (AttributeError, TypeError) as err:
_LOGGER.debug(
"ThinkingConfig not supported by this google-genai version: %s", err
)
return config
config = await asyncio.to_thread(create_config)
@@ -491,6 +671,9 @@ class APIClient:
response_text, usage = await asyncio.to_thread(extract_response)
if disable_thinking:
response_text = self._strip_think_blocks(response_text)
return {
"choices": [{
"message": {