mirror of
https://github.com/smkrv/ha-text-ai.git
synced 2026-07-22 07:03:58 +08:00
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:
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The HA Text AI integration.
|
||||
|
||||
@license: PolyForm Noncommercial 1.0.0 (https://polyformproject.org/licenses/noncommercial/1.0.0)
|
||||
@license: MIT (https://opensource.org/licenses/MIT)
|
||||
@author: SMKRV
|
||||
@github: https://github.com/smkrv/ha-text-ai
|
||||
@source: https://github.com/smkrv/ha-text-ai
|
||||
@@ -51,6 +51,8 @@ from .const import (
|
||||
CONF_MAX_HISTORY_SIZE,
|
||||
CONF_ALLOW_LOCAL_NETWORK,
|
||||
DEFAULT_ALLOW_LOCAL_NETWORK,
|
||||
CONF_DISABLE_THINKING,
|
||||
DEFAULT_DISABLE_THINKING,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -69,6 +71,7 @@ SERVICE_SCHEMA_ASK_QUESTION = vol.Schema({
|
||||
vol.Optional("context_messages"): cv.positive_int,
|
||||
vol.Optional("structured_output", default=False): cv.boolean,
|
||||
vol.Optional("json_schema"): vol.All(cv.string, vol.Length(max=50000)),
|
||||
vol.Optional("disable_thinking"): cv.boolean,
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||
@@ -78,7 +81,8 @@ SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
|
||||
|
||||
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
|
||||
vol.Required("instance"): cv.string,
|
||||
vol.Optional("limit"): cv.positive_int,
|
||||
# Cap limit to protect automations from huge response payloads.
|
||||
vol.Optional("limit", default=10): vol.All(cv.positive_int, vol.Range(min=1, max=100)),
|
||||
vol.Optional("filter_model"): cv.string,
|
||||
vol.Optional("start_date"): cv.string,
|
||||
vol.Optional("include_metadata"): cv.boolean,
|
||||
@@ -124,6 +128,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
context_messages=call.data.get("context_messages"),
|
||||
structured_output=call.data.get("structured_output", False),
|
||||
json_schema=call.data.get("json_schema"),
|
||||
disable_thinking=call.data.get("disable_thinking"),
|
||||
)
|
||||
|
||||
# Return structured response data
|
||||
@@ -162,7 +167,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
await coordinator.async_clear_history()
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error clearing history: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to clear history: {str(err)}")
|
||||
raise HomeAssistantError(f"Failed to clear history: {str(err)}") from err
|
||||
|
||||
async def async_get_history(call: ServiceCall) -> list:
|
||||
"""Handle get_history service."""
|
||||
@@ -177,7 +182,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error getting history: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to get history: {str(err)}")
|
||||
raise HomeAssistantError(f"Failed to get history: {str(err)}") from err
|
||||
|
||||
async def async_set_system_prompt(call: ServiceCall) -> None:
|
||||
"""Handle set_system_prompt service."""
|
||||
@@ -186,7 +191,7 @@ async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool:
|
||||
await coordinator.async_set_system_prompt(call.data["prompt"])
|
||||
except Exception as err:
|
||||
_LOGGER.error("Error setting system prompt: %s", str(err))
|
||||
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}")
|
||||
raise HomeAssistantError(f"Failed to set system prompt: {str(err)}") from err
|
||||
|
||||
# Register services
|
||||
hass.services.async_register(
|
||||
@@ -294,6 +299,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
temperature = config.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE)
|
||||
max_history_size = config.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY)
|
||||
context_messages = config.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES)
|
||||
disable_thinking = config.get(CONF_DISABLE_THINKING, DEFAULT_DISABLE_THINKING)
|
||||
|
||||
headers = build_auth_headers(api_provider, api_key)
|
||||
|
||||
@@ -324,6 +330,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
max_history_size=max_history_size,
|
||||
context_messages=context_messages,
|
||||
api_timeout=api_timeout,
|
||||
disable_thinking=disable_thinking,
|
||||
)
|
||||
|
||||
# Initialize coordinator (directories, history, metrics)
|
||||
@@ -369,8 +376,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await coordinator.async_shutdown()
|
||||
|
||||
# When removing the last config entry, also unregister services and
|
||||
# clear the domain bucket so HA doesn't show stale services in the UI.
|
||||
if not hass.data.get(DOMAIN):
|
||||
hass.data.pop(DOMAIN, None)
|
||||
for service in (
|
||||
SERVICE_ASK_QUESTION,
|
||||
SERVICE_CLEAR_HISTORY,
|
||||
SERVICE_GET_HISTORY,
|
||||
SERVICE_SET_SYSTEM_PROMPT,
|
||||
):
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
Reference in New Issue
Block a user