diff --git a/plugins/memory/mem0/README.md b/plugins/memory/mem0/README.md index c5c39f2bc4..b956e13729 100644 --- a/plugins/memory/mem0/README.md +++ b/plugins/memory/mem0/README.md @@ -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"}} } diff --git a/plugins/memory/mem0/_backend.py b/plugins/memory/mem0/_backend.py index 014417fac8..09da207815 100644 --- a/plugins/memory/mem0/_backend.py +++ b/plugins/memory/mem0/_backend.py @@ -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: diff --git a/plugins/memory/mem0/_openai_llm.py b/plugins/memory/mem0/_openai_llm.py new file mode 100644 index 0000000000..d61ab511c9 --- /dev/null +++ b/plugins/memory/mem0/_openai_llm.py @@ -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 diff --git a/plugins/memory/mem0/_setup.py b/plugins/memory/mem0/_setup.py index 368aec3417..7c68c81d4e 100644 --- a/plugins/memory/mem0/_setup.py +++ b/plugins/memory/mem0/_setup.py @@ -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 diff --git a/tests/plugins/memory/test_mem0_backend.py b/tests/plugins/memory/test_mem0_backend.py index e40e9c3b94..e30903672e 100644 --- a/tests/plugins/memory/test_mem0_backend.py +++ b/tests/plugins/memory/test_mem0_backend.py @@ -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 diff --git a/tests/plugins/memory/test_mem0_backend_integration.py b/tests/plugins/memory/test_mem0_backend_integration.py new file mode 100644 index 0000000000..e8942c0697 --- /dev/null +++ b/tests/plugins/memory/test_mem0_backend_integration.py @@ -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 diff --git a/tests/plugins/memory/test_mem0_setup.py b/tests/plugins/memory/test_mem0_setup.py index abd16a1926..8242fec2fc 100644 --- a/tests/plugins/memory/test_mem0_setup.py +++ b/tests/plugins/memory/test_mem0_setup.py @@ -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 == {}