mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(supermemory): support custom base URL for self-hosted servers
The supermemory SDK already honors SUPERMEMORY_BASE_URL, but the raw urllib call used for session-end conversation ingest hardcoded https://api.supermemory.ai/v4/conversations, so ingest always hit the cloud even when pointing at a self-hosted server (e.g. http://localhost:6767). Resolve the base URL as config (supermemory.json base_url) > SUPERMEMORY_BASE_URL env var > https://api.supermemory.ai, strip any trailing slash, and use it for both the SDK client and the /v4/conversations ingest endpoint.
This commit is contained in:
parent
244dabbd9c
commit
24ac26a3da
2 changed files with 90 additions and 5 deletions
|
|
@ -31,7 +31,7 @@ _VALID_SEARCH_MODES = ("hybrid", "memories", "documents")
|
|||
_DEFAULT_API_TIMEOUT = 5.0
|
||||
_MIN_CAPTURE_LENGTH = 10
|
||||
_MAX_ENTITY_CONTEXT_LENGTH = 1500
|
||||
_CONVERSATIONS_URL = "https://api.supermemory.ai/v4/conversations"
|
||||
_DEFAULT_BASE_URL = "https://api.supermemory.ai"
|
||||
_API_KEY_URL = "http://app.supermemory.ai/integrations?connect=hermes"
|
||||
_TRIVIAL_RE = re.compile(
|
||||
r"^(ok|okay|thanks|thank you|got it|sure|yes|no|yep|nope|k|ty|thx|np)\.?$",
|
||||
|
|
@ -65,6 +65,7 @@ def _default_config() -> dict:
|
|||
"search_mode": _DEFAULT_SEARCH_MODE,
|
||||
"entity_context": _DEFAULT_ENTITY_CONTEXT,
|
||||
"api_timeout": _DEFAULT_API_TIMEOUT,
|
||||
"base_url": "",
|
||||
"enable_custom_container_tags": False,
|
||||
"custom_containers": [],
|
||||
"custom_container_instructions": "",
|
||||
|
|
@ -77,6 +78,15 @@ def _sanitize_tag(raw: str) -> str:
|
|||
return tag.strip("_") or _DEFAULT_CONTAINER_TAG
|
||||
|
||||
|
||||
def _resolve_base_url(config_value: Any = "") -> str:
|
||||
"""Resolve the API base URL: config > SUPERMEMORY_BASE_URL env var > default.
|
||||
|
||||
Supports self-hosted Supermemory servers (e.g. http://localhost:6767).
|
||||
"""
|
||||
raw = str(config_value or "").strip() or os.environ.get("SUPERMEMORY_BASE_URL", "").strip()
|
||||
return (raw or _DEFAULT_BASE_URL).rstrip("/") or _DEFAULT_BASE_URL
|
||||
|
||||
|
||||
def _clamp_entity_context(text: str) -> str:
|
||||
if not text:
|
||||
return _DEFAULT_ENTITY_CONTEXT
|
||||
|
|
@ -129,6 +139,7 @@ def _load_supermemory_config(hermes_home: str) -> dict:
|
|||
config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT))))
|
||||
except Exception:
|
||||
config["api_timeout"] = _DEFAULT_API_TIMEOUT
|
||||
config["base_url"] = str(config.get("base_url", "") or "").strip()
|
||||
|
||||
# Multi-container support
|
||||
config["enable_custom_container_tags"] = _as_bool(config.get("enable_custom_container_tags"), False)
|
||||
|
|
@ -263,7 +274,8 @@ def _is_trivial_message(text: str) -> bool:
|
|||
|
||||
|
||||
class _SupermemoryClient:
|
||||
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
|
||||
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid",
|
||||
base_url: str = ""):
|
||||
# Lazy-install the supermemory SDK on demand. ensure() honors
|
||||
# security.allow_lazy_installs (default true) and, on a sealed Docker
|
||||
# venv, redirects the install to the durable target. On failure we
|
||||
|
|
@ -276,15 +288,16 @@ class _SupermemoryClient:
|
|||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from supermemory import Supermemory
|
||||
|
||||
self._api_key = api_key
|
||||
self._container_tag = container_tag
|
||||
self._search_mode = search_mode if search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE
|
||||
self._timeout = timeout
|
||||
self._base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/") or _DEFAULT_BASE_URL
|
||||
self._client = Supermemory(
|
||||
api_key=api_key,
|
||||
base_url=self._base_url,
|
||||
timeout=timeout,
|
||||
max_retries=0,
|
||||
default_headers={"x-sm-source": "hermes"},
|
||||
|
|
@ -388,7 +401,7 @@ class _SupermemoryClient:
|
|||
payload["metadata"] = self._merge_metadata(metadata)
|
||||
|
||||
req = urllib.request.Request(
|
||||
_CONVERSATIONS_URL,
|
||||
f"{self._base_url}/v4/conversations",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
|
|
@ -531,6 +544,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
|||
self._search_mode = _DEFAULT_SEARCH_MODE
|
||||
self._entity_context = _DEFAULT_ENTITY_CONTEXT
|
||||
self._api_timeout = _DEFAULT_API_TIMEOUT
|
||||
self._base_url = _DEFAULT_BASE_URL
|
||||
self._hermes_home = ""
|
||||
self._write_enabled = True
|
||||
self._active = False
|
||||
|
|
@ -645,6 +659,9 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
|||
self._search_mode = self._config["search_mode"]
|
||||
self._entity_context = self._config["entity_context"]
|
||||
self._api_timeout = self._config["api_timeout"]
|
||||
# Base URL: config > SUPERMEMORY_BASE_URL env var > api.supermemory.ai.
|
||||
# Supports self-hosted Supermemory servers.
|
||||
self._base_url = _resolve_base_url(self._config["base_url"])
|
||||
self._enable_custom_containers = self._config["enable_custom_container_tags"]
|
||||
self._custom_containers = self._config["custom_containers"]
|
||||
self._custom_container_instructions = self._config["custom_container_instructions"]
|
||||
|
|
@ -663,6 +680,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
|||
timeout=self._api_timeout,
|
||||
container_tag=self._container_tag,
|
||||
search_mode=self._search_mode,
|
||||
base_url=self._base_url,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Supermemory initialization failed", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ from plugins.memory.supermemory import (
|
|||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
|
||||
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid",
|
||||
base_url: str = ""):
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.container_tag = container_tag
|
||||
self.search_mode = search_mode
|
||||
self.base_url = base_url
|
||||
self.add_calls = []
|
||||
self.search_results = []
|
||||
self.profile_response = {"static": [], "dynamic": [], "search_results": []}
|
||||
|
|
@ -373,6 +375,71 @@ def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path):
|
|||
assert p._search_mode == "hybrid"
|
||||
|
||||
|
||||
# -- Base URL tests -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_base_url_defaults_to_cloud(monkeypatch, tmp_path):
|
||||
"""Without config or env override, the client targets api.supermemory.ai."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.delenv("SUPERMEMORY_BASE_URL", raising=False)
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._base_url == "https://api.supermemory.ai"
|
||||
assert p._client.base_url == "https://api.supermemory.ai"
|
||||
|
||||
|
||||
def test_base_url_env_var_override(monkeypatch, tmp_path):
|
||||
"""SUPERMEMORY_BASE_URL points the provider at a self-hosted server (trailing slash stripped)."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setenv("SUPERMEMORY_BASE_URL", "http://localhost:6767/")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._base_url == "http://localhost:6767"
|
||||
assert p._client.base_url == "http://localhost:6767"
|
||||
|
||||
|
||||
def test_base_url_config_overrides_env(monkeypatch, tmp_path):
|
||||
"""base_url in supermemory.json takes precedence over the env var."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setenv("SUPERMEMORY_BASE_URL", "http://env-host:6767")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"base_url": "http://config-host:6767/"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._base_url == "http://config-host:6767"
|
||||
assert p._client.base_url == "http://config-host:6767"
|
||||
|
||||
|
||||
def test_ingest_conversation_uses_custom_base_url(monkeypatch):
|
||||
"""The raw urllib conversations ingest hits the configured base URL, not the cloud."""
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
client = _SupermemoryClient.__new__(_SupermemoryClient)
|
||||
client._api_key = "test-key"
|
||||
client._container_tag = "hermes"
|
||||
client._timeout = 1.0
|
||||
client._base_url = "http://localhost:6767"
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
return _FakeResponse()
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
client.ingest_conversation("s1", [{"role": "user", "content": "hello there"}])
|
||||
assert captured["url"] == "http://localhost:6767/v4/conversations"
|
||||
|
||||
|
||||
# -- Multi-container tests ----------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue