Merge origin/main into bb/gui (2026-05-24)

Bring 313 commits of upstream main into the bb/gui dashboard
refactor branch.  Eight conflicts resolved by hand, the rest
auto-merged.  One missing class (_StreamErrorEvent) restored from
main after the auto-merger dropped it.

Conflict resolutions:

  apps/dashboard/README.md          take HEAD: main's text described
                                    the pre-rename web/ layout that
                                    bb/gui refactored away.

  apps/dashboard/package.json       combine: keep HEAD's @hermes/shared
                                    workspace dep, take main's
                                    @nous-research/ui 0.16.0 bump.

  apps/dashboard/package-lock.json  regenerate via
                                    npm install --package-lock-only.
                                    Root lock also regenerated; only
                                    dashboard and apps/desktop entries
                                    moved (apps/desktop version 0.0.1 →
                                    0.0.2 to match bb/gui's
                                    package.json bump).

  apps/dashboard/src/pages/         take main (4 hunks): text-xs
    EnvPage.tsx                     replaces text-[0.65rem] per the
                                    typography rule HEAD's own README
                                    documents.

  hermes_cli/gateway.py             take main (2 hunks): Discord
                                    setup metadata moved to plugin
                                    (architectural migration); s6
                                    service-manager dispatch helpers
                                    additive.

  hermes_cli/main.py                combine (2 hunks): take main's
                                    Termux-aware
                                    _sync_bundled_skills_for_startup;
                                    combine gui + portal subcommands
                                    in the known-subcommand list.

  hermes_cli/web_server.py          mixed (10 hunks):
                                    - take main on _PUBLIC_API_PATHS
                                      (bb/gui's own test asserts the
                                      rescan endpoint must require auth)
                                    - combine WS helpers: keep HEAD's
                                      _ws_client_label + main's
                                      Host/Origin guard + composing
                                      _ws_request_is_allowed
                                    - take HEAD's debug-level broadcast
                                      drop log (matches the comment
                                      "subscriber went away mid-send")
                                    - take main's _safe_plugin_api_relpath
                                      GHSA-5qr3-c538-wm9j fix and the
                                      paired discovery-time validation
                                    - take main's {name:path} route
                                      converter for plugin visibility

  tui_gateway/server.py             take main: PR #31379's verbose-
                                    args gating supersedes HEAD's
                                    unconditional args dump on
                                    tool.start.

Post-merge restoration:

  run_agent.py                      restored class _StreamErrorEvent
                                    (40 lines, from origin/main:288).
                                    Auto-merge silently dropped it,
                                    breaking imports in
                                    agent/codex_runtime.py and three
                                    test files
                                    (test_codex_xai_oauth_recovery.py,
                                    test_streaming.py).  Restored
                                    verbatim from main.

Sanity checks:

  * git diff --check / --cached --check: clean (no stray markers)
  * ast.parse + import on all touched .py files: clean
  * targeted pytest on resolved files: 756 passed, 1 pre-existing
    Windows-curses failure unrelated to the merge
  * full pytest_parallel run: 105 files / 391 failures vs baseline
    98 files / 346.  Differential vs origin/bb/gui shows all 11
    "new" failure files come from main's added tests/code and
    reproduce identically against origin/main on the same Windows
    host (pure Windows path-separator / perms / git-bash issues
    in upstream tests, not merge regressions).  4 baseline
    failures fixed: 3 in test_codex_xai_oauth_recovery (the
    _StreamErrorEvent restoration), 1 each in test_pairing,
    test_runner_startup_failures, test_stream_consumer.
  * sentinel-token sweep on main's eight largest commits:
    every audited symbol present in the merged tree at expected
    counts (TTSProvider 61, NtfyAdapter 29, S6ServiceManager 70,
    install_bws 12, security_audit 16, register_image_gen_provider
    23, list_profile_gateways 22, DISCORD_FREE_RESPONSE_CHANNELS
    48, …).
  * byte-diff sweep: 30/30 sampled main-only-modified files
    byte-identical to origin/main; the four bb/gui-only files
    that drifted (i18n/types.ts, i18n/ru.ts, ThemeSwitcher.tsx,
    ToolCall.tsx) correctly absorbed main's web/ → apps/dashboard/
    edits through git's rename detection (main's added lines all
    present, removed lines all absent).
This commit is contained in:
Jeffrey Quesnelle
2026-05-25 00:39:46 -04:00
521 changed files with 40613 additions and 3961 deletions
+182
View File
@@ -0,0 +1,182 @@
"""FAL.ai image generation backend.
Wraps the 18-model FAL catalog (FLUX 2, Z-Image, Nano Banana, GPT
Image 1.5, Recraft, Imagen 4, Qwen, Ideogram, …) as an
:class:`ImageGenProvider` implementation.
The heavy lifting — model catalog, payload construction, request
submission, managed-Nous-gateway selection, Clarity Upscaler chaining
— lives in :mod:`tools.image_generation_tool`. This plugin reaches into
that module via call-time indirection (``import tools.image_generation_tool as _it``)
so:
* the existing test suite (``tests/tools/test_image_generation.py``,
``tests/tools/test_managed_media_gateways.py``) keeps patching
``image_tool._submit_fal_request`` / ``image_tool.fal_client`` /
``image_tool._managed_fal_client`` without modification, and
* there's exactly one canonical FAL code path on disk — the plugin is a
registration adapter, not a parallel implementation.
See issue #26241 for the migration plan and the
``plugin-extraction-test-patch-compatibility.md`` rules this follows.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, List, Optional
from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
resolve_aspect_ratio,
)
logger = logging.getLogger(__name__)
class FalImageGenProvider(ImageGenProvider):
"""FAL.ai image generation backend.
Delegates to ``tools.image_generation_tool.image_generate_tool`` so
the in-tree FAL implementation (model catalog, payload builder,
managed-gateway selection, Clarity Upscaler chaining) is the single
source of truth. Everything is resolved at call time via the
``_it`` indirection so tests can monkey-patch the legacy module.
"""
@property
def name(self) -> str:
return "fal"
@property
def display_name(self) -> str:
return "FAL.ai"
def is_available(self) -> bool:
# Available when direct FAL_KEY is set OR the managed Nous
# gateway resolves a fal-queue origin. Both checks come from the
# legacy module so this provider tracks whatever logic ships
# there.
import tools.image_generation_tool as _it
try:
return bool(_it.check_fal_api_key())
except Exception: # noqa: BLE001 — defensive; never break the picker
return False
def list_models(self) -> List[Dict[str, Any]]:
import tools.image_generation_tool as _it
return [
{
"id": model_id,
"display": meta.get("display", model_id),
"speed": meta.get("speed", ""),
"strengths": meta.get("strengths", ""),
"price": meta.get("price", ""),
}
for model_id, meta in _it.FAL_MODELS.items()
]
def default_model(self) -> Optional[str]:
import tools.image_generation_tool as _it
return _it.DEFAULT_MODEL
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "FAL.ai",
"badge": "paid",
"tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc.",
"env_vars": [
{
"key": "FAL_KEY",
"prompt": "FAL API key",
"url": "https://fal.ai/dashboard/keys",
},
],
}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate an image via the legacy FAL pipeline.
Forwards prompt + aspect_ratio (and any forward-compat extras
the schema supports) into :func:`tools.image_generation_tool.image_generate_tool`,
then reshapes its JSON-string response into the provider-ABC
dict format consumed by ``_dispatch_to_plugin_provider``.
"""
import tools.image_generation_tool as _it
aspect = resolve_aspect_ratio(aspect_ratio)
passthrough = {
key: kwargs[key]
for key in (
"num_inference_steps",
"guidance_scale",
"num_images",
"output_format",
"seed",
)
if key in kwargs and kwargs[key] is not None
}
try:
raw = _it.image_generate_tool(
prompt=prompt,
aspect_ratio=aspect,
**passthrough,
)
except Exception as exc: # noqa: BLE001 — never raise out of generate
logger.warning("FAL image_generate_tool raised: %s", exc, exc_info=True)
return {
"success": False,
"image": None,
"error": f"FAL image generation failed: {exc}",
"error_type": type(exc).__name__,
"provider": "fal",
"prompt": prompt,
"aspect_ratio": aspect,
}
try:
response = json.loads(raw) if isinstance(raw, str) else raw
except Exception: # noqa: BLE001
response = {"success": False, "image": None, "error": "Invalid JSON from FAL pipeline"}
if not isinstance(response, dict):
response = {
"success": False,
"image": None,
"error": "FAL pipeline returned a non-dict response",
"error_type": "provider_contract",
}
# Stamp provider/prompt/aspect_ratio so downstream consumers see
# the uniform shape declared in ``agent.image_gen_provider``.
response.setdefault("provider", "fal")
response.setdefault("prompt", prompt)
response.setdefault("aspect_ratio", aspect)
# Annotate model best-effort — the legacy pipeline resolves it
# internally, so query it after the fact for the response shape.
if "model" not in response:
try:
model_id, _meta = _it._resolve_fal_model()
response["model"] = model_id
except Exception: # noqa: BLE001
pass
return response
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
def register(ctx) -> None:
"""Plugin entry point — wire ``FalImageGenProvider`` into the registry."""
ctx.register_image_gen_provider(FalImageGenProvider())
+7
View File
@@ -0,0 +1,7 @@
name: fal
version: 1.0.0
description: "FAL.ai image generation backend (flux-2-klein, flux-2-pro, nano-banana, gpt-image-1.5, recraft-v3, etc.)."
author: NousResearch
kind: backend
requires_env:
- FAL_KEY
+16 -3
View File
@@ -33,6 +33,7 @@ from agent.image_gen_provider import (
error_response,
resolve_aspect_ratio,
save_b64_image,
save_url_image,
success_response,
)
@@ -266,9 +267,21 @@ class OpenAIImageGenProvider(ImageGenProvider):
)
image_ref = str(saved_path)
elif url:
# Defensive — gpt-image-2 returns b64 today, but fall back
# gracefully if the API ever changes.
image_ref = url
# Defensive — gpt-image-2 returns b64 today, but OpenAI's API
# has previously returned URLs. Cache the bytes locally so the
# gateway never tries to fetch an ephemeral / signed URL after
# it expires — same rationale as the xAI provider (#26942).
try:
saved_path = save_url_image(url, prefix=f"openai_{tier_id}")
except Exception as exc:
logger.warning(
"OpenAI image URL %s could not be cached (%s); falling back to bare URL.",
url,
exc,
)
image_ref = url
else:
image_ref = str(saved_path)
else:
return error_response(
error="OpenAI response contained neither b64_json nor URL",
+19 -1
View File
@@ -29,6 +29,7 @@ from agent.image_gen_provider import (
error_response,
resolve_aspect_ratio,
save_b64_image,
save_url_image,
success_response,
)
from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials
@@ -281,7 +282,24 @@ class XAIImageGenProvider(ImageGenProvider):
)
image_ref = str(saved_path)
elif url:
image_ref = url
# xAI's grok-imagine-image returns ephemeral ``imgen.x.ai/xai-tmp-*``
# URLs that 404 within minutes — by the time Telegram's
# ``send_photo`` or any downstream consumer fetches them, the
# asset is gone (#26942). Materialise the bytes locally at
# tool-completion time so the gateway has a stable file path to
# upload, mirroring the b64 branch above and the audio_cache
# pattern used by text_to_speech.
try:
saved_path = save_url_image(url, prefix=f"xai_{model_id}")
except Exception as exc:
logger.warning(
"xAI image URL %s could not be cached (%s); falling back to bare URL.",
url,
exc,
)
image_ref = url
else:
image_ref = str(saved_path)
else:
return error_response(
error="xAI response contained neither b64_json nor URL",
+58 -25
View File
@@ -47,6 +47,25 @@ _DEFAULT_ENDPOINT = "http://127.0.0.1:1933"
_TIMEOUT = 30.0
_REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://")
# Maps the viking_remember `category` enum to a viking:// subdirectory.
# Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum.
_CATEGORY_SUBDIR_MAP = {
"preference": "preferences",
"entity": "entities",
"event": "events",
"case": "cases",
"pattern": "patterns",
}
_DEFAULT_MEMORY_SUBDIR = "preferences"
# Maps the built-in memory tool's `target` ("user" vs "memory") to a subdir
# for on_memory_write mirroring. User profile facts → preferences; agent
# notes / observations → patterns. Anything unknown falls back to the default.
_MEMORY_WRITE_TARGET_SUBDIR_MAP = {
"user": "preferences",
"memory": "patterns",
}
# ---------------------------------------------------------------------------
# Process-level atexit safety net — ensures pending sessions are committed
@@ -607,24 +626,35 @@ class OpenVikingMemoryProvider(MemoryProvider):
except Exception as e:
logger.warning("OpenViking session commit failed: %s", e)
def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Mirror built-in memory writes to OpenViking as explicit memories."""
def _build_memory_uri(self, subdir: str) -> str:
"""Build a viking:// memory URI under the configured user/subdir."""
slug = uuid.uuid4().hex[:12]
return f"viking://user/{self._user}/memories/{subdir}/mem_{slug}.md"
def on_memory_write(
self,
action: str,
target: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Mirror built-in memory writes to OpenViking via content/write."""
if not self._client or action != "add" or not content:
return
subdir = _MEMORY_WRITE_TARGET_SUBDIR_MAP.get(target, _DEFAULT_MEMORY_SUBDIR)
uri = self._build_memory_uri(subdir)
def _write():
try:
client = _VikingClient(
self._endpoint, self._api_key,
account=self._account, user=self._user, agent=self._agent,
)
# Add as a user message with memory context so the commit
# picks it up as an explicit memory during extraction
client.post(f"/api/v1/sessions/{self._session_id}/messages", {
"role": "user",
"parts": [
{"type": "text", "text": f"[Memory note — {target}] {content}"},
],
client.post("/api/v1/content/write", {
"uri": uri,
"content": content,
"mode": "create",
})
except Exception as e:
logger.debug("OpenViking memory mirror failed: %s", e)
@@ -858,24 +888,27 @@ class OpenVikingMemoryProvider(MemoryProvider):
if not content:
return tool_error("content is required")
# Store as a session message that will be extracted during commit.
# The category hint helps OpenViking's extraction classify correctly.
category = args.get("category", "")
text = f"[Remember] {content}"
if category:
text = f"[Remember — {category}] {content}"
subdir = _CATEGORY_SUBDIR_MAP.get(category, _DEFAULT_MEMORY_SUBDIR)
uri = self._build_memory_uri(subdir)
self._client.post(f"/api/v1/sessions/{self._session_id}/messages", {
"role": "user",
"parts": [
{"type": "text", "text": text},
],
})
return json.dumps({
"status": "stored",
"message": "Memory recorded. Will be extracted and indexed on session commit.",
})
# Write directly via content/write API.
# This creates the file, stores the content, and queues vector indexing
# in a single call — no dependency on session commit / VLM extraction.
try:
result = self._client.post("/api/v1/content/write", {
"uri": uri,
"content": content,
"mode": "create",
})
written = result.get("result", {}).get("written_bytes", 0)
return json.dumps({
"status": "stored",
"message": f"Memory stored ({written}b) and queued for vector indexing.",
})
except Exception as e:
logger.error("OpenViking content/write failed: %s", e)
return tool_error(f"Failed to store memory: {e}")
def _tool_add_resource(self, args: dict) -> str:
url = args.get("url", "")
@@ -7,9 +7,81 @@ Both use per-model api_mode routing:
(this profile)
"""
from __future__ import annotations
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
def _flat_model_name(model: str | None) -> str:
"""Return the bare OpenCode model ID, tolerating aggregator prefixes."""
return (model or "").strip().rsplit("/", 1)[-1].lower()
def _is_kimi_k2_model(model: str | None) -> bool:
return _flat_model_name(model).startswith("kimi-k2")
def _is_deepseek_thinking_model(model: str | None) -> bool:
m = _flat_model_name(model)
if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
return True
return m == "deepseek-reasoner"
class OpenCodeGoProfile(ProviderProfile):
"""OpenCode Go - model-specific reasoning controls."""
def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
) -> tuple[dict[str, Any], dict[str, Any]]:
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
if _is_kimi_k2_model(model):
# Kimi K2 on OpenCode Go uses Moonshot's native wire shape:
# extra_body.thinking (binary toggle) + top-level reasoning_effort
# (low|medium|high). Mirrors the KimiProfile (api.moonshot.ai/v1).
if not isinstance(reasoning_config, dict):
# No config → leave server defaults alone.
return extra_body, top_level
enabled = reasoning_config.get("enabled") is not False
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
if not enabled:
return extra_body, top_level
effort = (reasoning_config.get("effort") or "").strip().lower()
if effort in {"xhigh", "max"}:
top_level["reasoning_effort"] = "high"
elif effort in {"low", "medium", "high"}:
top_level["reasoning_effort"] = effort
return extra_body, top_level
if not _is_deepseek_thinking_model(model):
return extra_body, top_level
enabled = True
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
enabled = False
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
if not enabled:
return extra_body, top_level
if isinstance(reasoning_config, dict):
effort = (reasoning_config.get("effort") or "").strip().lower()
if effort in {"xhigh", "max"}:
top_level["reasoning_effort"] = "max"
elif effort in {"low", "medium", "high"}:
top_level["reasoning_effort"] = effort
return extra_body, top_level
opencode_zen = ProviderProfile(
name="opencode-zen",
aliases=("opencode", "opencode_zen", "zen"),
@@ -18,7 +90,7 @@ opencode_zen = ProviderProfile(
default_aux_model="gemini-3-flash",
)
opencode_go = ProviderProfile(
opencode_go = OpenCodeGoProfile(
name="opencode-go",
aliases=("opencode_go", "go", "opencode-go-sub"),
env_vars=("OPENCODE_GO_API_KEY",),
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
name: discord-platform
label: Discord
kind: platform
version: 1.0.0
description: >
Discord gateway adapter for Hermes Agent.
Connects to Discord via the discord.py library and relays messages
between Discord guilds/DMs and the Hermes agent. Supports voice mode,
slash commands, free-response channels, role-based DM auth, threads,
reactions, and channel skill bindings.
author: NousResearch
requires_env:
- name: DISCORD_BOT_TOKEN
description: "Discord bot token"
prompt: "Discord bot token"
url: "https://discord.com/developers/applications"
password: true
optional_env:
- name: DISCORD_ALLOWED_USERS
description: "Comma-separated Discord user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: DISCORD_ALLOW_ALL_USERS
description: "Allow any Discord user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: DISCORD_HOME_CHANNEL
description: "Default channel ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: DISCORD_HOME_CHANNEL_NAME
description: "Display name for the Discord home channel"
prompt: "Home channel display name"
password: false
+50 -22
View File
@@ -61,6 +61,8 @@ import json
import logging
import os
import re
import secrets
import stat
import subprocess
import sys
from pathlib import Path
@@ -89,6 +91,8 @@ except (ModuleNotFoundError, ImportError):
except ValueError:
return str(home)
from utils import atomic_replace
def _hermes_home() -> Path:
"""Resolve HERMES_HOME at call time (NOT module import).
@@ -296,14 +300,11 @@ def list_authorized_emails() -> List[str]:
def _persist_credentials(creds: Any, token_path: Path) -> None:
"""Atomic-ish JSON write of refreshed credentials."""
"""Persist refreshed credentials atomically with private permissions."""
try:
token_path.parent.mkdir(parents=True, exist_ok=True)
token_path.write_text(
json.dumps(
_normalize_authorized_user_payload(json.loads(creds.to_json())),
indent=2,
)
_write_private_json(
token_path,
_normalize_authorized_user_payload(json.loads(creds.to_json())),
)
except Exception:
logger.debug(
@@ -325,6 +326,38 @@ def _normalize_authorized_user_payload(payload: dict) -> dict:
return normalized
def _write_private_json(path: Path, data: Any) -> None:
"""Atomically write JSON with 0o600 permissions where supported."""
path.parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path.parent, 0o700)
except OSError:
pass
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
fd = os.open(
str(tmp_path),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IRUSR | stat.S_IWUSR,
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.flush()
os.fsync(fh.fileno())
atomic_replace(tmp_path, path)
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
def _ensure_deps() -> None:
"""Check deps available; install if not; exit on failure."""
try:
@@ -402,25 +435,21 @@ def store_client_secret(path: str) -> None:
sys.exit(1)
target = _client_secret_path()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(data, indent=2))
_write_private_json(target, data)
print(f"OK: Client secret saved to {target}")
def _save_pending_auth(*, state: str, code_verifier: str,
email: Optional[str] = None) -> None:
pending = _pending_auth_path(email)
pending.parent.mkdir(parents=True, exist_ok=True)
pending.write_text(
json.dumps(
{
"state": state,
"code_verifier": code_verifier,
"redirect_uri": _REDIRECT_URI,
"email": email or "",
},
indent=2,
)
_write_private_json(
pending,
{
"state": state,
"code_verifier": code_verifier,
"redirect_uri": _REDIRECT_URI,
"email": email or "",
},
)
@@ -548,8 +577,7 @@ def exchange_auth_code(code: str, email: Optional[str] = None) -> None:
token_payload["scopes"] = granted_scopes
token_path = _token_path(email)
token_path.parent.mkdir(parents=True, exist_ok=True)
token_path.write_text(json.dumps(token_payload, indent=2))
_write_private_json(token_path, token_payload)
_pending_auth_path(email).unlink(missing_ok=True)
print(f"OK: Authenticated. Token saved to {token_path}")
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
name: mattermost-platform
label: Mattermost
kind: platform
version: 1.0.0
description: >
Mattermost gateway adapter for Hermes Agent.
Connects to a self-hosted or cloud Mattermost instance via the v4 REST
API + WebSocket event stream and relays messages between Mattermost
channels/DMs and the Hermes agent. Supports thread-mode replies, native
file uploads, channel-scoped allowlists, and home-channel cron delivery.
author: NousResearch
requires_env:
- name: MATTERMOST_URL
description: "Mattermost server URL (e.g. https://mm.example.com)"
prompt: "Mattermost server URL"
password: false
- name: MATTERMOST_TOKEN
description: "Bot account token or personal-access token"
prompt: "Mattermost bot token"
password: true
optional_env:
- name: MATTERMOST_ALLOWED_USERS
description: "Comma-separated Mattermost user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: MATTERMOST_ALLOW_ALL_USERS
description: "Allow any Mattermost user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: MATTERMOST_HOME_CHANNEL
description: "Default channel ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: MATTERMOST_REPLY_MODE
description: "How replies are sent: 'thread' (nested) or 'off' (flat). Default: off."
prompt: "Reply mode (thread|off)"
password: false
- name: MATTERMOST_REQUIRE_MENTION
description: "Require @bot mention in channels (default true). Set false for free-response everywhere."
prompt: "Require @mention? (true/false)"
password: false
- name: MATTERMOST_FREE_RESPONSE_CHANNELS
description: "Comma-separated channel IDs where @mention is not required."
prompt: "Free-response channel IDs (comma-separated)"
password: false
- name: MATTERMOST_ALLOWED_CHANNELS
description: "If set, the bot only responds in these channels (whitelist)."
prompt: "Allowed channel IDs (comma-separated)"
password: false
+3
View File
@@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]
+582
View File
@@ -0,0 +1,582 @@
"""ntfy platform adapter (Hermes plugin).
Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via
HTTP streaming (``/json`` endpoint with ``poll=false``) and publishes
replies via HTTP POST. No external SDK — only httpx, which is already
a Hermes dependency.
This adapter ships as a Hermes platform plugin under
``plugins/platforms/ntfy/``. The Hermes plugin loader scans the
directory at startup, calls :func:`register`, and the platform becomes
available to ``gateway/run.py`` and ``tools/send_message_tool`` through
the registry — no edits to core files required.
Configuration in config.yaml::
platforms:
ntfy:
enabled: true
extra:
server: "https://ntfy.sh" # or self-hosted URL
topic: "hermes-in" # subscribe topic (incoming)
publish_topic: "hermes-out" # optional — defaults to topic
token: "..." # optional Bearer / Basic auth token
markdown: true # optional — enable markdown (default: false)
Environment variables (all read at adapter construct time, env wins over
config.yaml ``extra``):
NTFY_TOPIC Topic to subscribe to (required)
NTFY_SERVER_URL Server URL (default: https://ntfy.sh)
NTFY_TOKEN Bearer token or 'user:pass' for Basic auth
NTFY_PUBLISH_TOPIC Reply topic (defaults to NTFY_TOPIC)
NTFY_MARKDOWN "true"/"1"/"yes" enables X-Markdown header
NTFY_ALLOWED_USERS Allowlist (treated by gateway as user IDs;
on ntfy these are topic names)
NTFY_ALLOW_ALL_USERS Allow any topic — dev only
NTFY_HOME_CHANNEL Default topic for cron / notification delivery
NTFY_HOME_CHANNEL_NAME Human label for the home channel
Identity model: ntfy has no native authenticated user identity. The
``title`` field is publisher-controlled and is NOT used for
authorization. Each topic is treated as a single trusted channel —
``user_id`` is fixed to the topic name. Use a private topic protected
by a read token for any real trust boundary.
"""
import asyncio
import json
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
httpx = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
logger = logging.getLogger(__name__)
class _FatalStreamError(Exception):
"""Raised when a stream error is unrecoverable (e.g. 401, 404)."""
DEFAULT_SERVER = "https://ntfy.sh"
MAX_MESSAGE_LENGTH = 4096 # ntfy message body limit
DEDUP_WINDOW_SECONDS = 300
DEDUP_MAX_SIZE = 1000
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
def _build_auth_header(token: str) -> Dict[str, str]:
"""Build an ``Authorization`` header from an ntfy token.
Shared by :class:`NtfyAdapter._auth_headers` and :func:`_standalone_send`
so both paths follow the same auth shape and whitespace-stripping rules.
Tokens are stripped of surrounding whitespace — pasted tokens often
carry trailing newlines that would otherwise render the header
malformed (``Authorization: Bearer foo\\n``). ``user:pass`` tokens
become Basic auth; anything else is treated as a Bearer token.
Returns ``{}`` when no token is configured.
"""
if not token:
return {}
token = token.strip()
if not token:
return {}
if ":" in token:
import base64
encoded = base64.b64encode(token.encode()).decode()
return {"Authorization": f"Basic {encoded}"}
return {"Authorization": f"Bearer {token}"}
def _truncate_body(message: str, *, context: str) -> bytes:
"""Apply the ntfy 4096-char limit, logging a warning on truncation.
``context`` is included in the log message so adapter and standalone
truncations can be told apart in logs.
"""
if len(message) > MAX_MESSAGE_LENGTH:
logger.warning(
"%s: truncating message from %d to %d chars (ntfy limit)",
context, len(message), MAX_MESSAGE_LENGTH,
)
return message[:MAX_MESSAGE_LENGTH].encode("utf-8")
def check_requirements() -> bool:
"""Check whether the ntfy adapter is installable and minimally configured.
Reads ``NTFY_TOPIC`` directly to avoid the cost of a full
``load_gateway_config()`` (which also writes to ``os.environ``) on
every pre-flight check.
"""
if not HTTPX_AVAILABLE:
return False
topic = os.getenv("NTFY_TOPIC", "").strip()
return bool(topic)
def validate_config(config) -> bool:
"""Validate that the configured ntfy platform has a topic set."""
extra = getattr(config, "extra", {}) or {}
topic = extra.get("topic") or os.getenv("NTFY_TOPIC", "")
return bool(topic)
def is_connected(config) -> bool:
"""Check whether ntfy is configured (env or config.yaml)."""
extra = getattr(config, "extra", {}) or {}
topic = os.getenv("NTFY_TOPIC") or extra.get("topic", "")
return bool(topic)
class NtfyAdapter(BasePlatformAdapter):
"""ntfy adapter.
Subscribes to a topic via HTTP streaming (``/json`` endpoint) and
publishes replies via HTTP POST. No external SDK — only httpx.
"""
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
def __init__(self, config: PlatformConfig):
platform = Platform("ntfy")
super().__init__(config=config, platform=platform)
extra = config.extra or {}
self._server: str = (
extra.get("server")
or os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER)
).rstrip("/")
self._topic: str = extra.get("topic") or os.getenv("NTFY_TOPIC", "")
self._publish_topic: str = (
extra.get("publish_topic")
or os.getenv("NTFY_PUBLISH_TOPIC", "")
or self._topic
)
self._token: str = extra.get("token") or os.getenv("NTFY_TOKEN", "")
self._stream_task: Optional[asyncio.Task] = None
self._http_client: Optional["httpx.AsyncClient"] = None
# Message deduplication: msg_id -> timestamp
self._seen_messages: Dict[str, float] = {}
# -- Connection lifecycle -----------------------------------------------
async def connect(self) -> bool:
"""Connect to ntfy by starting the streaming subscription task."""
if not HTTPX_AVAILABLE:
logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name)
return False
if not self._topic:
logger.warning("[%s] NTFY_TOPIC not configured", self.name)
return False
try:
self._http_client = httpx.AsyncClient(timeout=None)
self._stream_task = asyncio.create_task(self._run_stream())
self._mark_connected()
logger.info("[%s] Connected — subscribing to %s/%s", self.name, self._server, self._topic)
return True
except Exception as e:
logger.error("[%s] Failed to connect: %s", self.name, e)
return False
async def _run_stream(self) -> None:
"""Subscribe to the ntfy topic with automatic reconnection."""
backoff_idx = 0
stream_start: float = 0.0
url = f"{self._server}/{self._topic}/json"
headers = self._auth_headers()
while self._running:
try:
logger.debug("[%s] Opening stream to %s", self.name, url)
stream_start = time.monotonic()
await self._consume_stream(url, headers)
except asyncio.CancelledError:
return
except _FatalStreamError:
self._running = False
return
except Exception as e:
if not self._running:
return
logger.warning("[%s] Stream error: %s", self.name, e)
if not self._running:
return
# Reset backoff if stream stayed alive for at least 60s
if time.monotonic() - stream_start >= 60.0:
backoff_idx = 0
delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
logger.info("[%s] Reconnecting in %ds...", self.name, delay)
await asyncio.sleep(delay)
backoff_idx += 1
async def _consume_stream(self, url: str, headers: Dict[str, str]) -> None:
"""Open an HTTP streaming connection and dispatch events."""
# poll=false keeps a persistent streaming connection alive with keepalive events
params = {"poll": "false"}
async with self._http_client.stream(
"GET",
url,
headers=headers,
params=params,
timeout=httpx.Timeout(connect=15.0, read=STREAM_TIMEOUT_SECONDS, write=15.0, pool=15.0),
) as response:
if response.status_code == 401:
logger.error(
"[%s] Authentication failed (401) — stopping reconnect loop. Check NTFY_TOKEN.",
self.name,
)
self._set_fatal_error(
"ntfy_unauthorized",
"ntfy server rejected auth (401). Check NTFY_TOKEN.",
retryable=False,
)
raise _FatalStreamError("401 Unauthorized")
if response.status_code == 404:
logger.error(
"[%s] Topic not found (404): %s — stopping reconnect loop.",
self.name, self._topic,
)
self._set_fatal_error(
"ntfy_topic_not_found",
f"ntfy topic '{self._topic}' returned 404. Check NTFY_TOPIC.",
retryable=False,
)
raise _FatalStreamError("404 Not Found")
response.raise_for_status()
async for line in response.aiter_lines():
if not self._running:
return
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("event") == "message":
await self._on_message(event)
async def disconnect(self) -> None:
"""Disconnect from ntfy."""
self._running = False
self._mark_disconnected()
if self._stream_task:
self._stream_task.cancel()
try:
await self._stream_task
except asyncio.CancelledError:
pass
self._stream_task = None
if self._http_client:
await self._http_client.aclose()
self._http_client = None
self._seen_messages.clear()
logger.info("[%s] Disconnected", self.name)
# -- Inbound message processing -----------------------------------------
async def _on_message(self, event: Dict[str, Any]) -> None:
"""Process an incoming ntfy message event."""
msg_id = event.get("id") or uuid.uuid4().hex
if self._is_duplicate(msg_id):
logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
return
text = (event.get("message") or "").strip()
if not text:
logger.debug("[%s] Empty message body, skipping", self.name)
return
topic = event.get("topic") or self._topic
# ntfy has no native authenticated user identity. The title field is
# publisher-controlled and must NOT be used for authorization — any
# publisher who knows the topic can set title to an allowed username.
# Treat ntfy as a single trusted channel; user_id is fixed to the
# topic name. NTFY_ALLOWED_USERS is only a real trust boundary when
# the topic itself is protected by a read token.
user_id = topic
user_name = topic
source = self.build_source(
chat_id=topic,
chat_name=topic,
chat_type="dm",
user_id=user_id,
user_name=user_name,
)
unix_ts = event.get("time")
try:
timestamp = (
datetime.fromtimestamp(int(unix_ts), tz=timezone.utc)
if unix_ts else datetime.now(tz=timezone.utc)
)
except (ValueError, OSError, TypeError):
timestamp = datetime.now(tz=timezone.utc)
message_event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id=msg_id,
raw_message=event,
timestamp=timestamp,
)
logger.debug("[%s] Message on topic %s: %s", self.name, topic, text[:80])
await self.handle_message(message_event)
# -- Deduplication ------------------------------------------------------
def _is_duplicate(self, msg_id: str) -> bool:
"""Return True if this message ID was already seen within the dedup window."""
now = time.time()
if len(self._seen_messages) > DEDUP_MAX_SIZE:
cutoff = now - DEDUP_WINDOW_SECONDS
self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff}
if msg_id in self._seen_messages:
return True
self._seen_messages[msg_id] = now
return False
# -- Outbound messaging -------------------------------------------------
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Publish a message to the configured publish topic."""
metadata = metadata or {}
publish_topic = metadata.get("publish_topic") or self._publish_topic or chat_id
if not self._http_client:
return SendResult(success=False, error="HTTP client not initialized")
url = f"{self._server}/{publish_topic}"
markdown_enabled = (self.config.extra or {}).get("markdown", False)
headers = {**self._auth_headers(), "Content-Type": "text/plain; charset=utf-8"}
if markdown_enabled:
headers["X-Markdown"] = "true"
if len(content) > self.MAX_MESSAGE_LENGTH:
logger.warning(
"[%s] Message truncated from %d to %d chars (ntfy limit)",
self.name, len(content), self.MAX_MESSAGE_LENGTH,
)
body = content[:self.MAX_MESSAGE_LENGTH]
try:
resp = await self._http_client.post(
url, content=body.encode("utf-8"), headers=headers, timeout=15.0,
)
if resp.status_code < 300:
try:
data = resp.json()
returned_id = data.get("id") or uuid.uuid4().hex[:12]
except Exception:
returned_id = uuid.uuid4().hex[:12]
return SendResult(success=True, message_id=returned_id)
body_text = resp.text
logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body_text[:200])
return SendResult(success=False, error=f"HTTP {resp.status_code}: {body_text[:200]}")
except httpx.TimeoutException:
return SendResult(success=False, error="Timeout publishing to ntfy")
except Exception as e:
logger.error("[%s] Send error: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""ntfy does not support typing indicators."""
pass
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return basic info about an ntfy topic."""
return {"name": chat_id, "type": "dm"}
# -- Helpers ------------------------------------------------------------
def _auth_headers(self) -> Dict[str, str]:
"""Build Authorization header if a token is configured."""
return _build_auth_header(self._token)
# ---------------------------------------------------------------------------
# Plugin registration
# ---------------------------------------------------------------------------
def _env_enablement() -> dict | None:
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
Called by the platform registry's env-enablement hook BEFORE adapter
construction, so ``gateway status`` and ``get_connected_platforms()``
reflect env-only configuration without instantiating the HTTP client.
Returns ``None`` when ntfy isn't minimally configured; the caller skips
auto-enabling.
The special ``home_channel`` key in the returned dict is handled by the
core hook — it becomes a proper ``HomeChannel`` dataclass on the
``PlatformConfig`` rather than being merged into ``extra``.
"""
topic = os.getenv("NTFY_TOPIC", "").strip()
if not topic:
return None
seed: dict = {
"topic": topic,
"server": os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER).rstrip("/"),
}
publish_topic = os.getenv("NTFY_PUBLISH_TOPIC", "").strip()
if publish_topic:
seed["publish_topic"] = publish_topic
token = os.getenv("NTFY_TOKEN", "").strip()
if token:
seed["token"] = token
markdown = os.getenv("NTFY_MARKDOWN", "").strip().lower()
if markdown:
seed["markdown"] = markdown in ("1", "true", "yes")
home = os.getenv("NTFY_HOME_CHANNEL", "").strip() or topic
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("NTFY_HOME_CHANNEL_NAME", home),
}
return seed
async def _standalone_send(
pconfig,
chat_id: str,
message: str,
*,
thread_id: Optional[str] = None,
media_files: Optional[List[str]] = None,
force_document: bool = False,
) -> Dict[str, Any]:
"""Out-of-process publish for cron / send_message_tool fallbacks.
Used by ``tools/send_message_tool._send_via_adapter`` and the cron
scheduler when the gateway runner is not in this process (e.g.
``hermes cron`` running standalone). Without this hook,
``deliver=ntfy`` cron jobs fail with ``No live adapter for platform``.
``thread_id`` and ``media_files`` are accepted for signature parity
only — ntfy has no thread or attachment primitive. Markdown is
honored if ``NTFY_MARKDOWN`` is set OR ``pconfig.extra["markdown"]``
is True.
"""
if not HTTPX_AVAILABLE:
return {"error": "ntfy standalone send: httpx not installed"}
extra = getattr(pconfig, "extra", {}) or {}
server = (
extra.get("server")
or os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER)
).rstrip("/")
publish_topic = (
chat_id
or extra.get("publish_topic")
or os.getenv("NTFY_PUBLISH_TOPIC", "").strip()
or extra.get("topic")
or os.getenv("NTFY_TOPIC", "").strip()
)
if not publish_topic:
return {"error": "ntfy standalone send: NTFY_TOPIC not configured"}
token = extra.get("token") or os.getenv("NTFY_TOKEN", "")
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
headers = {"Content-Type": "text/plain; charset=utf-8", **_build_auth_header(token)}
if markdown_enabled:
headers["X-Markdown"] = "true"
body = _truncate_body(message, context="ntfy standalone")
url = f"{server}/{publish_topic}"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(url, content=body, headers=headers)
if resp.status_code >= 300:
return {"error": f"ntfy HTTP {resp.status_code}: {resp.text[:200]}"}
try:
data = resp.json()
msg_id = data.get("id") or uuid.uuid4().hex[:12]
except Exception:
msg_id = uuid.uuid4().hex[:12]
return {"success": True, "platform": "ntfy", "chat_id": publish_topic, "message_id": msg_id}
except Exception as e:
return {"error": f"ntfy standalone send failed: {e}"}
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup."""
ctx.register_platform(
name="ntfy",
label="ntfy",
adapter_factory=lambda cfg: NtfyAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["NTFY_TOPIC"],
install_hint="pip install httpx # already a Hermes dependency",
# Env-driven auto-configuration: seeds PlatformConfig.extra so
# env-only setups show up in `hermes gateway status` without
# instantiating the HTTP client.
env_enablement_fn=_env_enablement,
# Cron home-channel delivery support — `deliver=ntfy` cron jobs
# route to NTFY_HOME_CHANNEL when set.
cron_deliver_env_var="NTFY_HOME_CHANNEL",
# Out-of-process cron delivery. Without this hook, deliver=ntfy
# cron jobs fail with "No live adapter" when cron runs separately
# from the gateway.
standalone_sender_fn=_standalone_send,
# Auth env vars for _is_user_authorized() integration.
allowed_users_env="NTFY_ALLOWED_USERS",
allow_all_env="NTFY_ALLOW_ALL_USERS",
max_message_length=MAX_MESSAGE_LENGTH,
emoji="🔔",
# ntfy publishers have no persistent identity — topic names are
# the only identifier, no phone numbers / emails to redact.
pii_safe=True,
allow_update_command=True,
platform_hint=(
"You are communicating via ntfy push notifications. "
"Use plain text by default — ntfy supports optional markdown "
"(set markdown: true in config or NTFY_MARKDOWN=true). "
"Keep responses concise; ntfy is a push notification service "
"with a 4096-character per-message limit."
),
)
+56
View File
@@ -0,0 +1,56 @@
name: ntfy-platform
label: ntfy
kind: platform
version: 1.0.0
description: >
ntfy push-notification gateway adapter for Hermes Agent.
Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via
HTTP streaming, and publishes replies via HTTP POST. Lightweight —
no external SDK, only httpx (already a Hermes dependency).
ntfy has no native user-identity primitive; the adapter treats each
topic as a single trusted channel and never derives user identity
from publisher-controlled fields. Use a private topic + read token
for any real trust boundary.
author: sprmn24
# ``requires_env`` and ``optional_env`` entries are surfaced in the
# ``hermes config`` UI via the platform-plugin env var injector in
# ``hermes_cli/config.py``.
requires_env:
- name: NTFY_TOPIC
description: "Topic name to subscribe to (e.g. hermes-in)"
prompt: "ntfy subscribe topic"
password: false
optional_env:
- name: NTFY_SERVER_URL
description: "ntfy server URL (default: https://ntfy.sh)"
prompt: "ntfy server URL"
password: false
- name: NTFY_TOKEN
description: "Bearer token or 'user:pass' for Basic auth (optional)"
prompt: "ntfy auth token (or empty)"
password: true
- name: NTFY_PUBLISH_TOPIC
description: "Topic to publish replies to (defaults to NTFY_TOPIC)"
prompt: "ntfy publish topic (or empty)"
password: false
- name: NTFY_MARKDOWN
description: "Send replies with X-Markdown: true header (true/false, default: false)"
prompt: "Enable markdown formatting? (true/false)"
password: false
- name: NTFY_ALLOWED_USERS
description: "Comma-separated topic names allowed (allowlist)"
prompt: "Allowed topic names (comma-separated)"
password: false
- name: NTFY_ALLOW_ALL_USERS
description: "Allow any topic to talk to the bot (dev only — disables allowlist)"
prompt: "Allow all topics? (true/false)"
password: false
- name: NTFY_HOME_CHANNEL
description: "Default topic for cron / notification delivery"
prompt: "Home channel topic (or empty)"
password: false
- name: NTFY_HOME_CHANNEL_NAME
description: "Human label for the home channel (defaults to the topic name)"
prompt: "Home channel display name (or empty)"
password: false
+9 -5
View File
@@ -282,20 +282,24 @@ def _build_payload(
# ---------------------------------------------------------------------------
# fal_client lazy import (same pattern as image_generation_tool)
# fal_client lazy import (shared with image_generation_tool via fal_common)
# ---------------------------------------------------------------------------
_fal_client: Any = None
def _load_fal_client() -> Any:
"""Lazy-load the ``fal_client`` SDK and cache it on this module.
Delegates the actual import to :func:`tools.fal_common.import_fal_client`
so the ``lazy_deps`` ensure-install handling stays in one place.
"""
global _fal_client
if _fal_client is not None:
return _fal_client
import fal_client # type: ignore
_fal_client = fal_client
return fal_client
from tools.fal_common import import_fal_client
_fal_client = import_fal_client()
return _fal_client
# ---------------------------------------------------------------------------
+3 -3
View File
@@ -11,7 +11,7 @@ Originally salvaged from PR #10600 by @Jaaneek; reshaped into the
generate-only surface.
Authentication: xAI Grok OAuth tokens (preferred — billed against the
user's SuperGrok subscription) or ``XAI_API_KEY``. Both routes are
user's SuperGrok or X Premium+ subscription) or ``XAI_API_KEY``. Both routes are
resolved through ``tools.xai_http.resolve_xai_http_credentials`` so a
single login covers chat + TTS + image gen + video gen + transcription.
Output is an HTTPS URL from xAI's CDN; the gateway downloads and
@@ -216,7 +216,7 @@ class XAIVideoGenProvider(VideoGenProvider):
# Auth resolution lives entirely in the shared ``xai_grok`` post_setup
# hook (``hermes_cli/tools_config.py``) so the picker doesn't blindly
# prompt for an API key when the user is already signed in via xAI
# Grok OAuth (SuperGrok Subscription) — TTS / image gen / video gen
# Grok OAuth (SuperGrok / Premium+) — TTS / image gen / video gen
# all share the same credential resolver. The hook offers an
# OAuth-vs-API-key choice when neither is configured.
return {
@@ -295,7 +295,7 @@ class XAIVideoGenProvider(VideoGenProvider):
return error_response(
error=(
"No xAI credentials found. Sign in via `hermes auth add xai-oauth` "
"(SuperGrok subscription) or set XAI_API_KEY from "
"(SuperGrok / Premium+) or set XAI_API_KEY from "
"https://console.x.ai/."
),
error_type="auth_required",