mirror of
https://github.com/sudoxreboot/groqd
synced 2026-04-14 19:47:07 +00:00
Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28b4ee672 | ||
|
|
84ee01f4ca | ||
|
|
50a6a152c4 | ||
|
|
61f0030f33 | ||
|
|
0760f8ca67 |
7 changed files with 239 additions and 19 deletions
|
|
@ -49,7 +49,7 @@
|
|||
## Quick Start
|
||||
- Create a new groqd entry.
|
||||
- Set your Groq API key.
|
||||
- Pick a model (default: `meta-llama/llama-4-maverick-17b-128e-instruct`).
|
||||
- Pick a model (default: `meta-llama/llama-4-scout-17b-16e-instruct`).
|
||||
- Set a personality prompt if you want tone/style changes.
|
||||
|
||||
## Options
|
||||
|
|
@ -62,13 +62,6 @@ Open **groqd → Options** to configure:
|
|||
- Auto-fetch URLs in user messages
|
||||
- Time format (12h / 24h)
|
||||
|
||||
## Recommended Prompt Additions
|
||||
To make tone warmer and lyrical:
|
||||
|
||||
"""
|
||||
Be warm, playful, and a bit lyrical. If the user quotes lyrics, continue one line then respond empathetically.
|
||||
"""
|
||||
|
||||
## Notes
|
||||
- TTS/STT are handled by Home Assistant and other integrations.
|
||||
- Web search is optional and only used if enabled.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import groq
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ from homeassistant.core import HomeAssistant
|
|||
|
||||
from .const import DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CONVERSATION]
|
||||
PLATFORMS: list[Platform] = [Platform.CONVERSATION, Platform.BUTTON]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -20,6 +21,7 @@ class GroqdRuntimeData:
|
|||
"""Runtime data for groqd."""
|
||||
|
||||
client: groq.AsyncClient
|
||||
conversation_agent: Any = field(default=None)
|
||||
|
||||
|
||||
type GroqdConfigEntry = ConfigEntry[GroqdRuntimeData]
|
||||
|
|
|
|||
43
custom_components/groqd/button.py
Normal file
43
custom_components/groqd/button.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Button platform for groqd — provides a 'Clear session memory' button."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import GroqdConfigEntry
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: GroqdConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up groqd button entities."""
|
||||
async_add_entities([GroqdClearMemoryButton(config_entry)])
|
||||
|
||||
|
||||
class GroqdClearMemoryButton(ButtonEntity):
|
||||
"""Button that wipes all persisted conversation history for this entry."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "clear_memory"
|
||||
|
||||
def __init__(self, entry: GroqdConfigEntry) -> None:
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_clear_memory"
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press — delegate to the conversation agent."""
|
||||
agent = self._entry.runtime_data.conversation_agent
|
||||
if agent is not None:
|
||||
await agent.async_clear_memory()
|
||||
LOGGER.info("groqd: clear memory button pressed for entry %s", self._entry.entry_id)
|
||||
else:
|
||||
LOGGER.warning("groqd: clear memory pressed but conversation agent not ready")
|
||||
|
|
@ -30,7 +30,7 @@ CONF_SEARXNG_LANGUAGE = "searxng_language"
|
|||
CONF_AUTO_FETCH_URLS = "auto_fetch_urls"
|
||||
CONF_TIME_FORMAT = "time_format"
|
||||
|
||||
DEFAULT_CHAT_MODEL = "meta-llama/llama-4-maverick-17b-128e-instruct"
|
||||
DEFAULT_CHAT_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
|
||||
DEFAULT_CONTEXT_MESSAGES = 20
|
||||
DEFAULT_MAX_TOKENS = 4096
|
||||
DEFAULT_TEMPERATURE = 1.0
|
||||
|
|
|
|||
|
|
@ -95,17 +95,153 @@ async def async_setup_entry(
|
|||
async_add_entities([agent])
|
||||
|
||||
|
||||
def _make_nullable(schema: Any) -> Any:
|
||||
"""Wrap a schema so it also accepts JSON null (Python None).
|
||||
|
||||
Models often emit ``null`` for optional parameters they don't need rather
|
||||
than omitting the key entirely. Making optional properties nullable in the
|
||||
schema sent to Groq prevents server-side validation failures for those
|
||||
``null`` values. ``_coerce_args_for_schema`` then strips the nulls before
|
||||
forwarding to Home Assistant, which does not accept null slot values.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
if schema.get("type") == "null":
|
||||
return schema # already null
|
||||
if "anyOf" in schema:
|
||||
options = schema["anyOf"]
|
||||
if any(isinstance(o, dict) and o.get("type") == "null" for o in options):
|
||||
return schema # null already present in union
|
||||
return {**schema, "anyOf": [*options, {"type": "null"}]}
|
||||
if "type" in schema:
|
||||
return {"anyOf": [schema, {"type": "null"}]}
|
||||
return schema
|
||||
|
||||
|
||||
def _is_slot_wrapper(schema: dict) -> bool:
|
||||
"""Return True if schema looks like an HA intent slot wrapper.
|
||||
|
||||
HA represents intent slot values as ``{"value": X}`` (sometimes also
|
||||
with a ``"text"`` sibling key). Detecting this pattern lets us relax
|
||||
the schema so Groq models can emit the bare value directly (e.g. "red"
|
||||
instead of {"value": "red"}).
|
||||
"""
|
||||
if schema.get("type") != "object":
|
||||
return False
|
||||
props = schema.get("properties", {})
|
||||
return "value" in props and len(props) <= 2
|
||||
|
||||
|
||||
def _relax_schema_arrays(schema: Any) -> Any:
|
||||
"""Recursively relax schemas so Groq's server-side validation accepts
|
||||
common model output patterns that differ from the strict HA schema.
|
||||
|
||||
Two relaxations are applied:
|
||||
|
||||
* ``type: array`` → ``anyOf: [array, string]``
|
||||
Models often emit ``"light"`` instead of ``["light"]``.
|
||||
|
||||
* HA slot-wrapper objects ``{type: object, properties: {value: X}}``
|
||||
→ ``anyOf: [object, X]``
|
||||
Models often emit ``"red"`` instead of ``{"value": "red"}``.
|
||||
|
||||
After Groq accepts the output, ``_coerce_args_for_schema`` fixes the
|
||||
args back to what HA expects before forwarding the tool call.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
if schema.get("type") == "object":
|
||||
props = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
is_wrapper = _is_slot_wrapper(schema)
|
||||
# Capture the raw value schema BEFORE recursing (used in anyOf below)
|
||||
raw_value_schema = props.get("value") if is_wrapper else None
|
||||
result = dict(schema)
|
||||
new_props: dict[str, Any] = {}
|
||||
for k, v in props.items():
|
||||
relaxed = _relax_schema_arrays(v)
|
||||
# Optional properties must also accept null so the model can emit
|
||||
# null instead of omitting the key and still pass Groq validation.
|
||||
if k not in required:
|
||||
relaxed = _make_nullable(relaxed)
|
||||
new_props[k] = relaxed
|
||||
result["properties"] = new_props
|
||||
if is_wrapper and raw_value_schema is not None:
|
||||
# Allow either the full {value: X} object OR a bare value of the
|
||||
# same base type — but WITHOUT any enum constraints. Models often
|
||||
# produce values outside the declared enum (e.g. XY colour strings
|
||||
# instead of named colours); stripping the enum here lets Groq
|
||||
# accept the output so we can pass it to HA and let HA report the
|
||||
# proper error if the value is truly invalid.
|
||||
raw_type = raw_value_schema.get("type", "string")
|
||||
loose_alt: dict[str, Any] = {"type": raw_type} if raw_type else {"type": "string"}
|
||||
return {"anyOf": [result, loose_alt]}
|
||||
return result
|
||||
if schema.get("type") == "array":
|
||||
return {"anyOf": [schema, {"type": "string"}]}
|
||||
return schema
|
||||
|
||||
|
||||
def _coerce_args_for_schema(args: Any, schema: Any) -> Any:
|
||||
"""Recursively coerce args so they match what HA expects.
|
||||
|
||||
Handles the two common model output mismatches:
|
||||
|
||||
* ``"light"`` (string) where schema expects ``["light"]`` (array).
|
||||
* ``"red"`` (string) where schema expects ``{"value": "red"}``
|
||||
(HA intent slot wrapper object).
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return args
|
||||
schema_type = schema.get("type")
|
||||
# Array: wrap bare string in a list
|
||||
if schema_type == "array":
|
||||
if isinstance(args, str):
|
||||
return [args]
|
||||
return args
|
||||
if schema_type == "object":
|
||||
props = schema.get("properties", {})
|
||||
# Models sometimes double-encode an object as a JSON string, e.g.
|
||||
# "color": "{\"xy\":[0.64,0.33]}" instead of a real dict.
|
||||
# Try to JSON-decode such strings before further coercion.
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
decoded = json.loads(args)
|
||||
if isinstance(decoded, dict):
|
||||
args = decoded
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# Slot wrapper: model sent bare value instead of {"value": X}
|
||||
if "value" in props and not isinstance(args, dict):
|
||||
return {"value": args}
|
||||
if isinstance(args, dict):
|
||||
result = dict(args)
|
||||
for key, prop_schema in props.items():
|
||||
if key in result and isinstance(prop_schema, dict):
|
||||
result[key] = _coerce_args_for_schema(result[key], prop_schema)
|
||||
# Strip None values — null means "omit" for optional HA parameters.
|
||||
# HA's voluptuous validators don't accept None for optional slots;
|
||||
# they simply expect the key to be absent.
|
||||
return {k: v for k, v in result.items() if v is not None}
|
||||
return args
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> ChatCompletionToolParam:
|
||||
"""Format tool specification."""
|
||||
) -> tuple[ChatCompletionToolParam, dict]:
|
||||
"""Format tool specification.
|
||||
|
||||
Returns the Groq-compatible tool param (with relaxed array schemas) and
|
||||
the original schema dict (for use in argument coercion before calling HA).
|
||||
"""
|
||||
original_schema = convert(tool.parameters, custom_serializer=custom_serializer)
|
||||
tool_spec = FunctionDefinition(
|
||||
name=tool.name,
|
||||
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
parameters=_relax_schema_arrays(original_schema),
|
||||
)
|
||||
if tool.description:
|
||||
tool_spec["description"] = tool.description
|
||||
return ChatCompletionToolParam(type="function", function=tool_spec)
|
||||
return ChatCompletionToolParam(type="function", function=tool_spec), original_schema
|
||||
|
||||
|
||||
def _parse_tool_choice(value: str) -> Any:
|
||||
|
|
@ -198,12 +334,29 @@ def _extract_urls(text: str, limit: int = 3) -> list[str]:
|
|||
break
|
||||
return cleaned
|
||||
|
||||
def _normalize_searxng_url(url: str) -> str:
|
||||
"""Return the bare SearXNG base URL, stripping any /search path or OpenSearch suffix.
|
||||
|
||||
Users sometimes copy the OpenSearch template URL (e.g.
|
||||
``https://searxng.example.com/search=%s``) instead of just the base
|
||||
URL. We strip the ``/search`` path component and anything after it so
|
||||
the code can always safely append ``/search`` with its own query params.
|
||||
A subpath mount is preserved (e.g. ``https://example.com/searxng``).
|
||||
"""
|
||||
url = url.strip()
|
||||
# Strip OpenSearch-style /search=%s (and variants like /search?q=%s)
|
||||
url = re.sub(r"/search[=?].*$", "", url, flags=re.IGNORECASE)
|
||||
# Strip a trailing bare /search as well
|
||||
url = re.sub(r"/search/?$", "", url, flags=re.IGNORECASE)
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
async def _run_searxng(
|
||||
hass: HomeAssistant,
|
||||
options: dict[str, Any],
|
||||
tool_args: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
base_url = options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL).rstrip("/")
|
||||
base_url = _normalize_searxng_url(options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL))
|
||||
params = {
|
||||
"q": tool_args.get("query", ""),
|
||||
"format": "json",
|
||||
|
|
@ -269,14 +422,25 @@ class GroqdConversationEntity(
|
|||
data = await self._store.async_load() or {}
|
||||
self._persisted_history = data.get("history", {})
|
||||
self._memory_index = data.get("memory_index", {})
|
||||
self.entry.runtime_data.conversation_agent = self
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
self.entry.runtime_data.conversation_agent = None
|
||||
conversation.async_unset_agent(self.hass, self.entry)
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
async def async_clear_memory(self) -> None:
|
||||
"""Clear all conversation history and persisted memory."""
|
||||
self.history.clear()
|
||||
self._persisted_history.clear()
|
||||
self._memory_index.clear()
|
||||
if self._store:
|
||||
await self._store.async_save({"history": {}, "memory_index": {}})
|
||||
LOGGER.info("groqd: memory cleared for entry %s", self.entry.entry_id)
|
||||
|
||||
async def async_process(
|
||||
self, user_input: conversation.ConversationInput
|
||||
) -> conversation.ConversationResult:
|
||||
|
|
@ -284,6 +448,7 @@ class GroqdConversationEntity(
|
|||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
llm_api: llm.APIInstance | None = None
|
||||
tools: list[ChatCompletionToolParam] | None = None
|
||||
tool_schemas: dict[str, dict] = {}
|
||||
user_name: str | None = None
|
||||
llm_context = llm.LLMContext(
|
||||
platform=DOMAIN,
|
||||
|
|
@ -309,7 +474,11 @@ class GroqdConversationEntity(
|
|||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=user_input.conversation_id
|
||||
)
|
||||
tools = [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
|
||||
_formatted = [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
|
||||
tools = [f[0] for f in _formatted]
|
||||
tool_schemas = {
|
||||
tool.name: f[1] for tool, f in zip(llm_api.tools, _formatted)
|
||||
}
|
||||
if options.get(CONF_SEARXNG_ENABLED, DEFAULT_SEARXNG_ENABLED):
|
||||
if tools is None:
|
||||
tools = []
|
||||
|
|
@ -406,7 +575,7 @@ class GroqdConversationEntity(
|
|||
formatted_time = now.strftime("%-I:%M %p")
|
||||
prompt_parts.append(
|
||||
f"Current local time: {formatted_time}. "
|
||||
"Always express time exactly in this numeric format; do not spell out numbers."
|
||||
"When reading times aloud, use natural spoken format (e.g., 'three thirty-four' not 'three thousand thirty-four')."
|
||||
)
|
||||
|
||||
if options.get(CONF_AUTO_FETCH_URLS, DEFAULT_AUTO_FETCH_URLS):
|
||||
|
|
@ -547,6 +716,12 @@ class GroqdConversationEntity(
|
|||
except Exception as err:
|
||||
tool_response = {"error": type(err).__name__, "error_text": str(err)}
|
||||
elif llm_api:
|
||||
# Coerce any string values back to arrays where the HA tool
|
||||
# schema requires them (Groq models sometimes emit a bare
|
||||
# string instead of a one-element array, e.g. "light" vs
|
||||
# ["light"] for the domain parameter).
|
||||
if tool_name in tool_schemas:
|
||||
tool_args = _coerce_args_for_schema(tool_args, tool_schemas[tool_name])
|
||||
tool_input = llm.ToolInput(
|
||||
tool_name=tool_name,
|
||||
tool_args=tool_args,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"domain": "groqd",
|
||||
"name": "groqd",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/sudoxreboot/groqd",
|
||||
"issue_tracker": "https://github.com/sudoxreboot/groqd/issues",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"clear_memory": {
|
||||
"name": "Clear session memory"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue