Compare commits

...

5 commits
v0.1.0 ... main

Author SHA1 Message Date
sudoxreboot
a28b4ee672
Update README.md 2026-03-11 21:38:47 -05:00
sudoxreboot
84ee01f4ca
Update README.md 2026-03-11 21:38:19 -05:00
sudoxreboot
50a6a152c4
Update const.py 2026-03-11 21:38:00 -05:00
sudoxreboot
61f0030f33
Add files via upload
updated tool call handling
updated light color handling
updated searxng handling
- all 3 should work now
2026-03-11 13:44:09 -05:00
Your Name
0760f8ca67 fix time pronunciation in spoken responses
changed prompt instruction to use natural spoken format for times instead of numeric format, preventing LLM from saying "three thousand thirty-four" instead of "three thirty-four"
2025-12-20 17:45:08 -06:00
7 changed files with 239 additions and 19 deletions

View file

@ -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.

View file

@ -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]

View 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")

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -1,4 +1,11 @@
{
"entity": {
"button": {
"clear_memory": {
"name": "Clear session memory"
}
}
},
"config": {
"step": {
"user": {