diff --git a/README.md b/README.md index c460eac..8c949de 100644 --- a/README.md +++ b/README.md @@ -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.
🌐 Potentially Compatible Providers @@ -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) diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 5782a57..1d61eb0 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -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: diff --git a/custom_components/ha_text_ai/api_client.py b/custom_components/ha_text_ai/api_client.py index dbc5737..88c74a8 100644 --- a/custom_components/ha_text_ai/api_client.py +++ b/custom_components/ha_text_ai/api_client.py @@ -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() diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 7eca6ca..077545a 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -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)) diff --git a/custom_components/ha_text_ai/const.py b/custom_components/ha_text_ai/const.py index f3ab014..7312f73 100644 --- a/custom_components/ha_text_ai/const.py +++ b/custom_components/ha_text_ai/const.py @@ -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 diff --git a/custom_components/ha_text_ai/translations/de.json b/custom_components/ha_text_ai/translations/de.json index a63e273..039cc72 100644 --- a/custom_components/ha_text_ai/translations/de.json +++ b/custom_components/ha_text_ai/translations/de.json @@ -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" } } } diff --git a/custom_components/ha_text_ai/translations/es.json b/custom_components/ha_text_ai/translations/es.json index f813987..d905d96 100644 --- a/custom_components/ha_text_ai/translations/es.json +++ b/custom_components/ha_text_ai/translations/es.json @@ -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" } } } diff --git a/custom_components/ha_text_ai/translations/hi.json b/custom_components/ha_text_ai/translations/hi.json index ecefc56..393d996 100644 --- a/custom_components/ha_text_ai/translations/hi.json +++ b/custom_components/ha_text_ai/translations/hi.json @@ -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": "वार्तालाप इतिहास" } } } } } -} \ No newline at end of file +} diff --git a/custom_components/ha_text_ai/translations/it.json b/custom_components/ha_text_ai/translations/it.json index 7791c07..732cfe8 100644 --- a/custom_components/ha_text_ai/translations/it.json +++ b/custom_components/ha_text_ai/translations/it.json @@ -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" } } } diff --git a/custom_components/ha_text_ai/translations/sr.json b/custom_components/ha_text_ai/translations/sr.json index 2a6e4bd..19b641f 100644 --- a/custom_components/ha_text_ai/translations/sr.json +++ b/custom_components/ha_text_ai/translations/sr.json @@ -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": "Историја разговора" } } } } } -} \ No newline at end of file +} diff --git a/custom_components/ha_text_ai/translations/zh.json b/custom_components/ha_text_ai/translations/zh.json index 78e2566..df2709c 100644 --- a/custom_components/ha_text_ai/translations/zh.json +++ b/custom_components/ha_text_ai/translations/zh.json @@ -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": "对话历史" } } } } } -} \ No newline at end of file +} diff --git a/custom_components/ha_text_ai/utils.py b/custom_components/ha_text_ai/utils.py index 1965aec..887e4e2 100644 --- a/custom_components/ha_text_ai/utils.py +++ b/custom_components/ha_text_ai/utils.py @@ -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(