diff --git a/custom_components/groq_cloud_api/__init__.py b/custom_components/groq_cloud_api/__init__.py index 47fbad1..18363bf 100644 --- a/custom_components/groq_cloud_api/__init__.py +++ b/custom_components/groq_cloud_api/__init__.py @@ -1,58 +1,55 @@ """Groq Cloud API integration.""" +from __future__ import annotations + import groq from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.typing import ConfigType -from .const import LOGGER +from .const import DOMAIN, LOGGER PLATFORMS = (Platform.CONVERSATION,) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type GroqConfigEntry = ConfigEntry[groq.AsyncClient] -async def async_setup_entry(hass: HomeAssistant, entry: GroqConfigEntry) -> bool: - """Load entry.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Groq Cloud API.""" + return True - LOGGER.info("Setting up %s", entry) + +async def async_setup_entry(hass: HomeAssistant, entry: GroqConfigEntry) -> bool: + """Set up Groq Cloud API from a config entry.""" + LOGGER.debug("Setting up %s", entry) client = groq.AsyncGroq( api_key=entry.data[CONF_API_KEY], - http_client=get_async_client(hass) + http_client=get_async_client(hass), ) - # TODO: I get async errors when trying to call the list function. But this would be the proper way to validate the API key. - # try: - # await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) - # except groq.AuthenticationError as err: - # LOGGER.error("Invalid API key: %s", err) - # return False - # except groq.GroqError as err: - # raise ConfigEntryNotReady(err) from err - entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) + entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry): - """Update entry.""" +async def async_update_options(hass: HomeAssistant, entry: GroqConfigEntry) -> None: + """Update options.""" await hass.config_entries.async_reload(entry.entry_id) - return True - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GroqConfigEntry) -> bool: """Unload a config entry.""" - - LOGGER.info("Unloading %s", entry) + LOGGER.debug("Unloading %s", entry) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/groq_cloud_api/config_flow.py b/custom_components/groq_cloud_api/config_flow.py index 21ef564..a9c5ce6 100644 --- a/custom_components/groq_cloud_api/config_flow.py +++ b/custom_components/groq_cloud_api/config_flow.py @@ -1,27 +1,25 @@ -"""Config flow for GroqCloud Whisper integration.""" +"""Config flow for Groq Cloud API integration.""" from __future__ import annotations import asyncio -from typing import Any from types import MappingProxyType +from typing import Any import groq - import requests import voluptuous as vol -from homeassistant.core import HomeAssistant from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant import exceptions -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -31,59 +29,48 @@ from homeassistant.helpers.selector import ( TemplateSelector, ) -from . import LOGGER from .const import ( - CONF_PROMPT, - CONF_TEMPERATURE, - DEFAULT_NAME, CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_NAME, DOMAIN, + LOGGER, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, ) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_KEY): str, } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - - -async def validate_input(hass: HomeAssistant, data: dict[str, Any]): - """Validate the user input.""" - - obscured_api_key = data.get(CONF_API_KEY) - data[CONF_API_KEY] = "" - LOGGER.debug("User validation got: %s", data) - data[CONF_API_KEY] = obscured_api_key - - if data.get(CONF_TEMPERATURE) is None: - data[CONF_CHAT_MODEL] = RECOMMENDED_CHAT_MODEL +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect.""" response = await asyncio.to_thread( requests.get, url="https://api.groq.com/openai/v1/models", headers={ "Authorization": f"Bearer {data.get(CONF_API_KEY)}", - "Content-Type": "application/json" + "Content-Type": "application/json", }, + timeout=10, ) - LOGGER.debug("Models request took %f s and returned %d - %s", response.elapsed.seconds, response.status_code, response.reason) + LOGGER.debug( + "Models request took %f s and returned %d - %s", + response.elapsed.total_seconds(), + response.status_code, + response.reason, + ) if response.status_code == 401: raise InvalidAPIKey @@ -94,56 +81,55 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]): if response.status_code != 200: raise UnknownError - for model in response.json().get("data", []): - if model.get("id") == data.get(CONF_CHAT_MODEL): - break - if model == response.json().get("data")[-1]: - raise ModelNotFound - - LOGGER.debug("User validation successful") - class GroqConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle UI config flow.""" + """Handle UI config flow for Groq Cloud API.""" VERSION = 1 - MINOR_VERSION = 0 + MINOR_VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None, - errors: dict[str, str] | None = None, ) -> ConfigFlowResult: """Handle initial step.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, ) - - errors = {} + errors: dict[str, str] = {} + + self._async_abort_entries_match(user_input) try: await validate_input(self.hass, user_input) except groq.APIConnectionError: errors["base"] = "cannot_connect" except groq.AuthenticationError: errors["base"] = "invalid_auth" + except InvalidAPIKey: + errors["base"] = "invalid_auth" + except UnauthorizedError: + errors["base"] = "unauthorized" except Exception: LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( - title="GroqCloud", + title=DEFAULT_NAME, data=user_input, options=RECOMMENDED_OPTIONS, ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, ) - @staticmethod + @callback def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlow: @@ -153,25 +139,25 @@ class GroqConfigFlow(ConfigFlow, domain=DOMAIN): class GroqOptionsFlow(OptionsFlow): - """Groq Cloud config flow options handler.""" + """Groq Cloud API options flow handler.""" - def __init__(self) -> None: - """Initialize options flow.""" - self.last_rendered_recommended = False + last_rendered_recommended: bool = False async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + options: dict[str, Any] | MappingProxyType[str, Any] = ( + self.config_entry.options + ) if user_input is None: self.last_rendered_recommended = options.get(CONF_RECOMMENDED, False) if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) return self.async_create_entry(title="", data=user_input) # Re-render the options again, now with the recommended options shown/hidden @@ -179,8 +165,10 @@ class GroqOptionsFlow(OptionsFlow): options = { CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + CONF_PROMPT: user_input.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ), + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), } schema = groq_config_option_schema(self.hass, options) @@ -196,20 +184,14 @@ def groq_config_option_schema( ) -> dict: """Return a schema for Groq Cloud completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] - schema = { + schema: dict = { vol.Optional( CONF_PROMPT, description={ @@ -221,7 +203,6 @@ def groq_config_option_schema( vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", ): SelectSelector(SelectSelectorConfig(options=hass_apis)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) @@ -258,17 +239,17 @@ def groq_config_option_schema( return schema -class UnknownError(exceptions.HomeAssistantError): +class UnknownError(HomeAssistantError): """Unknown error.""" -class UnauthorizedError(exceptions.HomeAssistantError): - """API key valid but doesn't have the rights to use Whisper.""" +class UnauthorizedError(HomeAssistantError): + """API key valid but doesn't have the rights.""" -class InvalidAPIKey(exceptions.HomeAssistantError): +class InvalidAPIKey(HomeAssistantError): """Invalid api_key error.""" -class ModelNotFound(exceptions.HomeAssistantError): - """Model can't be found in the GroqCloud model's list.""" +class ModelNotFound(HomeAssistantError): + """Model can't be found in the Groq Cloud model's list.""" diff --git a/custom_components/groq_cloud_api/const.py b/custom_components/groq_cloud_api/const.py index 28d0a42..b452f6f 100644 --- a/custom_components/groq_cloud_api/const.py +++ b/custom_components/groq_cloud_api/const.py @@ -2,18 +2,28 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "groq_cloud_api" LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Groq Cloud API" +DEFAULT_CONVERSATION_NAME = "Groq Conversation" CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "llama-3.3-70b-versatile" CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 3000 CONF_TOP_P = "top_p" RECOMMENDED_TOP_P = 1.0 CONF_TEMPERATURE = "temperature" -RECOMMENDED_TEMPERATURE = 1.0 \ No newline at end of file +RECOMMENDED_TEMPERATURE = 1.0 + +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} \ No newline at end of file diff --git a/custom_components/groq_cloud_api/conversation.py b/custom_components/groq_cloud_api/conversation.py index 13ebd31..39889b7 100644 --- a/custom_components/groq_cloud_api/conversation.py +++ b/custom_components/groq_cloud_api/conversation.py @@ -1,5 +1,7 @@ """Conversation support for Groq Cloud.""" +from __future__ import annotations + from collections.abc import Callable import json from typing import Any, Literal @@ -32,7 +34,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, intent, llm -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from . import GroqConfigEntry @@ -57,7 +59,7 @@ MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( hass: HomeAssistant, config_entry: GroqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" agent = GroqConversationEntity(config_entry) @@ -198,12 +200,10 @@ class GroqConversationEntity( try: await chat_log.async_provide_llm_data( - llm_context=user_input.as_llm_context(DOMAIN), - user_llm_hass_api=options.get(CONF_LLM_HASS_API), - user_llm_prompt=options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ), - user_extra_system_prompt=user_input.extra_system_prompt, + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except ConverseError as err: return err.as_conversation_result() diff --git a/custom_components/groq_cloud_api/manifest.json b/custom_components/groq_cloud_api/manifest.json index b6fdb29..75a450d 100644 --- a/custom_components/groq_cloud_api/manifest.json +++ b/custom_components/groq_cloud_api/manifest.json @@ -1,19 +1,18 @@ { "domain": "groq_cloud_api", "name": "Groq Cloud API", - "version": "0.1.3", + "version": "0.1.5", "codeowners": [ "@HunorLaczko" ], "config_flow": true, - "dependencies": [], + "after_dependencies": ["assist_pipeline", "intent"], + "dependencies": ["conversation"], "documentation": "https://github.com/HunorLaczko/ha-groq-cloud-api", "issue_tracker": "https://github.com/HunorLaczko/ha-groq-cloud-api/issues", - "homekit": {}, - "iot_class": "cloud_push", + "integration_type": "service", + "iot_class": "cloud_polling", "requirements": [ "groq==0.18.0" - ], - "ssdp": [], - "zeroconf": [] + ] } diff --git a/custom_components/groq_cloud_api/translations/en.json b/custom_components/groq_cloud_api/translations/en.json index fc2c249..c73b1ff 100644 --- a/custom_components/groq_cloud_api/translations/en.json +++ b/custom_components/groq_cloud_api/translations/en.json @@ -1,39 +1,45 @@ { - "config": { - "step": { - "user": { - "data": { - "api_key": "API key" - } - } - }, - "error": { - "cannot_connect": "Cannot connect to service", - "invalid_auth": "Invalid Authentification", - "unknown": "Unknown Error" + "config": { + "abort": { + "already_configured": "This API key is already configured" + }, + "step": { + "user": { + "data": { + "api_key": "API key" + }, + "description": "Enter your Groq Cloud API key to get started." } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "chat_model": "Chat model", - "max_tokens": "Maximum tokens to return in response", - "temperature": "Temperature", - "top_p": "Top P", - "llm_hass_api": "Home Assistant LLM API", - "recommended": "Recommended model settings" - }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." - } + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid API key", + "unauthorized": "API key does not have required permissions", + "unknown": "Unexpected error" + } + }, + "options": { + "step": { + "init": { + "data": { + "prompt": "Instructions", + "chat_model": "Model", + "max_tokens": "Maximum tokens to return in response", + "temperature": "Temperature", + "top_p": "Top P", + "llm_hass_api": "Control Home Assistant", + "recommended": "Recommended model settings" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "llm_hass_api": "Allow the model to interact with Home Assistant. Requires specific permissions." } } - }, - "exceptions": { - "invalid_config_entry": { - "message": "Invalid config entry provided. Got {config_entry}" - } } - } \ No newline at end of file + }, + "exceptions": { + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + } + } +} \ No newline at end of file diff --git a/custom_components/groq_cloud_api/translations/hu.json b/custom_components/groq_cloud_api/translations/hu.json index 9be712f..41c139f 100644 --- a/custom_components/groq_cloud_api/translations/hu.json +++ b/custom_components/groq_cloud_api/translations/hu.json @@ -1,39 +1,45 @@ { - "config": { - "step": { - "user": { - "data": { - "api_key": "API kulcs" - } - } - }, - "error": { - "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz", - "invalid_auth": "Érvénytelen hitelesítés", - "unknown": "Ismeretlen hiba" + "config": { + "abort": { + "already_configured": "Ez az API kulcs már konfigurálva van" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs" + }, + "description": "Add meg a Groq Cloud API kulcsot a kezdéshez." } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Utasítások", - "chat_model": "Chat modell", - "max_tokens": "Válaszban visszatérítendő maximális tokenek száma", - "temperature": "Hőmérséklet", - "top_p": "Top P", - "llm_hass_api": "Home Assistant LLM API", - "recommended": "Ajánlott modellbeállítások" - }, - "data_description": { - "prompt": "Add meg, hogy LLM hogyan reagáljon. Ez egy sablon is lehet." - } + "error": { + "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz", + "invalid_auth": "Érvénytelen API kulcs", + "unauthorized": "Az API kulcsnak nincs megfelelő jogosultsága", + "unknown": "Váratlan hiba" + } + }, + "options": { + "step": { + "init": { + "data": { + "prompt": "Utasítások", + "chat_model": "Modell", + "max_tokens": "Válaszban visszatérítendő maximális tokenek száma", + "temperature": "Hőmérséklet", + "top_p": "Top P", + "llm_hass_api": "Home Assistant irányítás", + "recommended": "Ajánlott modellbeállítások" + }, + "data_description": { + "prompt": "Add meg, hogy az LLM hogyan reagáljon. Ez egy sablon is lehet.", + "llm_hass_api": "Engedélyezd a modell számára a Home Assistant irányítását. Speciális jogosultságok szükségesek." } } - }, - "exceptions": { - "invalid_config_entry": { - "message": "Érvénytelen beállítás megadva. Ezt kaptam: {config_entry}" - } } - } \ No newline at end of file + }, + "exceptions": { + "invalid_config_entry": { + "message": "Érvénytelen beállítás megadva. Ezt kaptam: {config_entry}" + } + } +} \ No newline at end of file