Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fc9368f9b | ||
|
|
99797a183b | ||
|
|
206ec3531d | ||
|
|
3ae1120403 | ||
|
|
b3ec0978d3 | ||
|
|
34c797d285 | ||
|
|
a0d8a02b94 |
@@ -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:**
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+25
-10
@@ -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
@@ -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)
|
||||
|
||||
+4
-1
@@ -28,6 +28,7 @@ from mem0.memory.telemetry import capture_event
|
||||
from mem0.memory.utils import (
|
||||
extract_json,
|
||||
get_fact_retrieval_messages,
|
||||
normalize_facts,
|
||||
parse_messages,
|
||||
parse_vision_messages,
|
||||
process_telemetry_filters,
|
||||
@@ -37,8 +38,8 @@ from mem0.utils.factory import (
|
||||
EmbedderFactory,
|
||||
GraphStoreFactory,
|
||||
LlmFactory,
|
||||
VectorStoreFactory,
|
||||
RerankerFactory,
|
||||
VectorStoreFactory,
|
||||
)
|
||||
|
||||
# Suppress SWIG deprecation warnings globally
|
||||
@@ -451,6 +452,7 @@ class Memory(MemoryBase):
|
||||
# Try extracting JSON from response using built-in function
|
||||
extracted_json = extract_json(response)
|
||||
new_retrieved_facts = json.loads(extracted_json)["facts"]
|
||||
new_retrieved_facts = normalize_facts(new_retrieved_facts)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in new_retrieved_facts: {e}")
|
||||
new_retrieved_facts = []
|
||||
@@ -1477,6 +1479,7 @@ class AsyncMemory(MemoryBase):
|
||||
# Try extracting JSON from response using built-in function
|
||||
extracted_json = extract_json(response)
|
||||
new_retrieved_facts = json.loads(extracted_json)["facts"]
|
||||
new_retrieved_facts = normalize_facts(new_retrieved_facts)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in new_retrieved_facts: {e}")
|
||||
new_retrieved_facts = []
|
||||
|
||||
@@ -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__}",
|
||||
}
|
||||
|
||||
+28
-1
@@ -1,12 +1,15 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from mem0.configs.prompts import (
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
FACT_RETRIEVAL_PROMPT,
|
||||
USER_MEMORY_EXTRACTION_PROMPT,
|
||||
AGENT_MEMORY_EXTRACTION_PROMPT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_fact_retrieval_messages(message, is_agent_memory=False):
|
||||
"""Get fact retrieval messages based on the memory type.
|
||||
@@ -52,6 +55,30 @@ def format_entities(entities):
|
||||
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
def normalize_facts(raw_facts):
|
||||
"""Normalize LLM-extracted facts to a list of strings.
|
||||
|
||||
Smaller LLMs (e.g. llama3.1:8b) sometimes return facts as objects
|
||||
like {"fact": "..."} or {"text": "..."} instead of plain strings.
|
||||
This mirrors the TypeScript FactRetrievalSchema validation.
|
||||
"""
|
||||
if not raw_facts:
|
||||
return []
|
||||
normalized = []
|
||||
for item in raw_facts:
|
||||
if isinstance(item, str):
|
||||
fact = item
|
||||
elif isinstance(item, dict):
|
||||
fact = item.get("fact") or item.get("text")
|
||||
if fact is None:
|
||||
logger.warning("Unexpected fact shape from LLM, skipping: %s", item)
|
||||
continue
|
||||
else:
|
||||
fact = str(item)
|
||||
if fact:
|
||||
normalized.append(fact)
|
||||
return normalized
|
||||
|
||||
|
||||
def remove_code_blocks(content: str) -> str:
|
||||
"""
|
||||
|
||||
+1
-1
@@ -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" }
|
||||
|
||||
@@ -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"]}}]
|
||||
|
||||
+76
-1
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0 import Memory
|
||||
from mem0.configs.base import MemoryConfig
|
||||
from mem0.memory.utils import normalize_facts
|
||||
|
||||
|
||||
class MockVectorMemory:
|
||||
@@ -244,4 +246,77 @@ def test_get_all_handles_flat_list_from_postgres(mock_sqlite, mock_llm_factory,
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["memory"] == "Memory 1"
|
||||
assert result[1]["memory"] == "Memory 2"
|
||||
assert result[1]["memory"] == "Memory 2"
|
||||
|
||||
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@patch('mem0.utils.factory.VectorStoreFactory.create')
|
||||
@patch('mem0.utils.factory.LlmFactory.create')
|
||||
@patch('mem0.memory.storage.SQLiteManager')
|
||||
def test_add_infer_with_malformed_llm_facts(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
|
||||
"""
|
||||
Repro for: 'list' object has no attribute 'replace' on infer=true.
|
||||
|
||||
When an LLM (especially smaller models like llama3.1:8b) returns facts as
|
||||
objects ({"fact": "..."} or {"text": "..."}) instead of plain strings,
|
||||
the embedding model's .replace() call crashes with AttributeError.
|
||||
"""
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed.side_effect = lambda text, action: (_ for _ in ()).throw(
|
||||
AttributeError("'dict' object has no attribute 'replace'")
|
||||
) if not isinstance(text, str) else [0.1, 0.2, 0.3]
|
||||
mock_embedder_factory.return_value = mock_embedder
|
||||
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_store.search.return_value = []
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
|
||||
# LLM returns malformed facts: dicts instead of strings
|
||||
malformed_response = json.dumps({
|
||||
"facts": [
|
||||
{"fact": "User likes Python"},
|
||||
{"text": "User is a developer"},
|
||||
]
|
||||
})
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.generate_response.return_value = malformed_response
|
||||
mock_llm_factory.return_value = mock_llm
|
||||
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory as MemoryClass
|
||||
config = MemoryConfig()
|
||||
memory = MemoryClass(config)
|
||||
|
||||
# This should NOT raise AttributeError
|
||||
memory._add_to_vector_store(
|
||||
messages=[{"role": "user", "content": "I like Python and I'm a developer"}],
|
||||
metadata={"user_id": "test_user"},
|
||||
filters={"user_id": "test_user"},
|
||||
infer=True,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_facts_plain_strings():
|
||||
assert normalize_facts(["fact one", "fact two"]) == ["fact one", "fact two"]
|
||||
|
||||
|
||||
def test_normalize_facts_dict_with_fact_key():
|
||||
assert normalize_facts([{"fact": "User likes Python"}]) == ["User likes Python"]
|
||||
|
||||
|
||||
def test_normalize_facts_dict_with_text_key():
|
||||
assert normalize_facts([{"text": "User is a developer"}]) == ["User is a developer"]
|
||||
|
||||
|
||||
def test_normalize_facts_mixed():
|
||||
raw = [
|
||||
"plain string",
|
||||
{"fact": "from fact key"},
|
||||
{"text": "from text key"},
|
||||
]
|
||||
assert normalize_facts(raw) == ["plain string", "from fact key", "from text key"]
|
||||
|
||||
|
||||
def test_normalize_facts_filters_empty_strings():
|
||||
assert normalize_facts(["", "valid", ""]) == ["valid"]
|
||||
|
||||
+104
-25
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user