fix: Pinned session TypeError, dead default models, get_history compat, i18n sync

- create_pinned_session built directly on aiohttp: the HA helper injects
  its own connector and raised TypeError on every setup and config flow
- session lifecycle: closed in APIClient.shutdown, on failed setup, and
  in config flow validators; allow_redirects=False everywhere so 3xx
  cannot route past the pinned resolver
- defaults: gemini-2.0-flash (shut down 2026-06-01) -> gemini-3.5-flash,
  deepseek-chat (retires 2026-07-24) -> deepseek-v4-flash
- DeepSeek V4 disable_thinking via thinking request parameter
- Gemini 3.x disable_thinking via thinking_level (MINIMAL, LOW for Pro)
- get_history limit: no forced default/max in schema, storage layer clamps
- translations: 6 locales caught up with strings.json, 2 orphan keys dropped
- README: manual install path matches zip layout, model sections updated
This commit is contained in:
SMKRV
2026-07-07 01:23:01 +03:00
parent 9d4d2d5cd1
commit bcfe69b5f7
12 changed files with 209 additions and 68 deletions
+10 -6
View File
@@ -142,12 +142,16 @@ Transform your smart home experience with powerful AI assistance powered by mult
- **Claude Haiku 4.5** - The fastest and most economical option in the series
#### DeepSeek Models
- **DeepSeek-V3** - A general-purpose model for a wide range of tasks
- **DeepSeek-R1** - A specialized model focused on reasoning and coding
- **deepseek-v4-flash** - A fast general-purpose model for a wide range of tasks (default)
- **deepseek-v4-pro** - A more capable model for reasoning and coding
> The legacy model names `deepseek-chat` and `deepseek-reasoner` stop working on 2026-07-24. If your instance still uses one of them, switch the model in the integration options.
#### Google Gemini Models
- **Gemini 3.1 Pro** - The newest and most advanced model available
- **Gemini 3.1 Flash Lite** - Fastest and most cost-efficient model for high-volume workloads
- **gemini-3.5-flash** - Fast and cost-efficient, suitable for most tasks (default)
- **Gemini 3.1 Pro** - The most advanced Gemini model available
> Google shut down `gemini-2.0-flash` on 2026-06-01 and retires the 2.5 family on 2026-10-16. If your instance uses one of those, switch the model in the integration options.
<details>
<summary>🌐 Potentially Compatible Providers</summary>
@@ -199,8 +203,8 @@ If the integration is not found in the default repository:
5. Click "Download"
### Manual Installation
1. Download the latest release
2. Extract and copy `custom_components/ha_text_ai` to your `custom_components` directory
1. Download `ha_text_ai.zip` from the latest release
2. Extract the archive and copy the `ha_text_ai` folder into your `custom_components` directory
3. Restart Home Assistant
4. Add configuration via UI (Settings → Devices & Services → Add Integration)
+14 -4
View File
@@ -80,8 +80,11 @@ SERVICE_SCHEMA_SET_SYSTEM_PROMPT = vol.Schema({
SERVICE_SCHEMA_GET_HISTORY = vol.Schema({
vol.Required("instance"): cv.string,
# 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)),
# No default and no schema max: omitting limit returns the full history
# (pre-2.5.0 behavior) and oversized values are clamped to
# ABSOLUTE_MAX_HISTORY_SIZE in history.async_get_history instead of
# failing the whole service call.
vol.Optional("limit"): vol.All(cv.positive_int, vol.Range(min=1)),
vol.Optional("filter_model"): cv.string,
vol.Optional("start_date"): cv.string,
vol.Optional("include_metadata"): cv.boolean,
@@ -244,7 +247,9 @@ async def async_check_api(session, endpoint: str, headers: dict, provider: str,
check_url = f"{endpoint}{check_path}"
async with asyncio.timeout(api_timeout):
async with session.get(check_url, headers=headers) as response:
async with session.get(
check_url, headers=headers, allow_redirects=False
) as response:
if response.status == 200:
return True
elif response.status == 401:
@@ -264,6 +269,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HA Text AI from a config entry."""
_LOGGER.debug("Setting up HA Text AI entry: %s", safe_log_data(dict(entry.data)))
session = None
try:
# Get provider from data or options (options takes precedence)
config = {**entry.data, **entry.options}
@@ -292,7 +298,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 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)
# The integration owns this session: APIClient.shutdown() closes it
# on unload, the except handler below closes it on failed setup.
session = create_pinned_session(endpoint, resolved_ips)
# API key can now be updated via options
api_key = config.get(CONF_API_KEY, entry.data.get(CONF_API_KEY))
instance_name = entry.data.get(CONF_NAME, entry.entry_id)
@@ -359,6 +367,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except Exception as err:
_LOGGER.exception("Error setting up HA Text AI: %s", err)
if session is not None and not session.closed:
await session.close()
raise
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
+36 -10
View File
@@ -113,6 +113,9 @@ class APIClient:
json=payload,
headers=self.headers,
timeout=self.timeout,
# The session pins DNS to validated IPs; following a
# redirect would resolve a new host past that pin.
allow_redirects=False,
) as response:
_LOGGER.debug("Response status: %s", response.status)
if response.status == 200:
@@ -358,12 +361,18 @@ class APIClient:
separate field alongside content. We skip the no_think append for
this model and preserve reasoning_content in the response payload
so it's available for logging/debug.
DeepSeek V4+ (deepseek-v4-flash/-pro) selects thinking mode via a
top-level "thinking" request parameter instead of the model name,
so /no_think does not apply there.
"""
url = f"{self.endpoint}/chat/completions"
is_reasoner = "reasoner" in model.lower()
m_lower = model.lower()
is_reasoner = "reasoner" in m_lower
is_v4plus = re.search(r"deepseek-v[4-9]", m_lower) is not None
final_messages = (
self._apply_no_think_tag(messages)
if (disable_thinking and not is_reasoner)
if (disable_thinking and not is_reasoner and not is_v4plus)
else messages
)
payload = {
@@ -373,6 +382,8 @@ class APIClient:
"max_tokens": max_tokens,
"stream": False,
}
if disable_thinking and is_v4plus:
payload["thinking"] = {"type": "disabled"}
self._apply_structured_output(payload, structured_output, json_schema)
data = await self._make_request(url, payload)
@@ -627,15 +638,26 @@ class APIClient:
config.response_mime_type = "application/json"
config.response_schema = parsed_schema
# 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.
# Disable thinking. Gemini 3.x+ replaced the numeric
# thinking_budget with a semantic thinking_level; Pro
# variants do not accept MINIMAL, their floor is LOW.
# Gemini 2.5: Flash accepts thinking_budget=0 (fully off),
# Pro rejects 0 and requires at least 128 tokens.
# 2.0 and earlier ignore the field.
if disable_thinking:
m_lower = model.lower()
budget = 128 if "2.5-pro" in m_lower else 0
try:
config.thinking_config = types.ThinkingConfig(thinking_budget=budget)
except (AttributeError, TypeError) as err:
if re.search(r"gemini-[3-9]", m_lower):
level = "LOW" if "pro" in m_lower else "MINIMAL"
config.thinking_config = types.ThinkingConfig(
thinking_level=level
)
else:
budget = 128 if "2.5-pro" in m_lower else 0
config.thinking_config = types.ThinkingConfig(
thinking_budget=budget
)
except (AttributeError, TypeError, ValueError) as err:
_LOGGER.debug(
"ThinkingConfig not supported by this google-genai version: %s", err
)
@@ -735,7 +757,11 @@ class APIClient:
raise HomeAssistantError(f"Gemini API request failed: {e}") from e
async def shutdown(self) -> None:
"""Shutdown API client."""
"""Shutdown API client and close its dedicated session."""
_LOGGER.debug("Shutting down API client")
self._closed = True
# Do NOT close the shared Home Assistant session
# The session is dedicated to this config entry (pinned resolver,
# isolated cookie jar), so it must be closed here to release the
# connector; nothing else owns it.
if self.session is not None and not self.session.closed:
await self.session.close()
+30 -20
View File
@@ -255,23 +255,28 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return False
return True
# 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])
from .providers import get_provider_config
check_path = get_provider_config(self._provider).get("check_path", "/models")
check_url = f"{endpoint}{check_path}"
async with session.get(check_url, headers=headers) as response:
if response.status == 401:
self._errors["base"] = "invalid_auth"
return False
elif response.status != 200:
self._errors["base"] = "cannot_connect"
return False
return True
# 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(endpoint, resolved_ips)
try:
async with session.get(
check_url, headers=headers, allow_redirects=False
) as response:
if response.status == 401:
self._errors["base"] = "invalid_auth"
return False
elif response.status != 200:
self._errors["base"] = "cannot_connect"
return False
return True
finally:
await session.close()
except Exception as err:
_LOGGER.error("API validation error: %s", str(err))
@@ -340,21 +345,26 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if provider == API_PROVIDER_GEMINI:
return True
session = create_pinned_session(self.hass, endpoint, resolved_ips)
headers = build_auth_headers(provider, api_key)
from .providers import get_provider_config
check_path = get_provider_config(provider).get("check_path", "/models")
check_url = f"{endpoint}{check_path}"
async with session.get(check_url, headers=headers) as response:
if response.status == 401:
self._errors["base"] = "invalid_auth"
return False
elif response.status != 200:
self._errors["base"] = "cannot_connect"
return False
return True
session = create_pinned_session(endpoint, resolved_ips)
try:
async with session.get(
check_url, headers=headers, allow_redirects=False
) as response:
if response.status == 401:
self._errors["base"] = "invalid_auth"
return False
elif response.status != 200:
self._errors["base"] = "cannot_connect"
return False
return True
finally:
await session.close()
except Exception as err:
_LOGGER.error("API validation error: %s", str(err))
+5 -2
View File
@@ -58,8 +58,11 @@ MAX_HISTORY_FILE_SIZE = 1 * 1024 * 1024
# Default values
DEFAULT_MODEL: Final = "gpt-4o-mini"
DEFAULT_ANTHROPIC_MODEL: Final = "claude-sonnet-4-6"
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-chat"
DEFAULT_GEMINI_MODEL: Final = "gemini-2.0-flash"
# deepseek-chat/deepseek-reasoner are discontinued 2026-07-24; V4 models
# select thinking mode via a request parameter instead of the model name.
DEFAULT_DEEPSEEK_MODEL: Final = "deepseek-v4-flash"
# gemini-2.0-flash was shut down 2026-06-01; 2.5-flash follows 2026-10-16.
DEFAULT_GEMINI_MODEL: Final = "gemini-3.5-flash"
DEFAULT_TEMPERATURE: Final = 0.1
DEFAULT_MAX_TOKENS: Final = 1000
DEFAULT_REQUEST_INTERVAL: Final = 1.0
@@ -46,6 +46,7 @@
"name_exists": "Eine Instanz mit diesem Namen existiert bereits",
"invalid_name": "Ungültiger Instanzname",
"invalid_auth": "Authentifizierung fehlgeschlagen - überprüfen Sie Ihren API-Schlüssel",
"api_key_required": "API-Schlüssel ist erforderlich, wenn Anbieter oder Endpunkt geändert wird",
"invalid_api_key": "Ungültiger API-Schlüssel - bitte überprüfen Sie Ihre Anmeldeinformationen",
"cannot_connect": "Verbindung zum API-Dienst fehlgeschlagen",
"invalid_model": "Ausgewähltes Modell ist nicht verfügbar",
@@ -59,7 +60,6 @@
"invalid_instance": "Ungültige Instanz angegeben",
"unknown": "Unerwarteter Fehler aufgetreten",
"empty": "Name darf nicht leer sein",
"invalid_characters": "Name darf nur Buchstaben, Zahlen, Leerzeichen, Unterstriche und Bindestriche enthalten",
"name_too_long": "Name darf höchstens 50 Zeichen lang sein"
},
"abort": {
@@ -218,8 +218,7 @@
"rate_limited": "Rate limitiert",
"maintenance": "Wartung",
"initializing": "Initialisierung",
"retrying": "Wiederholen",
"queued": "In der Warteschlange"
"retrying": "Wiederholen"
},
"state_attributes": {
"question": {
@@ -311,6 +310,21 @@
},
"min_latency": {
"name": "Minimale Latenz"
},
"last_model": {
"name": "Zuletzt verwendetes Modell"
},
"last_timestamp": {
"name": "Zeitpunkt der letzten Antwort"
},
"instance_name": {
"name": "Instanzname"
},
"normalized_name": {
"name": "Normalisierter Name"
},
"conversation_history": {
"name": "Konversationsverlauf"
}
}
}
@@ -46,6 +46,7 @@
"name_exists": "Ya existe una instancia con este nombre",
"invalid_name": "Nombre de instancia no válido",
"invalid_auth": "La autenticación falló - verifica tu clave API",
"api_key_required": "Se requiere la clave API al cambiar de proveedor o endpoint",
"invalid_api_key": "Clave API no válida - verifica tus credenciales",
"cannot_connect": "Error al conectar con el servicio de API",
"invalid_model": "El modelo seleccionado no está disponible",
@@ -59,7 +60,6 @@
"invalid_instance": "Instancia no válida especificada",
"unknown": "Ocurrió un error inesperado",
"empty": "El nombre no puede estar vacío",
"invalid_characters": "El nombre solo puede contener letras, números, espacios, guiones bajos y guiones",
"name_too_long": "El nombre debe tener 50 caracteres o menos"
},
"abort": {
@@ -218,8 +218,7 @@
"rate_limited": "Limitado por tasa",
"maintenance": "Mantenimiento",
"initializing": "Inicializando",
"retrying": "Reintentando",
"queued": "En cola"
"retrying": "Reintentando"
},
"state_attributes": {
"question": {
@@ -311,6 +310,21 @@
},
"min_latency": {
"name": "Latencia Mínima"
},
"last_model": {
"name": "Último modelo utilizado"
},
"last_timestamp": {
"name": "Hora de la última respuesta"
},
"instance_name": {
"name": "Nombre de instancia"
},
"normalized_name": {
"name": "Nombre normalizado"
},
"conversation_history": {
"name": "Historial de conversación"
}
}
}
@@ -46,6 +46,7 @@
"name_exists": "इस नाम के साथ एक उदाहरण पहले से मौजूद है",
"invalid_name": "अमान्य उदाहरण नाम",
"invalid_auth": "प्रमाणीकरण विफल - अपनी एपीआई कुंजी की जांच करें",
"api_key_required": "प्रदाता या endpoint बदलते समय API कुंजी आवश्यक है",
"invalid_api_key": "अमान्य एपीआई कुंजी - कृपया अपनी क्रेडेंशियल्स की पुष्टि करें",
"cannot_connect": "एपीआई सेवा से कनेक्ट करने में विफल",
"invalid_model": "चुना हुआ मॉडल उपलब्ध नहीं है",
@@ -59,7 +60,6 @@
"invalid_instance": "अमान्य उदाहरण निर्दिष्ट किया गया",
"unknown": "अप्रत्याशित त्रुटि हुई",
"empty": "नाम खाली नहीं हो सकता",
"invalid_characters": "नाम में केवल अक्षर, अंक, रिक्त स्थान, अंडरस्कोर और हाइफन हो सकते हैं",
"name_too_long": "नाम 50 अक्षरों या उससे कम होना चाहिए"
},
"abort": {
@@ -218,8 +218,7 @@
"rate_limited": "रेट सीमित",
"maintenance": "रखरखाव",
"initializing": "प्रारंभिककरण",
"retrying": "पुनः प्रयास कर रहा है",
"queued": "क्यू में"
"retrying": "पुनः प्रयास कर रहा है"
},
"state_attributes": {
"question": {
@@ -311,9 +310,24 @@
},
"min_latency": {
"name": "न्यूनतम विलंबता"
},
"last_model": {
"name": "अंतिम उपयोग किया गया मॉडल"
},
"last_timestamp": {
"name": "अंतिम प्रतिक्रिया समय"
},
"instance_name": {
"name": "इंस्टेंस नाम"
},
"normalized_name": {
"name": "सामान्यीकृत नाम"
},
"conversation_history": {
"name": "वार्तालाप इतिहास"
}
}
}
}
}
}
}
@@ -46,6 +46,7 @@
"name_exists": "Esiste già un'istanza con questo nome",
"invalid_name": "Nome dell'istanza non valido",
"invalid_auth": "Autenticazione fallita - controlla la tua chiave API",
"api_key_required": "La chiave API è obbligatoria quando si cambia provider o endpoint",
"invalid_api_key": "Chiave API non valida - verifica le tue credenziali",
"cannot_connect": "Impossibile connettersi al servizio API",
"invalid_model": "Il modello selezionato non è disponibile",
@@ -59,7 +60,6 @@
"invalid_instance": "Istanze specificata non valida",
"unknown": "Si è verificato un errore imprevisto",
"empty": "Il nome non può essere vuoto",
"invalid_characters": "Il nome può contenere solo lettere, numeri, spazi, trattini bassi e trattini",
"name_too_long": "Il nome deve essere lungo 50 caratteri o meno"
},
"abort": {
@@ -218,8 +218,7 @@
"rate_limited": "Limite di frequenza",
"maintenance": "Manutenzione",
"initializing": "Inizializzazione",
"retrying": "Riprova",
"queued": "In coda"
"retrying": "Riprova"
},
"state_attributes": {
"question": {
@@ -311,6 +310,21 @@
},
"min_latency": {
"name": "Latenza minima"
},
"last_model": {
"name": "Ultimo modello utilizzato"
},
"last_timestamp": {
"name": "Ora dell'ultima risposta"
},
"instance_name": {
"name": "Nome istanza"
},
"normalized_name": {
"name": "Nome normalizzato"
},
"conversation_history": {
"name": "Cronologia conversazione"
}
}
}
@@ -46,6 +46,7 @@
"name_exists": "Инстанца са овим именом већ постоји",
"invalid_name": "Неважеће име инстанце",
"invalid_auth": "Аутентификација није успела - проверите ваш API кључ",
"api_key_required": "API кључ је обавезан при промени провајдера или endpoint-а",
"invalid_api_key": "Неважећи API кључ - молимо проверите ваше акредитиве",
"cannot_connect": "Неуспело повезивање са API сервисом",
"invalid_model": "Изабрани модел није доступан",
@@ -59,7 +60,6 @@
"invalid_instance": "Неважећа инстанца је назначена",
"unknown": "Дошло је до неочекиване грешке",
"empty": "Име не може бити празно",
"invalid_characters": "Име може садржавати само слова, цифре, размаке, подцрта и цртице",
"name_too_long": "Име мора бити 50 знакова или мање"
},
"abort": {
@@ -218,8 +218,7 @@
"rate_limited": "Ограничење захтева",
"maintenance": "Одржавање",
"initializing": "Инициализује се",
"retrying": "Покушава поново",
"queued": "У реду"
"retrying": "Покушава поново"
},
"state_attributes": {
"question": {
@@ -311,9 +310,24 @@
},
"min_latency": {
"name": "Минимална латенција"
},
"last_model": {
"name": "Последњи коришћени модел"
},
"last_timestamp": {
"name": "Време последњег одговора"
},
"instance_name": {
"name": "Назив инстанце"
},
"normalized_name": {
"name": "Нормализовани назив"
},
"conversation_history": {
"name": "Историја разговора"
}
}
}
}
}
}
}
@@ -46,6 +46,7 @@
"name_exists": "具有此名称的实例已存在",
"invalid_name": "无效的实例名称",
"invalid_auth": "身份验证失败 - 检查您的API密钥",
"api_key_required": "更改提供商或端点时需要输入 API 密钥",
"invalid_api_key": "无效的API密钥 - 请验证您的凭据",
"cannot_connect": "无法连接到API服务",
"invalid_model": "所选模型不可用",
@@ -59,7 +60,6 @@
"invalid_instance": "指定的实例无效",
"unknown": "发生意外错误",
"empty": "名称不能为空",
"invalid_characters": "名称只能包含字母、数字、空格、下划线和连字符",
"name_too_long": "名称必须少于50个字符"
},
"abort": {
@@ -218,8 +218,7 @@
"rate_limited": "速率限制",
"maintenance": "维护中",
"initializing": "初始化中",
"retrying": "重试中",
"queued": "排队中"
"retrying": "重试中"
},
"state_attributes": {
"question": {
@@ -311,9 +310,24 @@
},
"min_latency": {
"name": "最小延迟"
},
"last_model": {
"name": "最近使用的模型"
},
"last_timestamp": {
"name": "最近响应时间"
},
"instance_name": {
"name": "实例名称"
},
"normalized_name": {
"name": "规范化名称"
},
"conversation_history": {
"name": "对话历史"
}
}
}
}
}
}
}
+9 -5
View File
@@ -21,7 +21,6 @@ from aiohttp.resolver import DefaultResolver
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
_LOGGER = logging.getLogger(__name__)
@@ -150,7 +149,6 @@ async def resolve_hostname_ips(
return ips
def create_pinned_session(
hass: HomeAssistant,
endpoint: str,
resolved_ips: list[str],
) -> aiohttp.ClientSession:
@@ -161,6 +159,13 @@ def create_pinned_session(
rather than re-resolving the hostname on each request.
- Cookie pollution: DummyCookieJar prevents cookies from leaking between
this integration and other HA components sharing the same domain.
Built directly on aiohttp: HA's async_create_clientsession always
injects its own pooled connector and rejects a caller-supplied one,
so a custom resolver cannot go through the helper. The caller owns
the session and must close it. Requests must pass
allow_redirects=False so a 3xx response cannot route past the pinned
resolver to an unvalidated host.
"""
parsed = urlparse(endpoint)
hostname = (parsed.hostname or "").lower()
@@ -169,10 +174,9 @@ def create_pinned_session(
hostname: [(ip, port) for ip in resolved_ips]
}
connector = aiohttp.TCPConnector(resolver=_PinnedResolver(pinned))
return async_create_clientsession(
hass,
cookie_jar=aiohttp.DummyCookieJar(),
return aiohttp.ClientSession(
connector=connector,
cookie_jar=aiohttp.DummyCookieJar(),
)
async def validate_endpoint(