mirror of
https://github.com/sudoxreboot/groqd
synced 2026-05-08 06:56:19 +00:00
Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
7 changed files with 19 additions and 239 deletions
|
|
@ -49,7 +49,7 @@
|
||||||
## Quick Start
|
## Quick Start
|
||||||
- Create a new groqd entry.
|
- Create a new groqd entry.
|
||||||
- Set your Groq API key.
|
- Set your Groq API key.
|
||||||
- Pick a model (default: `meta-llama/llama-4-scout-17b-16e-instruct`).
|
- Pick a model (default: `meta-llama/llama-4-maverick-17b-128e-instruct`).
|
||||||
- Set a personality prompt if you want tone/style changes.
|
- Set a personality prompt if you want tone/style changes.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
@ -62,6 +62,13 @@ Open **groqd → Options** to configure:
|
||||||
- Auto-fetch URLs in user messages
|
- Auto-fetch URLs in user messages
|
||||||
- Time format (12h / 24h)
|
- 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
|
## Notes
|
||||||
- TTS/STT are handled by Home Assistant and other integrations.
|
- TTS/STT are handled by Home Assistant and other integrations.
|
||||||
- Web search is optional and only used if enabled.
|
- Web search is optional and only used if enabled.
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import groq
|
import groq
|
||||||
|
|
||||||
|
|
@ -13,7 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.CONVERSATION, Platform.BUTTON]
|
PLATFORMS: list[Platform] = [Platform.CONVERSATION]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -21,7 +20,6 @@ class GroqdRuntimeData:
|
||||||
"""Runtime data for groqd."""
|
"""Runtime data for groqd."""
|
||||||
|
|
||||||
client: groq.AsyncClient
|
client: groq.AsyncClient
|
||||||
conversation_agent: Any = field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
type GroqdConfigEntry = ConfigEntry[GroqdRuntimeData]
|
type GroqdConfigEntry = ConfigEntry[GroqdRuntimeData]
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"""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_AUTO_FETCH_URLS = "auto_fetch_urls"
|
||||||
CONF_TIME_FORMAT = "time_format"
|
CONF_TIME_FORMAT = "time_format"
|
||||||
|
|
||||||
DEFAULT_CHAT_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
|
DEFAULT_CHAT_MODEL = "meta-llama/llama-4-maverick-17b-128e-instruct"
|
||||||
DEFAULT_CONTEXT_MESSAGES = 20
|
DEFAULT_CONTEXT_MESSAGES = 20
|
||||||
DEFAULT_MAX_TOKENS = 4096
|
DEFAULT_MAX_TOKENS = 4096
|
||||||
DEFAULT_TEMPERATURE = 1.0
|
DEFAULT_TEMPERATURE = 1.0
|
||||||
|
|
|
||||||
|
|
@ -95,153 +95,17 @@ async def async_setup_entry(
|
||||||
async_add_entities([agent])
|
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(
|
def _format_tool(
|
||||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||||
) -> tuple[ChatCompletionToolParam, dict]:
|
) -> ChatCompletionToolParam:
|
||||||
"""Format tool specification.
|
"""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(
|
tool_spec = FunctionDefinition(
|
||||||
name=tool.name,
|
name=tool.name,
|
||||||
parameters=_relax_schema_arrays(original_schema),
|
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||||
)
|
)
|
||||||
if tool.description:
|
if tool.description:
|
||||||
tool_spec["description"] = tool.description
|
tool_spec["description"] = tool.description
|
||||||
return ChatCompletionToolParam(type="function", function=tool_spec), original_schema
|
return ChatCompletionToolParam(type="function", function=tool_spec)
|
||||||
|
|
||||||
|
|
||||||
def _parse_tool_choice(value: str) -> Any:
|
def _parse_tool_choice(value: str) -> Any:
|
||||||
|
|
@ -334,29 +198,12 @@ def _extract_urls(text: str, limit: int = 3) -> list[str]:
|
||||||
break
|
break
|
||||||
return cleaned
|
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(
|
async def _run_searxng(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
options: dict[str, Any],
|
options: dict[str, Any],
|
||||||
tool_args: dict[str, Any],
|
tool_args: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
base_url = _normalize_searxng_url(options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL))
|
base_url = options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL).rstrip("/")
|
||||||
params = {
|
params = {
|
||||||
"q": tool_args.get("query", ""),
|
"q": tool_args.get("query", ""),
|
||||||
"format": "json",
|
"format": "json",
|
||||||
|
|
@ -422,25 +269,14 @@ class GroqdConversationEntity(
|
||||||
data = await self._store.async_load() or {}
|
data = await self._store.async_load() or {}
|
||||||
self._persisted_history = data.get("history", {})
|
self._persisted_history = data.get("history", {})
|
||||||
self._memory_index = data.get("memory_index", {})
|
self._memory_index = data.get("memory_index", {})
|
||||||
self.entry.runtime_data.conversation_agent = self
|
|
||||||
self.entry.async_on_unload(
|
self.entry.async_on_unload(
|
||||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
self.entry.runtime_data.conversation_agent = None
|
|
||||||
conversation.async_unset_agent(self.hass, self.entry)
|
conversation.async_unset_agent(self.hass, self.entry)
|
||||||
await super().async_will_remove_from_hass()
|
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(
|
async def async_process(
|
||||||
self, user_input: conversation.ConversationInput
|
self, user_input: conversation.ConversationInput
|
||||||
) -> conversation.ConversationResult:
|
) -> conversation.ConversationResult:
|
||||||
|
|
@ -448,7 +284,6 @@ class GroqdConversationEntity(
|
||||||
intent_response = intent.IntentResponse(language=user_input.language)
|
intent_response = intent.IntentResponse(language=user_input.language)
|
||||||
llm_api: llm.APIInstance | None = None
|
llm_api: llm.APIInstance | None = None
|
||||||
tools: list[ChatCompletionToolParam] | None = None
|
tools: list[ChatCompletionToolParam] | None = None
|
||||||
tool_schemas: dict[str, dict] = {}
|
|
||||||
user_name: str | None = None
|
user_name: str | None = None
|
||||||
llm_context = llm.LLMContext(
|
llm_context = llm.LLMContext(
|
||||||
platform=DOMAIN,
|
platform=DOMAIN,
|
||||||
|
|
@ -474,11 +309,7 @@ class GroqdConversationEntity(
|
||||||
return conversation.ConversationResult(
|
return conversation.ConversationResult(
|
||||||
response=intent_response, conversation_id=user_input.conversation_id
|
response=intent_response, conversation_id=user_input.conversation_id
|
||||||
)
|
)
|
||||||
_formatted = [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
|
tools = [_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 options.get(CONF_SEARXNG_ENABLED, DEFAULT_SEARXNG_ENABLED):
|
||||||
if tools is None:
|
if tools is None:
|
||||||
tools = []
|
tools = []
|
||||||
|
|
@ -575,7 +406,7 @@ class GroqdConversationEntity(
|
||||||
formatted_time = now.strftime("%-I:%M %p")
|
formatted_time = now.strftime("%-I:%M %p")
|
||||||
prompt_parts.append(
|
prompt_parts.append(
|
||||||
f"Current local time: {formatted_time}. "
|
f"Current local time: {formatted_time}. "
|
||||||
"When reading times aloud, use natural spoken format (e.g., 'three thirty-four' not 'three thousand thirty-four')."
|
"Always express time exactly in this numeric format; do not spell out numbers."
|
||||||
)
|
)
|
||||||
|
|
||||||
if options.get(CONF_AUTO_FETCH_URLS, DEFAULT_AUTO_FETCH_URLS):
|
if options.get(CONF_AUTO_FETCH_URLS, DEFAULT_AUTO_FETCH_URLS):
|
||||||
|
|
@ -716,12 +547,6 @@ class GroqdConversationEntity(
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
tool_response = {"error": type(err).__name__, "error_text": str(err)}
|
tool_response = {"error": type(err).__name__, "error_text": str(err)}
|
||||||
elif llm_api:
|
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_input = llm.ToolInput(
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
tool_args=tool_args,
|
tool_args=tool_args,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"domain": "groqd",
|
"domain": "groqd",
|
||||||
"name": "groqd",
|
"name": "groqd",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://github.com/sudoxreboot/groqd",
|
"documentation": "https://github.com/sudoxreboot/groqd",
|
||||||
"issue_tracker": "https://github.com/sudoxreboot/groqd/issues",
|
"issue_tracker": "https://github.com/sudoxreboot/groqd/issues",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
{
|
{
|
||||||
"entity": {
|
|
||||||
"button": {
|
|
||||||
"clear_memory": {
|
|
||||||
"name": "Clear session memory"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue