mirror of
https://github.com/sudoxreboot/groqd
synced 2026-04-14 19:47:07 +00:00
Add files via upload
updated tool call handling updated light color handling updated searxng handling - all 3 should work now
This commit is contained in:
parent
0760f8ca67
commit
61f0030f33
4 changed files with 235 additions and 8 deletions
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import groq
|
import groq
|
||||||
|
|
||||||
|
|
@ -12,7 +13,7 @@ from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.CONVERSATION]
|
PLATFORMS: list[Platform] = [Platform.CONVERSATION, Platform.BUTTON]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -20,6 +21,7 @@ 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]
|
||||||
|
|
|
||||||
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")
|
||||||
|
|
@ -95,17 +95,153 @@ 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
|
||||||
) -> ChatCompletionToolParam:
|
) -> tuple[ChatCompletionToolParam, dict]:
|
||||||
"""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=convert(tool.parameters, custom_serializer=custom_serializer),
|
parameters=_relax_schema_arrays(original_schema),
|
||||||
)
|
)
|
||||||
if tool.description:
|
if tool.description:
|
||||||
tool_spec["description"] = 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:
|
def _parse_tool_choice(value: str) -> Any:
|
||||||
|
|
@ -198,12 +334,29 @@ 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 = options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL).rstrip("/")
|
base_url = _normalize_searxng_url(options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL))
|
||||||
params = {
|
params = {
|
||||||
"q": tool_args.get("query", ""),
|
"q": tool_args.get("query", ""),
|
||||||
"format": "json",
|
"format": "json",
|
||||||
|
|
@ -269,14 +422,25 @@ 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:
|
||||||
|
|
@ -284,6 +448,7 @@ 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,
|
||||||
|
|
@ -309,7 +474,11 @@ 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
|
||||||
)
|
)
|
||||||
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 options.get(CONF_SEARXNG_ENABLED, DEFAULT_SEARXNG_ENABLED):
|
||||||
if tools is None:
|
if tools is None:
|
||||||
tools = []
|
tools = []
|
||||||
|
|
@ -547,6 +716,12 @@ 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,4 +1,11 @@
|
||||||
{
|
{
|
||||||
|
"entity": {
|
||||||
|
"button": {
|
||||||
|
"clear_memory": {
|
||||||
|
"name": "Clear session memory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue