fix(memory): keep Mem0 OSS OpenAI requests direct

This commit is contained in:
GodsBoy
2026-08-29 16:58:29 +02:00
committed by Teknium
parent 94aad6dcd2
commit e38cca50d6
7 changed files with 787 additions and 24 deletions

View File

@@ -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"}}
}

View File

@@ -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:

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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 == {}