Compare commits

..
8 changed files with 154 additions and 178 deletions
+5
View File
@@ -7,6 +7,11 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-03" description="v1.0.5">
- **Telemetry Fix**
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
</Update>
<Update label="2026-02-17" description="v1.0.4">
**New Features & Updates:**
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.2.3",
"version": "2.2.4",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -91,7 +91,7 @@
"typescript": "5.5.4"
},
"dependencies": {
"axios": "1.7.7",
"axios": "1.13.6",
"openai": "^4.93.0",
"uuid": "9.0.1",
"zod": "^3.24.1"
+25 -10
View File
@@ -44,8 +44,8 @@ importers:
specifier: 3.1.11
version: 3.1.11
axios:
specifier: 1.7.7
version: 1.7.7
specifier: 1.13.6
version: 1.13.6
cloudflare:
specifier: ^4.2.0
version: 4.3.0(encoding@0.1.13)
@@ -1621,10 +1621,10 @@ packages:
integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==,
}
axios@1.7.7:
axios@1.13.6:
resolution:
{
integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==,
integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==,
}
babel-jest@29.7.0:
@@ -2560,10 +2560,10 @@ packages:
}
hasBin: true
follow-redirects@1.15.9:
follow-redirects@1.15.11:
resolution:
{
integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==,
integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==,
}
engines: { node: ">=4.0" }
peerDependencies:
@@ -2592,6 +2592,13 @@ packages:
}
engines: { node: ">= 6" }
form-data@4.0.5:
resolution:
{
integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==,
}
engines: { node: ">= 6" }
formdata-node@4.4.1:
resolution:
{
@@ -6566,10 +6573,10 @@ snapshots:
asynckit@0.4.0: {}
axios@1.7.7:
axios@1.13.6:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.2
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -7170,7 +7177,7 @@ snapshots:
kolorist: 1.8.0
read-pkg: 8.1.0
follow-redirects@1.15.9: {}
follow-redirects@1.15.11: {}
foreground-child@3.3.1:
dependencies:
@@ -7186,6 +7193,14 @@ snapshots:
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
+1 -27
View File
@@ -1,4 +1,3 @@
import json
from typing import Dict, List, Optional, Union
try:
@@ -9,7 +8,6 @@ except ImportError:
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.ollama import OllamaConfig
from mem0.llms.base import LLMBase
from mem0.memory.utils import extract_json
class OllamaLLM(LLMBase):
@@ -63,28 +61,7 @@ class OllamaLLM(LLMBase):
"tool_calls": [],
}
if isinstance(response, dict):
raw_calls = response.get("message", {}).get("tool_calls") or []
else:
raw_calls = getattr(response.message, "tool_calls", None) or []
for tool_call in raw_calls:
if isinstance(tool_call, dict):
fn = tool_call.get("function", {})
name = fn.get("name", "")
arguments = fn.get("arguments", {})
else:
fn = getattr(tool_call, "function", None)
name = getattr(fn, "name", "") if fn else ""
arguments = getattr(fn, "arguments", {}) if fn else {}
if isinstance(arguments, str):
arguments = json.loads(extract_json(arguments))
processed_response["tool_calls"].append(
{"name": name, "arguments": arguments}
)
# Ollama doesn't support tool calls in the same way, so we return the content
return processed_response
else:
return content
@@ -136,8 +113,5 @@ class OllamaLLM(LLMBase):
# Remove OpenAI-specific parameters that Ollama doesn't support
params.pop("max_tokens", None) # Ollama uses different parameter names
if tools:
params["tools"] = tools
response = self.client.chat(**params)
return self._parse_response(response, tools)
+16 -5
View File
@@ -24,14 +24,18 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
class AnonymousTelemetry:
def __init__(self, vector_store=None):
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
if not MEM0_TELEMETRY:
self.posthog = None
self.user_id = None
return
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.user_id = get_or_create_user_id(vector_store)
if not MEM0_TELEMETRY:
self.posthog.disabled = True
def capture_event(self, event_name, properties=None, user_email=None):
if self.posthog is None:
return
if properties is None:
properties = {}
properties = {
@@ -49,13 +53,17 @@ class AnonymousTelemetry:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
def close(self):
self.posthog.shutdown()
if self.posthog is not None:
self.posthog.shutdown()
client_telemetry = AnonymousTelemetry()
def capture_event(event_name, memory_instance, additional_data=None):
if not MEM0_TELEMETRY:
return
oss_telemetry = AnonymousTelemetry(
vector_store=memory_instance._telemetry_vector_store
if hasattr(memory_instance, "_telemetry_vector_store")
@@ -81,6 +89,9 @@ def capture_event(event_name, memory_instance, additional_data=None):
def capture_client_event(event_name, instance, additional_data=None):
if not MEM0_TELEMETRY:
return
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.4"
version = "1.0.5"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }
-108
View File
@@ -32,111 +32,3 @@ def test_generate_response_without_tools(mock_ollama_client):
model="llama3.1:70b", messages=messages, options={"temperature": 0.7, "num_predict": 100, "top_p": 1.0}
)
assert response == "I'm doing well, thank you for asking!"
def test_generate_response_with_tools_passes_tools_to_client(mock_ollama_client):
"""Tools should be forwarded to ollama client.chat()."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "Extract entities from: Alice works at UCSD"}]
tools = [
{
"type": "function",
"function": {
"name": "extract_entities",
"description": "Extract entities",
"parameters": {"type": "object", "properties": {"entities": {"type": "array"}}},
},
}
]
mock_response = {
"message": {
"content": "",
"tool_calls": [
{
"function": {
"name": "extract_entities",
"arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]},
}
}
],
}
}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
# Verify tools were passed to client.chat
call_kwargs = mock_ollama_client.chat.call_args
assert "tools" in call_kwargs.kwargs or (len(call_kwargs.args) > 0 and "tools" in call_kwargs[1])
assert call_kwargs[1]["tools"] == tools
# Verify tool_calls were parsed correctly
assert response["tool_calls"] == [
{"name": "extract_entities", "arguments": {"entities": [{"name": "Alice"}, {"name": "UCSD"}]}}
]
def test_generate_response_with_tools_no_tool_calls_in_response(mock_ollama_client):
"""When model returns content without tool_calls, tool_calls should be empty list."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "Hello"}]
tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}]
mock_response = {"message": {"content": "I cannot use tools for this.", "tool_calls": []}}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
assert response["content"] == "I cannot use tools for this."
assert response["tool_calls"] == []
def test_generate_response_with_tools_string_arguments(mock_ollama_client):
"""When tool_call arguments come as JSON string, they should be parsed."""
config = OllamaConfig(model="llama3.1:70b", temperature=0.1, max_tokens=100, top_p=1.0)
llm = OllamaLLM(config)
messages = [{"role": "user", "content": "test"}]
tools = [{"type": "function", "function": {"name": "test_fn", "parameters": {}}}]
mock_response = {
"message": {
"content": "",
"tool_calls": [
{"function": {"name": "test_fn", "arguments": '{"key": "value"}'}}
],
}
}
mock_ollama_client.chat.return_value = mock_response
response = llm.generate_response(messages, tools=tools)
assert response["tool_calls"] == [{"name": "test_fn", "arguments": {"key": "value"}}]
def test_parse_response_with_tools_object_style(mock_ollama_client):
"""Test _parse_response with object-style response (non-dict)."""
config = OllamaConfig(model="llama3.1:70b")
llm = OllamaLLM(config)
# Simulate object-style response
mock_fn = Mock()
mock_fn.name = "extract"
mock_fn.arguments = {"entities": ["Alice"]}
mock_tool_call = Mock()
mock_tool_call.function = mock_fn
mock_message = Mock()
mock_message.content = ""
mock_message.tool_calls = [mock_tool_call]
mock_response = Mock()
mock_response.message = mock_message
tools = [{"type": "function", "function": {"name": "extract"}}]
result = llm._parse_response(mock_response, tools)
assert result["tool_calls"] == [{"name": "extract", "arguments": {"entities": ["Alice"]}}]
+104 -25
View File
@@ -1,35 +1,114 @@
import os
from unittest.mock import patch
import threading
from unittest.mock import MagicMock, patch
import pytest
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
if isinstance(MEM0_TELEMETRY, str):
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
import mem0.memory.telemetry as telemetry_module
def use_telemetry():
if os.getenv("MEM0_TELEMETRY", "true").lower() == "true":
return True
return False
class TestTelemetryDisabled:
"""Verify PostHog is never instantiated when telemetry is disabled."""
def test_posthog_not_created_when_disabled(self):
"""Posthog() constructor should never be called when MEM0_TELEMETRY=False."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_not_called()
assert at.posthog is None
assert at.user_id is None
def test_capture_event_noop_when_disabled(self):
"""capture_event() should return immediately without creating AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
telemetry_module.capture_event("test.event", MagicMock())
mock_cls.assert_not_called()
def test_capture_client_event_noop_when_disabled(self):
"""capture_client_event() should return immediately without calling posthog."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
mock_instance = MagicMock()
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_not_called()
def test_instance_capture_event_noop_when_posthog_is_none(self):
"""AnonymousTelemetry.capture_event() should be a no-op when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.capture_event("test.event", {"key": "value"}) # should not raise
def test_close_noop_when_posthog_is_none(self):
"""close() should not raise when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.close() # should not raise
def test_no_threads_spawned_when_disabled(self):
"""No consumer threads should be created when telemetry is disabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
threads_before = threading.active_count()
telemetry_module.AnonymousTelemetry()
threads_after = threading.active_count()
assert threads_after == threads_before
@pytest.fixture(autouse=True)
def reset_env():
with patch.dict(os.environ, {}, clear=True):
yield
class TestTelemetryEnabled:
"""Verify PostHog works normally when telemetry is enabled."""
def test_posthog_created_when_enabled(self):
"""Posthog() should be instantiated when MEM0_TELEMETRY=True."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_called_once()
assert at.posthog is not None
assert at.user_id == "test-user"
def test_capture_event_sends_when_enabled(self):
"""capture_event() should create AnonymousTelemetry and call capture when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
mock_at = MagicMock()
mock_cls.return_value = mock_at
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
telemetry_module.capture_event("test.event", mock_memory)
mock_at.capture_event.assert_called_once()
def test_capture_client_event_sends_when_enabled(self):
"""capture_client_event() should call client_telemetry.capture_event when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
mock_instance = MagicMock()
mock_instance.user_email = "test@example.com"
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_called_once()
def test_telemetry_enabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "true"}):
assert use_telemetry() is True
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""
def test_telemetry_disabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "false"}):
assert use_telemetry() is False
def test_telemetry_default_enabled():
assert use_telemetry() is True
@pytest.mark.parametrize(
"value,expected",
[
("true", True),
("True", True),
("TRUE", True),
("1", True),
("yes", True),
("false", False),
("False", False),
("0", False),
("no", False),
("anything_else", False),
],
)
def test_env_var_parsing(self, value, expected):
result = value.lower() in ("true", "1", "yes")
assert result == expected