fix(memory): keep Mem0 OSS OpenAI requests direct
This commit is contained in:
@@ -119,7 +119,7 @@ Or edit `$HERMES_HOME/mem0.json` directly:
|
||||
{
|
||||
"mode": "oss",
|
||||
"oss": {
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini", "is_reasoning_model": true}},
|
||||
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
|
||||
"vector_store": {"provider": "qdrant", "config": {"path": "~/.hermes/mem0_qdrant"}}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,32 @@ class SelfHostedBackend(Mem0Backend):
|
||||
pass
|
||||
|
||||
|
||||
_DIRECT_OPENAI_PROVIDER = "hermes_openai"
|
||||
_DIRECT_OPENAI_CLASS_PATH = "plugins.memory.mem0._openai_llm.DirectOpenAILLM"
|
||||
|
||||
|
||||
def _register_direct_openai_provider() -> None:
|
||||
"""Register Hermes' OpenAI-only Mem0 LLM provider once per factory."""
|
||||
from mem0.configs.llms.openai import OpenAIConfig
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
provider_map = getattr(LlmFactory, "provider_to_class", None)
|
||||
register_provider = getattr(LlmFactory, "register_provider", None)
|
||||
if not isinstance(provider_map, dict) or not callable(register_provider):
|
||||
raise RuntimeError(
|
||||
"mem0 LlmFactory does not support the provider registration required "
|
||||
"for the Hermes OpenAI OSS backend"
|
||||
)
|
||||
|
||||
registration = (_DIRECT_OPENAI_CLASS_PATH, OpenAIConfig)
|
||||
if provider_map.get(_DIRECT_OPENAI_PROVIDER) != registration:
|
||||
register_provider(
|
||||
_DIRECT_OPENAI_PROVIDER,
|
||||
_DIRECT_OPENAI_CLASS_PATH,
|
||||
OpenAIConfig,
|
||||
)
|
||||
|
||||
|
||||
class OSSBackend(Mem0Backend):
|
||||
"""Wraps mem0.Memory for self-hosted (OSS) mode."""
|
||||
|
||||
@@ -203,7 +229,24 @@ class OSSBackend(Mem0Backend):
|
||||
"embedder": _provider_block("embedder"),
|
||||
"version": "v1.1",
|
||||
}
|
||||
self._memory = Memory.from_config(config)
|
||||
if str(config["llm"].get("provider") or "").strip().lower() == "openai":
|
||||
# mem0 validates LlmConfig.provider before its factory lookup, so
|
||||
# first build the supported OpenAI config and only then swap the
|
||||
# provider on that validated in-memory object.
|
||||
_register_direct_openai_provider()
|
||||
from mem0.configs.base import MemoryConfig
|
||||
|
||||
memory_config = MemoryConfig(**config)
|
||||
try:
|
||||
memory_config.llm.provider = _DIRECT_OPENAI_PROVIDER
|
||||
except (AttributeError, TypeError) as exc:
|
||||
raise RuntimeError(
|
||||
"mem0 MemoryConfig does not expose a mutable llm.provider "
|
||||
"for the Hermes OpenAI OSS backend"
|
||||
) from exc
|
||||
self._memory = Memory(memory_config)
|
||||
else:
|
||||
self._memory = Memory.from_config(config)
|
||||
|
||||
@staticmethod
|
||||
def _recreate_collection_if_dims_changed(provider: str, vs_config: dict, expected_dims: int) -> None:
|
||||
|
||||
100
plugins/memory/mem0/_openai_llm.py
Normal file
100
plugins/memory/mem0/_openai_llm.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""OpenAI-only LLM adapter for Mem0 OSS mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.openai import OpenAIConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
from mem0.llms.openai import OpenAILLM
|
||||
|
||||
|
||||
class DirectOpenAILLM(OpenAILLM):
|
||||
"""Use OpenAI credentials and requests regardless of router environment."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[Union[BaseLlmConfig, OpenAIConfig, Dict]] = None,
|
||||
):
|
||||
if config is None:
|
||||
config = OpenAIConfig()
|
||||
elif isinstance(config, dict):
|
||||
config = OpenAIConfig(**config)
|
||||
elif isinstance(config, BaseLlmConfig) and not isinstance(config, OpenAIConfig):
|
||||
config = OpenAIConfig(
|
||||
model=config.model,
|
||||
temperature=config.temperature,
|
||||
api_key=config.api_key,
|
||||
max_tokens=config.max_tokens,
|
||||
top_p=config.top_p,
|
||||
top_k=config.top_k,
|
||||
enable_vision=config.enable_vision,
|
||||
vision_details=config.vision_details,
|
||||
reasoning_effort=getattr(config, "reasoning_effort", None),
|
||||
http_client_proxies=config.http_client_proxies,
|
||||
is_reasoning_model=getattr(config, "is_reasoning_model", None),
|
||||
)
|
||||
|
||||
if not config.model:
|
||||
config.model = "gpt-5-mini"
|
||||
|
||||
# Older, partial, and manually edited configs may predate the setup
|
||||
# marker. Keep the exact default model safe at runtime without
|
||||
# overriding an explicit user choice or changing the persisted config.
|
||||
if config.model == "gpt-5-mini" and config.is_reasoning_model is None:
|
||||
config.is_reasoning_model = True
|
||||
|
||||
# Bypass OpenAILLM.__init__: it intentionally selects OpenRouter when
|
||||
# OPENROUTER_API_KEY is present. LLMBase still owns validation and
|
||||
# supported-parameter filtering for parity with Mem0's implementation.
|
||||
LLMBase.__init__(self, config)
|
||||
|
||||
api_key = self.config.api_key or os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"OpenAI API key is required for the Hermes Mem0 OSS provider"
|
||||
)
|
||||
|
||||
base_url = (
|
||||
self.config.openai_base_url
|
||||
or os.getenv("OPENAI_BASE_URL")
|
||||
or "https://api.openai.com/v1"
|
||||
)
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
|
||||
def generate_response(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
response_format=None,
|
||||
tools: Optional[List[Dict]] = None,
|
||||
tool_choice: str = "auto",
|
||||
**kwargs,
|
||||
):
|
||||
params = self._get_supported_params(messages=messages, **kwargs)
|
||||
params.update({"model": self.config.model, "messages": messages})
|
||||
|
||||
# OpenRouter-only fields are deliberately not added here. ``store`` is
|
||||
# opt-in so OpenAI-compatible endpoints do not receive unknown fields.
|
||||
if self.config.store is not None:
|
||||
params["store"] = self.config.store
|
||||
|
||||
if response_format:
|
||||
params["response_format"] = response_format
|
||||
if tools:
|
||||
params["tools"] = tools
|
||||
params["tool_choice"] = tool_choice
|
||||
|
||||
response = self.client.chat.completions.create(**params)
|
||||
parsed_response = self._parse_response(response, tools)
|
||||
if self.config.response_callback:
|
||||
try:
|
||||
self.config.response_callback(self, response, params)
|
||||
except Exception:
|
||||
logging.error("Error running Mem0 OpenAI response callback")
|
||||
return parsed_response
|
||||
@@ -135,6 +135,8 @@ def build_oss_config(flags: dict[str, str]) -> tuple[dict, dict[str, str]]:
|
||||
llm_def = LLM_PROVIDERS[llm_id]
|
||||
llm_model = flags.get("oss_llm_model") or llm_def["default_model"]
|
||||
llm_config: dict[str, Any] = {"model": llm_model}
|
||||
if llm_id == "openai" and llm_model == "gpt-5-mini":
|
||||
llm_config["is_reasoning_model"] = True
|
||||
llm_url = flags.get("oss_llm_url") or llm_def.get("default_url")
|
||||
if llm_url and llm_def.get("base_url_key"):
|
||||
llm_config[llm_def["base_url_key"]] = llm_url
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
|
||||
|
||||
import copy
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.mem0._backend import (
|
||||
@@ -104,6 +112,268 @@ class FakeOSSMemory:
|
||||
return {"message": "Memory deleted successfully!"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMem0State:
|
||||
factory_registrations: list = field(default_factory=list)
|
||||
from_config_calls: int = 0
|
||||
clients: list = field(default_factory=list)
|
||||
requests: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _install_fake_mem0(monkeypatch):
|
||||
"""Install a small mem0 2.0.10-shaped surface for OSS backend tests."""
|
||||
|
||||
state = _FakeMem0State()
|
||||
|
||||
class BaseLlmConfig:
|
||||
def __init__(
|
||||
self,
|
||||
model=None,
|
||||
temperature=0.1,
|
||||
api_key=None,
|
||||
max_tokens=2000,
|
||||
top_p=0.1,
|
||||
top_k=1,
|
||||
enable_vision=False,
|
||||
vision_details="auto",
|
||||
reasoning_effort=None,
|
||||
http_client_proxies=None,
|
||||
is_reasoning_model=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.model = model
|
||||
self.temperature = temperature
|
||||
self.api_key = api_key
|
||||
self.max_tokens = max_tokens
|
||||
self.top_p = top_p
|
||||
self.top_k = top_k
|
||||
self.enable_vision = enable_vision
|
||||
self.vision_details = vision_details
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.http_client_proxies = http_client_proxies
|
||||
self.is_reasoning_model = is_reasoning_model
|
||||
for name, value in kwargs.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
class OpenAIConfig(BaseLlmConfig):
|
||||
def __init__(
|
||||
self,
|
||||
model=None,
|
||||
temperature=0.1,
|
||||
api_key=None,
|
||||
max_tokens=2000,
|
||||
top_p=0.1,
|
||||
top_k=1,
|
||||
enable_vision=False,
|
||||
vision_details="auto",
|
||||
reasoning_effort=None,
|
||||
http_client_proxies=None,
|
||||
is_reasoning_model=None,
|
||||
openai_base_url=None,
|
||||
models=None,
|
||||
route="fallback",
|
||||
openrouter_base_url=None,
|
||||
site_url=None,
|
||||
app_name=None,
|
||||
store=None,
|
||||
response_callback=None,
|
||||
):
|
||||
super().__init__(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
api_key=api_key,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
enable_vision=enable_vision,
|
||||
vision_details=vision_details,
|
||||
reasoning_effort=reasoning_effort,
|
||||
http_client_proxies=http_client_proxies,
|
||||
is_reasoning_model=is_reasoning_model,
|
||||
)
|
||||
self.openai_base_url = openai_base_url
|
||||
self.models = models
|
||||
self.route = route
|
||||
self.openrouter_base_url = openrouter_base_url
|
||||
self.site_url = site_url
|
||||
self.app_name = app_name
|
||||
self.store = store
|
||||
self.response_callback = response_callback
|
||||
|
||||
class LLMBase:
|
||||
def __init__(self, config=None):
|
||||
self.config = config or BaseLlmConfig()
|
||||
if not hasattr(self.config, "model"):
|
||||
raise ValueError("Configuration must have a 'model' attribute")
|
||||
|
||||
def _get_supported_params(self, **kwargs):
|
||||
if self.config.is_reasoning_model:
|
||||
return {
|
||||
name: kwargs[name]
|
||||
for name in ("messages", "response_format", "tools", "tool_choice")
|
||||
if name in kwargs
|
||||
}
|
||||
params = {
|
||||
"temperature": self.config.temperature,
|
||||
"top_p": self.config.top_p,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return params
|
||||
|
||||
class OpenAILLM(LLMBase):
|
||||
@staticmethod
|
||||
def _parse_response(response, tools):
|
||||
if not tools:
|
||||
return response.choices[0].message.content
|
||||
parsed = {
|
||||
"content": response.choices[0].message.content,
|
||||
"tool_calls": [],
|
||||
}
|
||||
for tool_call in response.choices[0].message.tool_calls or []:
|
||||
parsed["tool_calls"].append(
|
||||
{
|
||||
"name": tool_call.function.name,
|
||||
"arguments": json.loads(tool_call.function.arguments),
|
||||
}
|
||||
)
|
||||
return parsed
|
||||
|
||||
class Factory:
|
||||
provider_to_class = {
|
||||
"openai": ("mem0.llms.openai.OpenAILLM", OpenAIConfig),
|
||||
"ollama": ("mem0.llms.openai.OpenAILLM", BaseLlmConfig),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def register_provider(cls, name, class_path, config_class=None):
|
||||
cls.provider_to_class[name] = (
|
||||
class_path,
|
||||
config_class or BaseLlmConfig,
|
||||
)
|
||||
state.factory_registrations.append((name, class_path, config_class))
|
||||
|
||||
@classmethod
|
||||
def create(cls, provider_name, config=None, **kwargs):
|
||||
class_path, config_class = cls.provider_to_class[provider_name]
|
||||
if config is None:
|
||||
config = config_class(**kwargs)
|
||||
elif isinstance(config, dict):
|
||||
config = config_class(**config)
|
||||
module_name, class_name = class_path.rsplit(".", 1)
|
||||
llm_class = getattr(importlib.import_module(module_name), class_name)
|
||||
return llm_class(config)
|
||||
|
||||
class MemoryConfig:
|
||||
def __init__(self, **config):
|
||||
llm = config["llm"]
|
||||
if llm["provider"] not in {"openai", "ollama"}:
|
||||
raise ValueError(
|
||||
f"Unsupported LLM provider: {llm['provider']}"
|
||||
)
|
||||
self.llm = SimpleNamespace(
|
||||
provider=llm["provider"],
|
||||
config=copy.deepcopy(llm.get("config", {})),
|
||||
)
|
||||
embedder = config["embedder"]
|
||||
self.embedder = SimpleNamespace(
|
||||
provider=embedder["provider"],
|
||||
config=copy.deepcopy(embedder.get("config", {})),
|
||||
)
|
||||
vector_store = config["vector_store"]
|
||||
self.vector_store = SimpleNamespace(
|
||||
provider=vector_store["provider"],
|
||||
config=copy.deepcopy(vector_store.get("config", {})),
|
||||
)
|
||||
self.version = config.get("version", "v1.1")
|
||||
|
||||
class Memory:
|
||||
instances = []
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.llm = Factory.create(config.llm.provider, config.llm.config)
|
||||
self.embedding_model = SimpleNamespace(
|
||||
provider=config.embedder.provider,
|
||||
config=config.embedder.config,
|
||||
)
|
||||
self.vector_store = SimpleNamespace(
|
||||
provider=config.vector_store.provider,
|
||||
config=config.vector_store.config,
|
||||
)
|
||||
type(self).instances.append(self)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config):
|
||||
# This mirrors mem0 2.0.10: validation rejects the private provider
|
||||
# before the factory gets a chance to resolve its registration.
|
||||
state.from_config_calls += 1
|
||||
return cls(MemoryConfig(**config))
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, *, api_key, base_url):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
state.clients.append(self)
|
||||
self.chat = SimpleNamespace(
|
||||
completions=SimpleNamespace(create=self._create)
|
||||
)
|
||||
|
||||
def _create(self, **params):
|
||||
state.requests.append(params)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
content="direct answer",
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
function=SimpleNamespace(
|
||||
name="remember",
|
||||
arguments='{"fact": "tea"}',
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
package_names = {
|
||||
"mem0": types.ModuleType("mem0"),
|
||||
"mem0.configs": types.ModuleType("mem0.configs"),
|
||||
"mem0.configs.llms": types.ModuleType("mem0.configs.llms"),
|
||||
"mem0.llms": types.ModuleType("mem0.llms"),
|
||||
"mem0.utils": types.ModuleType("mem0.utils"),
|
||||
"mem0.configs.base": types.ModuleType("mem0.configs.base"),
|
||||
"mem0.configs.llms.base": types.ModuleType("mem0.configs.llms.base"),
|
||||
"mem0.configs.llms.openai": types.ModuleType("mem0.configs.llms.openai"),
|
||||
"mem0.llms.base": types.ModuleType("mem0.llms.base"),
|
||||
"mem0.llms.openai": types.ModuleType("mem0.llms.openai"),
|
||||
"mem0.utils.factory": types.ModuleType("mem0.utils.factory"),
|
||||
"openai": types.ModuleType("openai"),
|
||||
}
|
||||
setattr(package_names["mem0"], "Memory", Memory)
|
||||
setattr(package_names["mem0.configs.base"], "MemoryConfig", MemoryConfig)
|
||||
setattr(package_names["mem0.configs.llms.base"], "BaseLlmConfig", BaseLlmConfig)
|
||||
setattr(package_names["mem0.configs.llms.openai"], "OpenAIConfig", OpenAIConfig)
|
||||
setattr(package_names["mem0.llms.base"], "LLMBase", LLMBase)
|
||||
setattr(package_names["mem0.llms.openai"], "OpenAILLM", OpenAILLM)
|
||||
setattr(package_names["mem0.utils.factory"], "LlmFactory", Factory)
|
||||
setattr(package_names["openai"], "OpenAI", FakeOpenAI)
|
||||
for name, module in package_names.items():
|
||||
if name in {"mem0", "mem0.configs", "mem0.configs.llms", "mem0.llms", "mem0.utils"}:
|
||||
module.__path__ = []
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
|
||||
# The class-path registration imports this module after the fake mem0
|
||||
# surface is installed, so it binds to the test doubles above.
|
||||
monkeypatch.delitem(
|
||||
sys.modules, "plugins.memory.mem0._openai_llm", raising=False
|
||||
)
|
||||
return state, Memory, Factory
|
||||
|
||||
|
||||
class TestOSSBackend:
|
||||
|
||||
def _make(self):
|
||||
@@ -114,27 +384,15 @@ class TestOSSBackend:
|
||||
|
||||
|
||||
def test_legacy_api_base_aliases_are_normalized_before_mem0_init(self, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
captured = {}
|
||||
|
||||
class Memory:
|
||||
@staticmethod
|
||||
def from_config(config):
|
||||
captured.update(config)
|
||||
return FakeOSSMemory()
|
||||
|
||||
# OSSBackend.__init__ does `from mem0 import Memory`. mem0 is a lazy
|
||||
# optional dep absent from CI's env, so inject a stub module rather
|
||||
# than importing the real package (which would ModuleNotFoundError).
|
||||
stub_mem0 = types.ModuleType("mem0")
|
||||
stub_mem0.Memory = Memory # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "mem0", stub_mem0)
|
||||
state, Memory, factory = _install_fake_mem0(monkeypatch)
|
||||
raw = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {"model": "gpt-5-mini", "api_base": "https://llm.example/v1"},
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "openai-sentinel",
|
||||
"api_base": "https://llm.example/v1",
|
||||
},
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "ollama",
|
||||
@@ -143,13 +401,220 @@ class TestOSSBackend:
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
before = copy.deepcopy(raw)
|
||||
environment = dict(os.environ)
|
||||
|
||||
OSSBackend(raw)
|
||||
|
||||
assert captured["llm"]["config"]["openai_base_url"] == "https://llm.example/v1"
|
||||
assert captured["embedder"]["config"]["ollama_base_url"] == "http://ollama:11434"
|
||||
assert "api_base" not in captured["llm"]["config"]
|
||||
assert "api_base" not in captured["embedder"]["config"]
|
||||
assert len(Memory.instances) == 1
|
||||
captured = Memory.instances[0].config
|
||||
assert captured.llm.provider == "hermes_openai"
|
||||
assert captured.llm.config["openai_base_url"] == "https://llm.example/v1"
|
||||
assert captured.embedder.provider == "ollama"
|
||||
assert captured.embedder.config["ollama_base_url"] == "http://ollama:11434"
|
||||
assert "api_base" not in captured.llm.config
|
||||
assert "api_base" not in captured.embedder.config
|
||||
assert factory.provider_to_class["hermes_openai"][1].__name__ == "OpenAIConfig"
|
||||
assert len(state.factory_registrations) == 1
|
||||
assert state.from_config_calls == 0
|
||||
assert raw == before
|
||||
assert dict(os.environ) == environment
|
||||
|
||||
def test_direct_openai_uses_openai_credentials_and_request_shape(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
callback_calls = []
|
||||
config = factory.provider_to_class["openai"][1](
|
||||
model="gpt-5-mini",
|
||||
api_key="configured-openai-sentinel",
|
||||
openai_base_url="https://openai.example/v1",
|
||||
models=["router-model"],
|
||||
route="lowest-latency",
|
||||
site_url="https://hermes.example",
|
||||
app_name="Hermes",
|
||||
store=True,
|
||||
response_callback=lambda *args: callback_calls.append(args),
|
||||
)
|
||||
adapter = module.DirectOpenAILLM(config)
|
||||
assert adapter.config.is_reasoning_model is True
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "remember", "parameters": {}},
|
||||
}
|
||||
]
|
||||
|
||||
result = adapter.generate_response(
|
||||
[{"role": "user", "content": "remember tea"}],
|
||||
response_format={"type": "json_object"},
|
||||
tools=tools,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
assert len(state.clients) == 1
|
||||
client = state.clients[0]
|
||||
assert client.api_key == "configured-openai-sentinel"
|
||||
assert client.base_url == "https://openai.example/v1"
|
||||
request = state.requests[0]
|
||||
assert request["model"] == "gpt-5-mini"
|
||||
assert request["tools"] == tools
|
||||
assert request["tool_choice"] == "required"
|
||||
assert request["response_format"] == {"type": "json_object"}
|
||||
assert request["store"] is True
|
||||
assert "models" not in request
|
||||
assert "route" not in request
|
||||
assert "extra_headers" not in request
|
||||
assert "temperature" not in request
|
||||
assert "top_p" not in request
|
||||
assert "max_tokens" not in request
|
||||
assert result == {
|
||||
"content": "direct answer",
|
||||
"tool_calls": [{"name": "remember", "arguments": {"fact": "tea"}}],
|
||||
}
|
||||
assert len(callback_calls) == 1
|
||||
assert callback_calls[0][0] is adapter
|
||||
assert callback_calls[0][2] == request
|
||||
|
||||
def test_direct_openai_preserves_explicit_non_reasoning_override(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
config = factory.provider_to_class["openai"][1](
|
||||
model="gpt-5-mini",
|
||||
api_key="configured-openai-sentinel",
|
||||
is_reasoning_model=False,
|
||||
)
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
adapter = module.DirectOpenAILLM(config)
|
||||
adapter.generate_response([{"role": "user", "content": "remember tea"}])
|
||||
|
||||
assert adapter.config.is_reasoning_model is False
|
||||
request = state.requests[0]
|
||||
assert request["temperature"] == 0.1
|
||||
assert request["top_p"] == 0.1
|
||||
assert request["max_tokens"] == 2000
|
||||
|
||||
def test_direct_openai_defaults_missing_model_to_reasoning_safe_mini(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
|
||||
_install_fake_mem0(monkeypatch)
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
adapter = module.DirectOpenAILLM()
|
||||
|
||||
assert adapter.config.model == "gpt-5-mini"
|
||||
assert adapter.config.is_reasoning_model is True
|
||||
|
||||
def test_direct_openai_uses_openai_environment_when_config_omits_values(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://env-openai.example/v1")
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
config = factory.provider_to_class["openai"][1](model="gpt-5-mini")
|
||||
adapter = module.DirectOpenAILLM(config)
|
||||
|
||||
assert len(state.clients) == 1
|
||||
assert state.clients[0].api_key == "env-openai-sentinel"
|
||||
assert state.clients[0].base_url == "https://env-openai.example/v1"
|
||||
|
||||
def test_missing_openai_key_fails_before_client_and_hides_router_secret(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
router_secret = "router-secret-sentinel"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", router_secret)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
config = factory.provider_to_class["openai"][1](
|
||||
model="gpt-5-mini",
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
module.DirectOpenAILLM(config)
|
||||
|
||||
assert "OpenAI API key" in str(exc_info.value)
|
||||
assert router_secret not in str(exc_info.value)
|
||||
assert state.clients == []
|
||||
assert state.requests == []
|
||||
|
||||
def test_registration_is_idempotent_and_clients_keep_instance_config(self, monkeypatch):
|
||||
state, Memory, factory = _install_fake_mem0(monkeypatch)
|
||||
first = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "first-openai-sentinel",
|
||||
"openai_base_url": "https://first.example/v1",
|
||||
},
|
||||
},
|
||||
"embedder": {"provider": "ollama", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
second = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "second-openai-sentinel",
|
||||
"openai_base_url": "https://second.example/v1",
|
||||
},
|
||||
},
|
||||
"embedder": {"provider": "ollama", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
first_before = copy.deepcopy(first)
|
||||
second_before = copy.deepcopy(second)
|
||||
|
||||
OSSBackend(first)
|
||||
OSSBackend(second)
|
||||
|
||||
assert len(state.factory_registrations) == 1
|
||||
assert factory.provider_to_class["hermes_openai"][0].endswith(
|
||||
"_openai_llm.DirectOpenAILLM"
|
||||
)
|
||||
assert [
|
||||
(client.api_key, client.base_url) for client in state.clients
|
||||
] == [
|
||||
("first-openai-sentinel", "https://first.example/v1"),
|
||||
("second-openai-sentinel", "https://second.example/v1"),
|
||||
]
|
||||
assert len(Memory.instances) == 2
|
||||
assert state.from_config_calls == 0
|
||||
assert first == first_before
|
||||
assert second == second_before
|
||||
|
||||
def test_ollama_bypasses_direct_openai_adapter(self, monkeypatch):
|
||||
state, Memory, factory = _install_fake_mem0(monkeypatch)
|
||||
raw = {
|
||||
"llm": {
|
||||
"provider": "ollama",
|
||||
"config": {
|
||||
"model": "llama3.1:8b",
|
||||
"api_base": "http://ollama:11434",
|
||||
},
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "ollama",
|
||||
"config": {
|
||||
"model": "nomic-embed-text",
|
||||
"api_base": "http://ollama:11434",
|
||||
},
|
||||
},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
before = copy.deepcopy(raw)
|
||||
|
||||
OSSBackend(raw)
|
||||
|
||||
assert len(Memory.instances) == 1
|
||||
assert state.from_config_calls == 1
|
||||
assert Memory.instances[0].config.llm.provider == "ollama"
|
||||
assert Memory.instances[0].config.embedder.provider == "ollama"
|
||||
assert "hermes_openai" not in factory.provider_to_class
|
||||
assert state.clients == []
|
||||
assert raw == before
|
||||
|
||||
|
||||
|
||||
131
tests/plugins/memory/test_mem0_backend_integration.py
Normal file
131
tests/plugins/memory/test_mem0_backend_integration.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Integration coverage for Hermes' pinned Mem0 OSS boundary."""
|
||||
|
||||
import copy
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
pytest.importorskip("mem0", reason="requires the existing mem0 extra")
|
||||
|
||||
|
||||
def test_openai_backend_uses_real_mem0_config_and_factory(monkeypatch, tmp_path):
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
monkeypatch.setenv("MEM0_DIR", str(mem0_dir))
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
|
||||
|
||||
import openai
|
||||
from mem0.memory import main as memory_main
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
from plugins.memory.mem0._backend import OSSBackend
|
||||
from plugins.memory.mem0._openai_llm import DirectOpenAILLM
|
||||
|
||||
clients = []
|
||||
requests = []
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, *, api_key, base_url):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.chat = SimpleNamespace(
|
||||
completions=SimpleNamespace(create=self._create)
|
||||
)
|
||||
clients.append(self)
|
||||
|
||||
@staticmethod
|
||||
def _create(**params):
|
||||
requests.append(params)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
content="direct answer",
|
||||
tool_calls=None,
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
class DummyVectorStore:
|
||||
pass
|
||||
|
||||
class DummyDB:
|
||||
def __init__(self, _path):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
LlmFactory,
|
||||
"provider_to_class",
|
||||
dict(LlmFactory.provider_to_class),
|
||||
)
|
||||
monkeypatch.setattr(openai, "OpenAI", FakeOpenAI)
|
||||
monkeypatch.setattr(
|
||||
memory_main.EmbedderFactory,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
memory_main.VectorStoreFactory,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: DummyVectorStore(),
|
||||
)
|
||||
monkeypatch.setattr(memory_main, "SQLiteManager", DummyDB)
|
||||
monkeypatch.setattr(memory_main, "MEM0_TELEMETRY", False)
|
||||
monkeypatch.setattr(memory_main, "capture_event", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
OSSBackend,
|
||||
"_recreate_collection_if_dims_changed",
|
||||
staticmethod(lambda *_args, **_kwargs: None),
|
||||
)
|
||||
|
||||
config = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "configured-openai-sentinel",
|
||||
"openai_base_url": "https://openai.example/v1",
|
||||
"models": ["router-model"],
|
||||
"route": "lowest-latency",
|
||||
},
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "ollama",
|
||||
"config": {
|
||||
"model": "nomic-embed-text",
|
||||
"ollama_base_url": "http://ollama.example:11434",
|
||||
"embedding_dims": 768,
|
||||
},
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"config": {
|
||||
"collection_name": "mem0",
|
||||
"path": str(tmp_path / "qdrant"),
|
||||
},
|
||||
},
|
||||
}
|
||||
original_config = copy.deepcopy(config)
|
||||
environment = dict(os.environ)
|
||||
|
||||
backend = OSSBackend(config)
|
||||
result = backend._memory.llm.generate_response(
|
||||
[{"role": "user", "content": "remember tea"}]
|
||||
)
|
||||
|
||||
assert isinstance(backend._memory.llm, DirectOpenAILLM)
|
||||
assert len(clients) == 1
|
||||
assert clients[0].api_key == "configured-openai-sentinel"
|
||||
assert clients[0].base_url == "https://openai.example/v1"
|
||||
assert requests == [
|
||||
{
|
||||
"model": "gpt-5-mini",
|
||||
"messages": [{"role": "user", "content": "remember tea"}],
|
||||
}
|
||||
]
|
||||
assert result == "direct answer"
|
||||
assert config == original_config
|
||||
assert dict(os.environ) == environment
|
||||
@@ -65,18 +65,40 @@ class TestBuildOSSConfig:
|
||||
oss, env_writes = build_oss_config(flags)
|
||||
assert oss["llm"]["provider"] == "openai"
|
||||
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
|
||||
assert oss["llm"]["config"]["is_reasoning_model"] is True
|
||||
assert oss["embedder"]["provider"] == "openai"
|
||||
assert oss["embedder"]["config"]["model"] == "text-embedding-3-small"
|
||||
assert oss["vector_store"]["provider"] == "qdrant"
|
||||
assert env_writes["OPENAI_API_KEY"] == "sk-oai"
|
||||
|
||||
|
||||
def test_explicit_gpt_5_mini_is_reasoning_model(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-llm-model", "gpt-5-mini",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
|
||||
assert oss["llm"]["config"]["is_reasoning_model"] is True
|
||||
|
||||
|
||||
def test_custom_openai_model_is_not_forced_to_reasoning(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-llm-model", "gpt-5.2",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
assert oss["llm"]["config"]["model"] == "gpt-5.2"
|
||||
assert "is_reasoning_model" not in oss["llm"]["config"]
|
||||
|
||||
|
||||
def test_ollama_no_key_needed(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm", "ollama", "--oss-embedder", "ollama"])
|
||||
oss, env_writes = build_oss_config(flags)
|
||||
assert oss["llm"]["provider"] == "ollama"
|
||||
assert "model" in oss["llm"]["config"]
|
||||
assert oss["llm"]["config"]["ollama_base_url"] == "http://localhost:11434"
|
||||
assert "is_reasoning_model" not in oss["llm"]["config"]
|
||||
assert oss["embedder"]["config"]["ollama_base_url"] == "http://localhost:11434"
|
||||
assert env_writes == {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user