Refactor Groq Cloud API integration: update setup methods, enhance error handling, and improve configuration options

This commit is contained in:
2025-12-23 17:40:44 +01:00
parent f87aecaefa
commit 4fa1ddae19
7 changed files with 177 additions and 178 deletions
+19 -22
View File
@@ -1,58 +1,55 @@
"""Groq Cloud API integration.""" """Groq Cloud API integration."""
from __future__ import annotations
import groq import groq
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady 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.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
from .const import LOGGER from .const import DOMAIN, LOGGER
PLATFORMS = (Platform.CONVERSATION,) PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type GroqConfigEntry = ConfigEntry[groq.AsyncClient] type GroqConfigEntry = ConfigEntry[groq.AsyncClient]
async def async_setup_entry(hass: HomeAssistant, entry: GroqConfigEntry) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Load entry.""" """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( client = groq.AsyncGroq(
api_key=entry.data[CONF_API_KEY], 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 entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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 return True
async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry): async def async_update_options(hass: HomeAssistant, entry: GroqConfigEntry) -> None:
"""Update entry.""" """Update options."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
return True
async def async_unload_entry(hass: HomeAssistant, entry: GroqConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
LOGGER.debug("Unloading %s", entry)
LOGGER.info("Unloading %s", entry)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+54 -73
View File
@@ -1,27 +1,25 @@
"""Config flow for GroqCloud Whisper integration.""" """Config flow for Groq Cloud API integration."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any
from types import MappingProxyType from types import MappingProxyType
from typing import Any
import groq import groq
import requests import requests
import voluptuous as vol import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ( from homeassistant.config_entries import (
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
) )
from homeassistant import exceptions from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm from homeassistant.helpers import llm
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
NumberSelector, NumberSelector,
NumberSelectorConfig, NumberSelectorConfig,
@@ -31,59 +29,48 @@ from homeassistant.helpers.selector import (
TemplateSelector, TemplateSelector,
) )
from . import LOGGER
from .const import ( from .const import (
CONF_PROMPT,
CONF_TEMPERATURE,
DEFAULT_NAME,
CONF_CHAT_MODEL, CONF_CHAT_MODEL,
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
CONF_PROMPT, CONF_PROMPT,
CONF_RECOMMENDED, CONF_RECOMMENDED,
CONF_TEMPERATURE, CONF_TEMPERATURE,
CONF_TOP_P, CONF_TOP_P,
DEFAULT_NAME,
DOMAIN, DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS, RECOMMENDED_MAX_TOKENS,
RECOMMENDED_OPTIONS,
RECOMMENDED_TEMPERATURE, RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P, RECOMMENDED_TOP_P,
) )
STEP_USER_DATA_SCHEMA = vol.Schema( STEP_USER_DATA_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_API_KEY): str,
vol.Required(CONF_API_KEY): cv.string,
} }
) )
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] = "<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( response = await asyncio.to_thread(
requests.get, requests.get,
url="https://api.groq.com/openai/v1/models", url="https://api.groq.com/openai/v1/models",
headers={ headers={
"Authorization": f"Bearer {data.get(CONF_API_KEY)}", "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: if response.status_code == 401:
raise InvalidAPIKey raise InvalidAPIKey
@@ -94,56 +81,55 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]):
if response.status_code != 200: if response.status_code != 200:
raise UnknownError 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): class GroqConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle UI config flow.""" """Handle UI config flow for Groq Cloud API."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 0 MINOR_VERSION = 1
async def async_step_user( async def async_step_user(
self, self,
user_input: dict[str, Any] | None = None, user_input: dict[str, Any] | None = None,
errors: dict[str, str] | None = None,
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle initial step.""" """Handle initial step."""
if user_input is None: if user_input is None:
return self.async_show_form( 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: try:
await validate_input(self.hass, user_input) await validate_input(self.hass, user_input)
except groq.APIConnectionError: except groq.APIConnectionError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except groq.AuthenticationError: except groq.AuthenticationError:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except InvalidAPIKey:
errors["base"] = "invalid_auth"
except UnauthorizedError:
errors["base"] = "unauthorized"
except Exception: except Exception:
LOGGER.exception("Unexpected exception") LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
return self.async_create_entry( return self.async_create_entry(
title="GroqCloud", title=DEFAULT_NAME,
data=user_input, data=user_input,
options=RECOMMENDED_OPTIONS, options=RECOMMENDED_OPTIONS,
) )
return self.async_show_form( 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 @staticmethod
@callback
def async_get_options_flow( def async_get_options_flow(
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> OptionsFlow: ) -> OptionsFlow:
@@ -153,25 +139,25 @@ class GroqConfigFlow(ConfigFlow, domain=DOMAIN):
class GroqOptionsFlow(OptionsFlow): class GroqOptionsFlow(OptionsFlow):
"""Groq Cloud config flow options handler.""" """Groq Cloud API options flow handler."""
def __init__(self) -> None: last_rendered_recommended: bool = False
"""Initialize options flow."""
self.last_rendered_recommended = False
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage the options.""" """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: if user_input is None:
self.last_rendered_recommended = options.get(CONF_RECOMMENDED, False) self.last_rendered_recommended = options.get(CONF_RECOMMENDED, False)
if user_input is not None: if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none": if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API) user_input.pop(CONF_LLM_HASS_API, None)
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)
# Re-render the options again, now with the recommended options shown/hidden # Re-render the options again, now with the recommended options shown/hidden
@@ -179,8 +165,10 @@ class GroqOptionsFlow(OptionsFlow):
options = { options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT], CONF_PROMPT: user_input.get(
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], 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) schema = groq_config_option_schema(self.hass, options)
@@ -196,20 +184,14 @@ def groq_config_option_schema(
) -> dict: ) -> dict:
"""Return a schema for Groq Cloud completion options.""" """Return a schema for Groq Cloud completion options."""
hass_apis: list[SelectOptionDict] = [ hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label="No control",
value="none",
)
]
hass_apis.extend(
SelectOptionDict( SelectOptionDict(
label=api.name, label=api.name,
value=api.id, value=api.id,
) )
for api in llm.async_get_apis(hass) for api in llm.async_get_apis(hass)
) ]
schema = { schema: dict = {
vol.Optional( vol.Optional(
CONF_PROMPT, CONF_PROMPT,
description={ description={
@@ -221,7 +203,6 @@ def groq_config_option_schema(
vol.Optional( vol.Optional(
CONF_LLM_HASS_API, CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)}, description={"suggested_value": options.get(CONF_LLM_HASS_API)},
default="none",
): SelectSelector(SelectSelectorConfig(options=hass_apis)), ): SelectSelector(SelectSelectorConfig(options=hass_apis)),
vol.Required( vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
@@ -258,17 +239,17 @@ def groq_config_option_schema(
return schema return schema
class UnknownError(exceptions.HomeAssistantError): class UnknownError(HomeAssistantError):
"""Unknown error.""" """Unknown error."""
class UnauthorizedError(exceptions.HomeAssistantError): class UnauthorizedError(HomeAssistantError):
"""API key valid but doesn't have the rights to use Whisper.""" """API key valid but doesn't have the rights."""
class InvalidAPIKey(exceptions.HomeAssistantError): class InvalidAPIKey(HomeAssistantError):
"""Invalid api_key error.""" """Invalid api_key error."""
class ModelNotFound(exceptions.HomeAssistantError): class ModelNotFound(HomeAssistantError):
"""Model can't be found in the GroqCloud model's list.""" """Model can't be found in the Groq Cloud model's list."""
+12 -2
View File
@@ -2,18 +2,28 @@
import logging import logging
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
DOMAIN = "groq_cloud_api" DOMAIN = "groq_cloud_api"
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Groq Cloud API" DEFAULT_NAME = "Groq Cloud API"
DEFAULT_CONVERSATION_NAME = "Groq Conversation"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt" CONF_PROMPT = "prompt"
CONF_CHAT_MODEL = "chat_model" CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "llama-3.3-70b-versatile" RECOMMENDED_CHAT_MODEL = "llama-3.3-70b-versatile"
CONF_MAX_TOKENS = "max_tokens" CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 150 RECOMMENDED_MAX_TOKENS = 3000
CONF_TOP_P = "top_p" CONF_TOP_P = "top_p"
RECOMMENDED_TOP_P = 1.0 RECOMMENDED_TOP_P = 1.0
CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TEMPERATURE = 1.0
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
@@ -1,5 +1,7 @@
"""Conversation support for Groq Cloud.""" """Conversation support for Groq Cloud."""
from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import json import json
from typing import Any, Literal 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.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, intent, llm 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 homeassistant.helpers.json import json_dumps
from . import GroqConfigEntry from . import GroqConfigEntry
@@ -57,7 +59,7 @@ MAX_TOOL_ITERATIONS = 10
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: GroqConfigEntry, config_entry: GroqConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up conversation entities.""" """Set up conversation entities."""
agent = GroqConversationEntity(config_entry) agent = GroqConversationEntity(config_entry)
@@ -198,12 +200,10 @@ class GroqConversationEntity(
try: try:
await chat_log.async_provide_llm_data( await chat_log.async_provide_llm_data(
llm_context=user_input.as_llm_context(DOMAIN), user_input.as_llm_context(DOMAIN),
user_llm_hass_api=options.get(CONF_LLM_HASS_API), options.get(CONF_LLM_HASS_API),
user_llm_prompt=options.get( options.get(CONF_PROMPT),
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT user_input.extra_system_prompt,
),
user_extra_system_prompt=user_input.extra_system_prompt,
) )
except ConverseError as err: except ConverseError as err:
return err.as_conversation_result() return err.as_conversation_result()
@@ -1,19 +1,18 @@
{ {
"domain": "groq_cloud_api", "domain": "groq_cloud_api",
"name": "Groq Cloud API", "name": "Groq Cloud API",
"version": "0.1.3", "version": "0.1.5",
"codeowners": [ "codeowners": [
"@HunorLaczko" "@HunorLaczko"
], ],
"config_flow": true, "config_flow": true,
"dependencies": [], "after_dependencies": ["assist_pipeline", "intent"],
"dependencies": ["conversation"],
"documentation": "https://github.com/HunorLaczko/ha-groq-cloud-api", "documentation": "https://github.com/HunorLaczko/ha-groq-cloud-api",
"issue_tracker": "https://github.com/HunorLaczko/ha-groq-cloud-api/issues", "issue_tracker": "https://github.com/HunorLaczko/ha-groq-cloud-api/issues",
"homekit": {}, "integration_type": "service",
"iot_class": "cloud_push", "iot_class": "cloud_polling",
"requirements": [ "requirements": [
"groq==0.18.0" "groq==0.18.0"
], ]
"ssdp": [],
"zeroconf": []
} }
@@ -1,39 +1,45 @@
{ {
"config": { "config": {
"step": { "abort": {
"user": { "already_configured": "This API key is already configured"
"data": { },
"api_key": "API key" "step": {
} "user": {
} "data": {
}, "api_key": "API key"
"error": { },
"cannot_connect": "Cannot connect to service", "description": "Enter your Groq Cloud API key to get started."
"invalid_auth": "Invalid Authentification",
"unknown": "Unknown Error"
} }
}, },
"options": { "error": {
"step": { "cannot_connect": "Failed to connect",
"init": { "invalid_auth": "Invalid API key",
"data": { "unauthorized": "API key does not have required permissions",
"prompt": "Instructions", "unknown": "Unexpected error"
"chat_model": "Chat model", }
"max_tokens": "Maximum tokens to return in response", },
"temperature": "Temperature", "options": {
"top_p": "Top P", "step": {
"llm_hass_api": "Home Assistant LLM API", "init": {
"recommended": "Recommended model settings" "data": {
}, "prompt": "Instructions",
"data_description": { "chat_model": "Model",
"prompt": "Instruct how the LLM should respond. This can be a template." "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}"
}
} }
} },
"exceptions": {
"invalid_config_entry": {
"message": "Invalid config entry provided. Got {config_entry}"
}
}
}
@@ -1,39 +1,45 @@
{ {
"config": { "config": {
"step": { "abort": {
"user": { "already_configured": "Ez az API kulcs már konfigurálva van"
"data": { },
"api_key": "API kulcs" "step": {
} "user": {
} "data": {
}, "api_key": "API kulcs"
"error": { },
"cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz", "description": "Add meg a Groq Cloud API kulcsot a kezdéshez."
"invalid_auth": "Érvénytelen hitelesítés",
"unknown": "Ismeretlen hiba"
} }
}, },
"options": { "error": {
"step": { "cannot_connect": "Nem lehet csatlakozni a szolgáltatáshoz",
"init": { "invalid_auth": "Érvénytelen API kulcs",
"data": { "unauthorized": "Az API kulcsnak nincs megfelelő jogosultsága",
"prompt": "Utasítások", "unknown": "Váratlan hiba"
"chat_model": "Chat modell", }
"max_tokens": "Válaszban visszatérítendő maximális tokenek száma", },
"temperature": "Hőmérséklet", "options": {
"top_p": "Top P", "step": {
"llm_hass_api": "Home Assistant LLM API", "init": {
"recommended": "Ajánlott modellbeállítások" "data": {
}, "prompt": "Utasítások",
"data_description": { "chat_model": "Modell",
"prompt": "Add meg, hogy LLM hogyan reagáljon. Ez egy sablon is lehet." "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}"
}
} }
} },
"exceptions": {
"invalid_config_entry": {
"message": "Érvénytelen beállítás megadva. Ezt kaptam: {config_entry}"
}
}
}