From 43cbac2d04831dcc29f70fe6030ab8517d6d9a9a Mon Sep 17 00:00:00 2001 From: SMKRV Date: Tue, 30 Dec 2025 17:22:48 +0300 Subject: [PATCH] feat: Add ability to edit provider, API key, and endpoint in existing integrations - Extended OptionsFlowHandler with two-step configuration flow - Step 1: Select provider (OpenAI, Anthropic, DeepSeek, Gemini) - Step 2: Configure API key, endpoint, model, and other settings - Auto-reload integration on options change - When switching providers, show appropriate default endpoint and model - Updated translations for all 8 languages --- custom_components/ha_text_ai/__init__.py | 45 ++- custom_components/ha_text_ai/config_flow.py | 265 ++++++++++++++---- .../ha_text_ai/translations/de.json | 15 +- .../ha_text_ai/translations/en.json | 13 +- .../ha_text_ai/translations/es.json | 13 +- .../ha_text_ai/translations/hi.json | 13 +- .../ha_text_ai/translations/it.json | 13 +- .../ha_text_ai/translations/ru.json | 13 +- .../ha_text_ai/translations/sr.json | 13 +- .../ha_text_ai/translations/zh.json | 13 +- 10 files changed, 330 insertions(+), 86 deletions(-) diff --git a/custom_components/ha_text_ai/__init__.py b/custom_components/ha_text_ai/__init__.py index 372b93e..179b843 100644 --- a/custom_components/ha_text_ai/__init__.py +++ b/custom_components/ha_text_ai/__init__.py @@ -312,21 +312,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug(f"Setting up HA Text AI entry: {entry.data}") try: - if CONF_API_PROVIDER not in entry.data: + # Get provider from data or options (options takes precedence) + config = {**entry.data, **entry.options} + api_provider = config.get(CONF_API_PROVIDER) + + if not api_provider: _LOGGER.error("API provider not specified") raise ConfigEntryNotReady("API provider is required") - # Get configuration (merge data with options to apply any runtime changes) - config = {**entry.data, **entry.options} session = aiohttp_client.async_get_clientsession(hass) - api_provider = config.get(CONF_API_PROVIDER) - model = config.get(CONF_MODEL, DEFAULT_MODEL) - endpoint = config.get( - CONF_API_ENDPOINT, - DEFAULT_OPENAI_ENDPOINT if api_provider == API_PROVIDER_OPENAI - else DEFAULT_ANTHROPIC_ENDPOINT - ).rstrip('/') - api_key = entry.data[CONF_API_KEY] # API key stays in data, not in options + + # Get default endpoint based on provider + default_endpoint = { + API_PROVIDER_OPENAI: DEFAULT_OPENAI_ENDPOINT, + API_PROVIDER_ANTHROPIC: DEFAULT_ANTHROPIC_ENDPOINT, + API_PROVIDER_DEEPSEEK: DEFAULT_DEEPSEEK_ENDPOINT, + API_PROVIDER_GEMINI: DEFAULT_GEMINI_ENDPOINT, + }.get(api_provider, DEFAULT_OPENAI_ENDPOINT) + + # Get default model based on provider + default_model = ( + DEFAULT_DEEPSEEK_MODEL if api_provider == API_PROVIDER_DEEPSEEK else + DEFAULT_GEMINI_MODEL if api_provider == API_PROVIDER_GEMINI else + DEFAULT_MODEL + ) + + model = config.get(CONF_MODEL, default_model) + endpoint = config.get(CONF_API_ENDPOINT, default_endpoint).rstrip('/') + # 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) request_interval = config.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL) api_timeout = config.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT) @@ -386,6 +400,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Set up platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Register update listener for options changes + entry.async_on_unload(entry.add_update_listener(async_update_options)) + _LOGGER.debug(f"Setup completed for {instance_name}") return True @@ -394,6 +411,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.exception(f"Error setting up HA Text AI: {err}") raise +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update - reload the config entry.""" + _LOGGER.info("Options updated for %s, reloading integration", entry.title) + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" try: diff --git a/custom_components/ha_text_ai/config_flow.py b/custom_components/ha_text_ai/config_flow.py index 2029b01..fe6b00a 100644 --- a/custom_components/ha_text_ai/config_flow.py +++ b/custom_components/ha_text_ai/config_flow.py @@ -479,74 +479,223 @@ class HATextAIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow.""" - async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) + def __init__(self) -> None: + """Initialize options flow.""" + self._errors = {} + self._selected_provider = None - current_data = {**self.config_entry.data, **self.config_entry.options} - provider = current_data.get(CONF_API_PROVIDER) + def _get_default_endpoint(self, provider: str) -> str: + """Get default endpoint for provider.""" + return { + API_PROVIDER_OPENAI: DEFAULT_OPENAI_ENDPOINT, + API_PROVIDER_ANTHROPIC: DEFAULT_ANTHROPIC_ENDPOINT, + API_PROVIDER_DEEPSEEK: DEFAULT_DEEPSEEK_ENDPOINT, + API_PROVIDER_GEMINI: DEFAULT_GEMINI_ENDPOINT, + }.get(provider, DEFAULT_OPENAI_ENDPOINT) - default_model = ( + def _get_default_model(self, provider: str) -> str: + """Get default model for provider.""" + return ( DEFAULT_DEEPSEEK_MODEL if provider == API_PROVIDER_DEEPSEEK else DEFAULT_GEMINI_MODEL if provider == API_PROVIDER_GEMINI else DEFAULT_MODEL ) + def _get_api_headers(self, api_key: str, provider: str) -> Dict[str, str]: + """Get API headers based on provider.""" + if provider == API_PROVIDER_ANTHROPIC: + return { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json" + } + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + async def _async_validate_api(self, provider: str, api_key: str, endpoint: str) -> bool: + """Validate API connection.""" + try: + if not api_key: + self._errors["base"] = "invalid_auth" + return False + + # For Gemini, just check if API key is present + if provider == API_PROVIDER_GEMINI: + return True + + session = async_get_clientsession(self.hass) + headers = self._get_api_headers(api_key, provider) + endpoint = endpoint.rstrip('/') + + check_url = ( + f"{endpoint}/v1/models" if provider == API_PROVIDER_ANTHROPIC + else f"{endpoint}/models" + ) + + async with session.get(check_url, headers=headers) as response: + if response.status == 401: + self._errors["base"] = "invalid_auth" + return False + elif response.status not in [200, 404]: + self._errors["base"] = "cannot_connect" + return False + return True + + except Exception as err: + _LOGGER.error("API validation error: %s", str(err)) + self._errors["base"] = "cannot_connect" + return False + + async def async_step_init(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult: + """Handle provider selection step.""" + current_data = {**self.config_entry.data, **self.config_entry.options} + current_provider = current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI) + + if user_input is not None: + self._selected_provider = user_input.get(CONF_API_PROVIDER, current_provider) + return await self.async_step_settings() + return self.async_show_form( step_id="init", data_schema=vol.Schema({ - vol.Optional( - CONF_MODEL, - default=current_data.get(CONF_MODEL, default_model) - ): str, - vol.Optional( - CONF_TEMPERATURE, - default=current_data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) - ): vol.All( - vol.Coerce(float), - vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) - ), - vol.Optional( - CONF_MAX_TOKENS, - default=current_data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) - ): vol.All( - vol.Coerce(int), - vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) - ), - vol.Optional( - CONF_REQUEST_INTERVAL, - default=current_data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL) - ): vol.All( - vol.Coerce(float), - vol.Range(min=MIN_REQUEST_INTERVAL) - ), - vol.Optional( - CONF_API_TIMEOUT, - default=current_data.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT) - ): vol.All( - vol.Coerce(int), - vol.Range(min=MIN_API_TIMEOUT, max=MAX_API_TIMEOUT) - ), - vol.Optional( - CONF_CONTEXT_MESSAGES, - default=current_data.get( - CONF_CONTEXT_MESSAGES, - DEFAULT_CONTEXT_MESSAGES + vol.Required( + CONF_API_PROVIDER, + default=current_provider + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=API_PROVIDERS, + translation_key="api_provider" ) - ): vol.All( - vol.Coerce(int), - vol.Range(min=1, max=20) ), - vol.Optional( - CONF_MAX_HISTORY_SIZE, - default=current_data.get( - CONF_MAX_HISTORY_SIZE, - DEFAULT_MAX_HISTORY - ) - ): vol.All( - vol.Coerce(int), - vol.Range(min=1, max=100) - ), - }) + }), + description_placeholders={ + "current_provider": current_provider + } ) + + async def async_step_settings(self, user_input: Optional[Dict[str, Any]] = None) -> FlowResult: + """Handle settings configuration step.""" + self._errors = {} + current_data = {**self.config_entry.data, **self.config_entry.options} + provider = self._selected_provider or current_data.get(CONF_API_PROVIDER, API_PROVIDER_OPENAI) + + # Determine if provider changed to show appropriate defaults + provider_changed = provider != current_data.get(CONF_API_PROVIDER) + + # Use new defaults if provider changed, otherwise use current values + if provider_changed: + default_endpoint = self._get_default_endpoint(provider) + default_model = self._get_default_model(provider) + else: + default_endpoint = current_data.get(CONF_API_ENDPOINT, self._get_default_endpoint(provider)) + default_model = current_data.get(CONF_MODEL, self._get_default_model(provider)) + + if user_input is not None: + # Validate API connection + api_key = user_input.get(CONF_API_KEY, current_data.get(CONF_API_KEY, "")) + endpoint = user_input.get(CONF_API_ENDPOINT, default_endpoint) + + if await self._async_validate_api(provider, api_key, endpoint): + # Merge with provider selection + final_data = { + CONF_API_PROVIDER: provider, + **user_input + } + return self.async_create_entry(title="", data=final_data) + + # Show form again with errors + return self.async_show_form( + step_id="settings", + data_schema=self._get_settings_schema( + provider=provider, + current_data=current_data, + user_input=user_input, + default_endpoint=default_endpoint, + default_model=default_model, + ), + errors=self._errors + ) + + return self.async_show_form( + step_id="settings", + data_schema=self._get_settings_schema( + provider=provider, + current_data=current_data, + user_input=None, + default_endpoint=default_endpoint, + default_model=default_model, + ), + description_placeholders={ + "provider": provider + } + ) + + def _get_settings_schema( + self, + provider: str, + current_data: Dict[str, Any], + user_input: Optional[Dict[str, Any]], + default_endpoint: str, + default_model: str, + ) -> vol.Schema: + """Build settings schema.""" + data = user_input or current_data + + return vol.Schema({ + vol.Required( + CONF_API_KEY, + default=data.get(CONF_API_KEY, "") + ): str, + vol.Required( + CONF_API_ENDPOINT, + default=data.get(CONF_API_ENDPOINT, default_endpoint) + ): str, + vol.Required( + CONF_MODEL, + default=data.get(CONF_MODEL, default_model) + ): str, + vol.Optional( + CONF_TEMPERATURE, + default=data.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) + ): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE) + ), + vol.Optional( + CONF_MAX_TOKENS, + default=data.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_MAX_TOKENS, max=MAX_MAX_TOKENS) + ), + vol.Optional( + CONF_REQUEST_INTERVAL, + default=data.get(CONF_REQUEST_INTERVAL, DEFAULT_REQUEST_INTERVAL) + ): vol.All( + vol.Coerce(float), + vol.Range(min=MIN_REQUEST_INTERVAL) + ), + vol.Optional( + CONF_API_TIMEOUT, + default=data.get(CONF_API_TIMEOUT, DEFAULT_API_TIMEOUT) + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_API_TIMEOUT, max=MAX_API_TIMEOUT) + ), + vol.Optional( + CONF_CONTEXT_MESSAGES, + default=data.get(CONF_CONTEXT_MESSAGES, DEFAULT_CONTEXT_MESSAGES) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=20) + ), + vol.Optional( + CONF_MAX_HISTORY_SIZE, + default=data.get(CONF_MAX_HISTORY_SIZE, DEFAULT_MAX_HISTORY) + ): vol.All( + vol.Coerce(int), + vol.Range(min=1, max=100) + ), + }) diff --git a/custom_components/ha_text_ai/translations/de.json b/custom_components/ha_text_ai/translations/de.json index 58e0300..1061de9 100644 --- a/custom_components/ha_text_ai/translations/de.json +++ b/custom_components/ha_text_ai/translations/de.json @@ -74,13 +74,22 @@ "options": { "step": { "init": { - "title": "Instanzeinstellungen aktualisieren", - "description": "Ändern Sie die Einstellungen für diese AI-Assistenteninstanz.", + "title": "Anbieter auswählen", + "description": "Wählen Sie den AI-Anbieter für diese Instanz. Die Integration wird nach dem Speichern der Änderungen neu geladen.", "data": { + "api_provider": "API-Anbieter" + } + }, + "settings": { + "title": "Verbindungs- und Modelleinstellungen", + "description": "Konfigurieren Sie API-Anmeldeinformationen und Modellparameter. Änderungen werden nach dem Neuladen der Integration wirksam.", + "data": { + "api_key": "API-Schlüssel", + "api_endpoint": "API-Endpunkt-URL", "model": "AI-Modell", "temperature": "Kreativität der Antwort (0-2)", "max_tokens": "Maximale Länge der Antwort (1-100000)", - "request_interval": "Minimale Anfrageintervall (0,1-60 Sekunden)", + "request_interval": "Minimales Anfrageintervall (0,1-60 Sekunden)", "api_timeout": "API-Anfrage Timeout in Sekunden (5-600)", "context_messages": "Anzahl der vorherigen Nachrichten, die im Kontext enthalten sein sollen (1-20)", "max_history_size": "Maximale Größe des Gesprächsverlaufs (1-100)" diff --git a/custom_components/ha_text_ai/translations/en.json b/custom_components/ha_text_ai/translations/en.json index dc7aa50..b3b6187 100644 --- a/custom_components/ha_text_ai/translations/en.json +++ b/custom_components/ha_text_ai/translations/en.json @@ -74,9 +74,18 @@ "options": { "step": { "init": { - "title": "Update Instance Settings", - "description": "Modify settings for this AI assistant instance.", + "title": "Select Provider", + "description": "Choose the AI provider for this instance. The integration will reload after saving changes.", "data": { + "api_provider": "API Provider" + } + }, + "settings": { + "title": "Connection & Model Settings", + "description": "Configure API credentials and model parameters. Changes will take effect after the integration reloads.", + "data": { + "api_key": "API Key", + "api_endpoint": "API Endpoint URL", "model": "AI model", "temperature": "Response creativity (0-2)", "max_tokens": "Maximum response length (1-100000)", diff --git a/custom_components/ha_text_ai/translations/es.json b/custom_components/ha_text_ai/translations/es.json index c2f7fcd..f33de77 100644 --- a/custom_components/ha_text_ai/translations/es.json +++ b/custom_components/ha_text_ai/translations/es.json @@ -74,9 +74,18 @@ "options": { "step": { "init": { - "title": "Actualizar configuración de la instancia", - "description": "Modifica la configuración para esta instancia de asistente de IA.", + "title": "Seleccionar proveedor", + "description": "Elige el proveedor de IA para esta instancia. La integración se recargará después de guardar los cambios.", "data": { + "api_provider": "Proveedor de API" + } + }, + "settings": { + "title": "Configuración de conexión y modelo", + "description": "Configura las credenciales de API y los parámetros del modelo. Los cambios tendrán efecto después de recargar la integración.", + "data": { + "api_key": "Clave API", + "api_endpoint": "URL del endpoint de API", "model": "Modelo de IA", "temperature": "Creatividad de la respuesta (0-2)", "max_tokens": "Longitud máxima de la respuesta (1-100000)", diff --git a/custom_components/ha_text_ai/translations/hi.json b/custom_components/ha_text_ai/translations/hi.json index 459347e..db6a4f2 100644 --- a/custom_components/ha_text_ai/translations/hi.json +++ b/custom_components/ha_text_ai/translations/hi.json @@ -65,9 +65,18 @@ "options": { "step": { "init": { - "title": "उदाहरण सेटिंग्स अपडेट करें", - "description": "इस एआई सहायक उदाहरण के लिए सेटिंग्स संशोधित करें।", + "title": "प्रदाता चुनें", + "description": "इस उदाहरण के लिए एआई प्रदाता चुनें। परिवर्तन सहेजने के बाद एकीकरण पुनः लोड होगा।", "data": { + "api_provider": "एपीआई प्रदाता" + } + }, + "settings": { + "title": "कनेक्शन और मॉडल सेटिंग्स", + "description": "एपीआई क्रेडेंशियल और मॉडल पैरामीटर कॉन्फ़िगर करें। एकीकरण पुनः लोड होने के बाद परिवर्तन प्रभावी होंगे।", + "data": { + "api_key": "एपीआई कुंजी", + "api_endpoint": "एपीआई एंडपॉइंट यूआरएल", "model": "एआई मॉडल", "temperature": "प्रतिक्रिया की रचनात्मकता (0-2)", "max_tokens": "प्रतिक्रिया की अधिकतम लंबाई (1-100000)", diff --git a/custom_components/ha_text_ai/translations/it.json b/custom_components/ha_text_ai/translations/it.json index c5821ce..087563a 100644 --- a/custom_components/ha_text_ai/translations/it.json +++ b/custom_components/ha_text_ai/translations/it.json @@ -74,9 +74,18 @@ "options": { "step": { "init": { - "title": "Aggiorna impostazioni dell'istanza", - "description": "Modifica le impostazioni per questa istanza di assistente AI.", + "title": "Seleziona fornitore", + "description": "Scegli il fornitore AI per questa istanza. L'integrazione verrà ricaricata dopo aver salvato le modifiche.", "data": { + "api_provider": "Fornitore API" + } + }, + "settings": { + "title": "Impostazioni di connessione e modello", + "description": "Configura le credenziali API e i parametri del modello. Le modifiche avranno effetto dopo il ricaricamento dell'integrazione.", + "data": { + "api_key": "Chiave API", + "api_endpoint": "URL dell'endpoint API", "model": "Modello AI", "temperature": "Creatività della risposta (0-2)", "max_tokens": "Lunghezza massima della risposta (1-100000)", diff --git a/custom_components/ha_text_ai/translations/ru.json b/custom_components/ha_text_ai/translations/ru.json index c73d626..35882fe 100644 --- a/custom_components/ha_text_ai/translations/ru.json +++ b/custom_components/ha_text_ai/translations/ru.json @@ -74,9 +74,18 @@ "options": { "step": { "init": { - "title": "Обновление настроек экземпляра", - "description": "Измените настройки для этого экземпляра ИИ-помощника.", + "title": "Выбор провайдера", + "description": "Выберите провайдера ИИ для этого экземпляра. Интеграция перезагрузится после сохранения изменений.", "data": { + "api_provider": "Провайдер API" + } + }, + "settings": { + "title": "Настройки подключения и модели", + "description": "Настройте учётные данные API и параметры модели. Изменения вступят в силу после перезагрузки интеграции.", + "data": { + "api_key": "API-ключ", + "api_endpoint": "URL конечной точки API", "model": "Модель ИИ", "temperature": "Креативность ответа (0-2)", "max_tokens": "Максимальная длина ответа (1-100000)", diff --git a/custom_components/ha_text_ai/translations/sr.json b/custom_components/ha_text_ai/translations/sr.json index 74f6ce2..a3d2d59 100644 --- a/custom_components/ha_text_ai/translations/sr.json +++ b/custom_components/ha_text_ai/translations/sr.json @@ -65,9 +65,18 @@ "options": { "step": { "init": { - "title": "Ажурирајте подешавања инстанце", - "description": "Измените подешавања за ову AI асистент инстанцу.", + "title": "Изаберите провајдера", + "description": "Изаберите AI провајдера за ову инстанцу. Интеграција ће се поново учитати након чувања измена.", "data": { + "api_provider": "API провајдер" + } + }, + "settings": { + "title": "Подешавања везе и модела", + "description": "Конфигуришите API акредитиве и параметре модела. Промене ће ступити на снагу након поновног учитавања интеграције.", + "data": { + "api_key": "API кључ", + "api_endpoint": "URL API крајње тачке", "model": "AI модел", "temperature": "Креативност одговора (0-2)", "max_tokens": "Максимална дужина одговора (1-100000)", diff --git a/custom_components/ha_text_ai/translations/zh.json b/custom_components/ha_text_ai/translations/zh.json index 77aea0e..b7d89bf 100644 --- a/custom_components/ha_text_ai/translations/zh.json +++ b/custom_components/ha_text_ai/translations/zh.json @@ -65,9 +65,18 @@ "options": { "step": { "init": { - "title": "更新实例设置", - "description": "修改此AI助手实例的设置。", + "title": "选择提供者", + "description": "选择此实例的AI提供者。保存更改后集成将重新加载。", "data": { + "api_provider": "API提供者" + } + }, + "settings": { + "title": "连接和模型设置", + "description": "配置API凭据和模型参数。更改将在集成重新加载后生效。", + "data": { + "api_key": "API密钥", + "api_endpoint": "API端点URL", "model": "AI模型", "temperature": "响应创造力(0-2)", "max_tokens": "最大响应长度(1-100000)",