* feat: OpenAI-compatible API server platform adapter Salvaged from PR #956, updated for current main. Adds an HTTP API server as a gateway platform adapter that exposes hermes-agent via the OpenAI Chat Completions and Responses APIs. Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat, AnythingLLM, NextChat, ChatBox, etc.) can connect by pointing at http://localhost:8642/v1. Endpoints: - POST /v1/chat/completions — stateless Chat Completions API - POST /v1/responses — stateful Responses API with chaining - GET /v1/responses/{id} — retrieve stored response - DELETE /v1/responses/{id} — delete stored response - GET /v1/models — list hermes-agent as available model - GET /health — health check Features: - Real SSE streaming via stream_delta_callback (uses main's streaming) - In-memory LRU response store for Responses API conversation chaining - Named conversations via 'conversation' parameter - Bearer token auth (optional, via API_SERVER_KEY) - CORS support for browser-based frontends - System prompt layering (frontend system messages on top of core) - Real token usage tracking in responses Integration points: - Platform.API_SERVER in gateway/config.py - _create_adapter() branch in gateway/run.py - API_SERVER_* env vars in hermes_cli/config.py - Env var overrides in gateway/config.py _apply_env_overrides() Changes vs original PR #956: - Removed streaming infrastructure (already on main via stream_consumer.py) - Removed Telegram reply_to_mode (separate feature, not included) - Updated _resolve_model() -> _resolve_gateway_model() - Updated stream_callback -> stream_delta_callback - Updated connect()/disconnect() to use _mark_connected()/_mark_disconnected() - Adapted to current Platform enum (includes MATTERMOST, MATRIX, DINGTALK) Tests: 72 new tests, all passing Docs: API server guide, Open WebUI integration guide, env var reference * feat(whatsapp): make reply prefix configurable via config.yaml Reworked from PR #1764 (ifrederico) to use config.yaml instead of .env. The WhatsApp bridge prepends a header to every outgoing message. This was hardcoded to '⚕ *Hermes Agent*'. Users can now customize or disable it via config.yaml: whatsapp: reply_prefix: '' # disable header reply_prefix: '🤖 *My Bot*\n───\n' # custom prefix How it works: - load_gateway_config() reads whatsapp.reply_prefix from config.yaml and stores it in PlatformConfig.extra['reply_prefix'] - WhatsAppAdapter reads it from config.extra at init - When spawning bridge.js, the adapter passes it as WHATSAPP_REPLY_PREFIX in the subprocess environment - bridge.js handles undefined (default), empty (no header), or custom values with \\n escape support - Self-chat echo suppression uses the configured prefix Also fixes _config_version: was 9 but ENV_VARS_BY_VERSION had a key 10 (TAVILY_API_KEY), so existing users at v9 would never be prompted for Tavily. Bumped to 10 to close the gap. Added a regression test to prevent this from happening again. Credit: ifrederico (PR #1764) for the bridge.js implementation and the config version gap discovery. --------- Co-authored-by: Test <test@test.com>
122 lines
5.0 KiB
Python
122 lines
5.0 KiB
Python
"""Tests for WhatsApp reply_prefix config.yaml support.
|
|
|
|
Covers:
|
|
- config.yaml whatsapp.reply_prefix bridging into PlatformConfig.extra
|
|
- WhatsAppAdapter reading reply_prefix from config.extra
|
|
- Bridge subprocess receiving WHATSAPP_REPLY_PREFIX env var
|
|
- Config version covers all ENV_VARS_BY_VERSION keys (regression guard)
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config bridging from config.yaml
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigYamlBridging:
|
|
"""Test that whatsapp.reply_prefix in config.yaml flows into PlatformConfig."""
|
|
|
|
def test_reply_prefix_bridged_from_yaml(self, tmp_path):
|
|
"""whatsapp.reply_prefix in config.yaml sets PlatformConfig.extra."""
|
|
config_yaml = tmp_path / "config.yaml"
|
|
config_yaml.write_text('whatsapp:\n reply_prefix: "Custom Bot"\n')
|
|
|
|
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
|
from gateway.config import load_gateway_config
|
|
# Need to also patch WHATSAPP_ENABLED so the platform exists
|
|
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
|
config = load_gateway_config()
|
|
|
|
wa_config = config.platforms.get(Platform.WHATSAPP)
|
|
assert wa_config is not None
|
|
assert wa_config.extra.get("reply_prefix") == "Custom Bot"
|
|
|
|
def test_empty_reply_prefix_bridged(self, tmp_path):
|
|
"""Empty string reply_prefix disables the header."""
|
|
config_yaml = tmp_path / "config.yaml"
|
|
config_yaml.write_text('whatsapp:\n reply_prefix: ""\n')
|
|
|
|
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
|
from gateway.config import load_gateway_config
|
|
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
|
config = load_gateway_config()
|
|
|
|
wa_config = config.platforms.get(Platform.WHATSAPP)
|
|
assert wa_config is not None
|
|
assert wa_config.extra.get("reply_prefix") == ""
|
|
|
|
def test_no_whatsapp_section_no_extra(self, tmp_path):
|
|
"""Without whatsapp section, no reply_prefix is set."""
|
|
config_yaml = tmp_path / "config.yaml"
|
|
config_yaml.write_text("timezone: UTC\n")
|
|
|
|
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
|
from gateway.config import load_gateway_config
|
|
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
|
config = load_gateway_config()
|
|
|
|
wa_config = config.platforms.get(Platform.WHATSAPP)
|
|
assert wa_config is not None
|
|
assert "reply_prefix" not in wa_config.extra
|
|
|
|
def test_whatsapp_section_without_reply_prefix(self, tmp_path):
|
|
"""whatsapp section present but without reply_prefix key."""
|
|
config_yaml = tmp_path / "config.yaml"
|
|
config_yaml.write_text("whatsapp:\n other_setting: true\n")
|
|
|
|
with patch("gateway.config.get_hermes_home", return_value=tmp_path):
|
|
from gateway.config import load_gateway_config
|
|
with patch.dict("os.environ", {"WHATSAPP_ENABLED": "true"}, clear=False):
|
|
config = load_gateway_config()
|
|
|
|
wa_config = config.platforms.get(Platform.WHATSAPP)
|
|
assert "reply_prefix" not in wa_config.extra
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WhatsAppAdapter __init__
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAdapterInit:
|
|
"""Test that WhatsAppAdapter reads reply_prefix from config.extra."""
|
|
|
|
def test_reply_prefix_from_extra(self):
|
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
|
config = PlatformConfig(enabled=True, extra={"reply_prefix": "Bot\\n"})
|
|
adapter = WhatsAppAdapter(config)
|
|
assert adapter._reply_prefix == "Bot\\n"
|
|
|
|
def test_reply_prefix_default_none(self):
|
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
|
config = PlatformConfig(enabled=True)
|
|
adapter = WhatsAppAdapter(config)
|
|
assert adapter._reply_prefix is None
|
|
|
|
def test_reply_prefix_empty_string(self):
|
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
|
config = PlatformConfig(enabled=True, extra={"reply_prefix": ""})
|
|
adapter = WhatsAppAdapter(config)
|
|
assert adapter._reply_prefix == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config version regression guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConfigVersionCoverage:
|
|
"""Ensure _config_version covers all ENV_VARS_BY_VERSION keys."""
|
|
|
|
def test_default_config_version_covers_env_var_versions(self):
|
|
"""_config_version must be >= the highest ENV_VARS_BY_VERSION key."""
|
|
from hermes_cli.config import DEFAULT_CONFIG, ENV_VARS_BY_VERSION
|
|
assert DEFAULT_CONFIG["_config_version"] >= max(ENV_VARS_BY_VERSION)
|