chore: merge origin/main into mintlify/af82bda4 and resolve conflicts
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Python SDK tests (`tests/`)
|
||||
|
||||
pytest suite for the `mem0/` package.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make install_all # optional deps; several tests need them
|
||||
make test # pytest tests/
|
||||
make test-py-3.9 # pin a Python version (3.9 through 3.12)
|
||||
|
||||
pytest tests/llms/test_openai.py::test_generate_response # single test
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Files are named `test_<module>.py`.
|
||||
- Provider tests mirror the source tree: `tests/<category>/<provider_name>/`.
|
||||
- pytest-mock for mocks, pytest-asyncio for the async surface.
|
||||
- Ruff line length **120**, matching `mem0/`. See [`../mem0/AGENTS.md`](../mem0/AGENTS.md).
|
||||
- Mock the provider SDK, never the code under test. A test that asserts the implementation back at itself is worse than no test.
|
||||
- Bug fixes need a regression test that fails without the fix. Write it first and watch it fail.
|
||||
|
||||
Tests for other packages live with those packages: `mem0-ts/` (jest), `cli/python/tests/` (pytest), `cli/node/` (vitest), and each directory under `integrations/`.
|
||||
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -72,7 +72,8 @@ def test_embed_with_custom_embedding_dims(mock_sentence_transformer):
|
||||
assert result == [1.0, 1.1, 1.2]
|
||||
|
||||
|
||||
def test_embed_with_huggingface_base_url():
|
||||
def test_embed_with_huggingface_base_url(monkeypatch):
|
||||
monkeypatch.delenv("HUGGINGFACE_API_KEY", raising=False)
|
||||
config = BaseEmbedderConfig(
|
||||
huggingface_base_url="http://localhost:8080",
|
||||
model="my-custom-model",
|
||||
@@ -81,20 +82,20 @@ def test_embed_with_huggingface_base_url():
|
||||
with patch("mem0.embeddings.huggingface.OpenAI") as mock_openai:
|
||||
mock_client = Mock()
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
|
||||
# Create a mock for the response object and its attributes
|
||||
mock_embedding_response = Mock()
|
||||
mock_embedding_response.embedding = [0.1, 0.2, 0.3]
|
||||
|
||||
|
||||
mock_create_response = Mock()
|
||||
mock_create_response.data = [mock_embedding_response]
|
||||
|
||||
|
||||
mock_client.embeddings.create.return_value = mock_create_response
|
||||
|
||||
embedder = HuggingFaceEmbedding(config)
|
||||
result = embedder.embed("Hello from custom endpoint")
|
||||
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key=None)
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="hf")
|
||||
mock_client.embeddings.create.assert_called_once_with(
|
||||
input="Hello from custom endpoint",
|
||||
model="my-custom-model",
|
||||
@@ -115,6 +116,26 @@ def test_embed_with_huggingface_base_url_forwards_api_key():
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="tei-token")
|
||||
|
||||
|
||||
def test_embed_with_huggingface_base_url_falls_back_to_env_var(monkeypatch):
|
||||
monkeypatch.setenv("HUGGINGFACE_API_KEY", "env-token")
|
||||
config = BaseEmbedderConfig(huggingface_base_url="http://localhost:8080", model="my-custom-model")
|
||||
with patch("mem0.embeddings.huggingface.OpenAI") as mock_openai:
|
||||
HuggingFaceEmbedding(config)
|
||||
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="env-token")
|
||||
|
||||
|
||||
def test_embed_with_huggingface_base_url_config_key_beats_env_var(monkeypatch):
|
||||
monkeypatch.setenv("HUGGINGFACE_API_KEY", "env-token")
|
||||
config = BaseEmbedderConfig(
|
||||
huggingface_base_url="http://localhost:8080", model="my-custom-model", api_key="config-token"
|
||||
)
|
||||
with patch("mem0.embeddings.huggingface.OpenAI") as mock_openai:
|
||||
HuggingFaceEmbedding(config)
|
||||
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="config-token")
|
||||
|
||||
|
||||
def test_embed_batch_sentence_transformer(mock_sentence_transformer):
|
||||
config = BaseEmbedderConfig()
|
||||
embedder = HuggingFaceEmbedding(config)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("langchain", reason="langchain is an optional extra")
|
||||
|
||||
from langchain.embeddings.base import Embeddings # noqa: E402
|
||||
|
||||
from mem0.configs.embeddings.base import BaseEmbedderConfig # noqa: E402
|
||||
from mem0.embeddings.langchain import LangchainEmbedding # noqa: E402
|
||||
|
||||
|
||||
class DummyEmbeddings(Embeddings):
|
||||
def __init__(self):
|
||||
self.queries = []
|
||||
|
||||
def embed_documents(self, texts):
|
||||
return [[0.1, 0.2, 0.3] for _ in texts]
|
||||
|
||||
def embed_query(self, text):
|
||||
self.queries.append(text)
|
||||
return [0.1, 0.2, 0.3]
|
||||
|
||||
|
||||
def test_missing_model_raises():
|
||||
with pytest.raises(ValueError, match="`model` parameter is required"):
|
||||
LangchainEmbedding(BaseEmbedderConfig())
|
||||
|
||||
|
||||
def test_model_that_is_not_an_embeddings_instance_raises():
|
||||
with pytest.raises(ValueError, match="`model` must be an instance of Embeddings"):
|
||||
LangchainEmbedding(BaseEmbedderConfig(model="text-embedding-3-small"))
|
||||
|
||||
|
||||
def test_configured_model_instance_is_kept():
|
||||
model = DummyEmbeddings()
|
||||
|
||||
assert LangchainEmbedding(BaseEmbedderConfig(model=model)).langchain_model is model
|
||||
|
||||
|
||||
def test_embed_delegates_to_embed_query():
|
||||
model = DummyEmbeddings()
|
||||
embedder = LangchainEmbedding(BaseEmbedderConfig(model=model))
|
||||
|
||||
assert embedder.embed("hello", "add") == [0.1, 0.2, 0.3]
|
||||
assert model.queries == ["hello"]
|
||||
|
||||
|
||||
def test_memory_action_never_reaches_the_model():
|
||||
model = DummyEmbeddings()
|
||||
embedder = LangchainEmbedding(BaseEmbedderConfig(model=model))
|
||||
|
||||
embedder.embed("hello", memory_action="add")
|
||||
|
||||
assert model.queries == ["hello"]
|
||||
|
||||
|
||||
def test_embed_batch_falls_back_to_sequential_embed():
|
||||
model = DummyEmbeddings()
|
||||
embedder = LangchainEmbedding(BaseEmbedderConfig(model=model))
|
||||
|
||||
assert embedder.embed_batch(["a", "b"]) == [[0.1, 0.2, 0.3], [0.1, 0.2, 0.3]]
|
||||
assert model.queries == ["a", "b"]
|
||||
@@ -1,3 +1,6 @@
|
||||
import builtins
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -94,3 +97,25 @@ def test_embed_batch_count_mismatch_raises(mock_ollama_client):
|
||||
|
||||
with pytest.raises(ValueError, match="returned 1 embeddings for 2 texts"):
|
||||
embedder.embed_batch(["first text", "second text"])
|
||||
|
||||
|
||||
def test_missing_ollama_raises_actionable_import_error(monkeypatch):
|
||||
"""Missing ollama raises a catchable ImportError naming the install command, never input()/sys.exit()."""
|
||||
monkeypatch.delitem(sys.modules, "mem0.embeddings.ollama", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "ollama", raising=False)
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "ollama":
|
||||
raise ImportError("No module named 'ollama'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with patch("builtins.input") as mock_input, patch("sys.exit") as mock_exit:
|
||||
with pytest.raises(ImportError, match="pip install ollama"):
|
||||
importlib.import_module("mem0.embeddings.ollama")
|
||||
|
||||
mock_input.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
@@ -6,7 +6,6 @@ from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
|
||||
from mem0.llms.aws_bedrock import AWSBedrockLLM, extract_provider
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -70,6 +69,23 @@ class TestExtractProvider:
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
extract_provider("unknown-vendor.some-model-v1:0")
|
||||
|
||||
def test_application_inference_profile_arn_without_override_raises(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
extract_provider(arn)
|
||||
|
||||
def test_application_inference_profile_arn_with_explicit_provider(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
assert extract_provider(arn, "anthropic") == "anthropic"
|
||||
|
||||
def test_explicit_provider_takes_precedence_over_regex(self):
|
||||
assert extract_provider("anthropic.claude-3-5-sonnet-20240620-v1:0", "amazon") == "amazon"
|
||||
|
||||
def test_explicit_provider_typo_raises(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
with pytest.raises(ValueError, match="Unknown provider_override 'anthorpic'"):
|
||||
extract_provider(arn, "anthorpic")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWSBedrockConfig
|
||||
@@ -114,6 +130,44 @@ class TestAWSBedrockConfig:
|
||||
)
|
||||
assert config.aws_region == "us-east-2"
|
||||
|
||||
def test_provider_override_defaults_to_none(self):
|
||||
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
assert config.provider_override is None
|
||||
|
||||
def test_provider_override_stored(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
config = AWSBedrockConfig(model=arn, provider_override="anthropic")
|
||||
assert config.provider_override == "anthropic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWSBedrockLLM with application inference profile ARNs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplicationInferenceProfileArn:
|
||||
ARN = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
|
||||
def test_arn_without_provider_override_raises(self, mock_boto3):
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
_make_llm(self.ARN, mock_boto3)
|
||||
|
||||
def test_arn_with_provider_override_resolves(self, mock_boto3):
|
||||
llm = _make_llm(self.ARN, mock_boto3, provider_override="anthropic")
|
||||
assert llm.provider == "anthropic"
|
||||
assert llm.supports_tools is True
|
||||
|
||||
def test_plain_model_id_unaffected(self, mock_boto3):
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
||||
assert llm.provider == "anthropic"
|
||||
|
||||
def test_cross_region_inference_profile_unaffected(self, mock_boto3):
|
||||
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3)
|
||||
assert llm.provider == "anthropic"
|
||||
|
||||
def test_arn_with_misspelled_provider_override_raises(self, mock_boto3):
|
||||
with pytest.raises(ValueError, match="Unknown provider_override 'anthorpic'"):
|
||||
_make_llm(self.ARN, mock_boto3, provider_override="anthorpic")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LlmFactory
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from mem0 import AsyncMemory, Memory
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.vllm import VllmConfig
|
||||
from mem0.llms.vllm import VllmLLM
|
||||
|
||||
|
||||
@@ -15,6 +16,59 @@ def mock_vllm_client():
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_base_url_explicit_arg_wins_over_env(mock_vllm_client, monkeypatch):
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
|
||||
config = VllmConfig(vllm_base_url="http://from-arg:8000/v1")
|
||||
VllmLLM(config)
|
||||
|
||||
assert config.vllm_base_url == "http://from-arg:8000/v1"
|
||||
|
||||
|
||||
def test_base_url_env_wins_over_default(mock_vllm_client, monkeypatch):
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
|
||||
config = VllmConfig()
|
||||
VllmLLM(config)
|
||||
|
||||
assert config.vllm_base_url == "http://from-env:8000/v1"
|
||||
|
||||
|
||||
def test_base_url_default_when_neither_set(mock_vllm_client, monkeypatch):
|
||||
monkeypatch.delenv("VLLM_BASE_URL", raising=False)
|
||||
config = VllmConfig()
|
||||
VllmLLM(config)
|
||||
|
||||
assert config.vllm_base_url == "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_openai_client_receives_explicit_base_url(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_BASE_URL", raising=False)
|
||||
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
|
||||
VllmLLM(VllmConfig(vllm_base_url="http://from-arg:8000/v1"))
|
||||
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "http://from-arg:8000/v1"
|
||||
|
||||
|
||||
def test_openai_client_receives_base_url_from_env(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
|
||||
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
|
||||
VllmLLM(VllmConfig())
|
||||
|
||||
base_url = mock_openai.call_args.kwargs["base_url"]
|
||||
assert base_url == "http://from-env:8000/v1"
|
||||
assert base_url != "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_openai_client_receives_base_url_from_env_via_base_llm_config(monkeypatch):
|
||||
"""Original #6256 repro: BaseLlmConfig (not VllmConfig) plus VLLM_BASE_URL only."""
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://remote-vllm:8000/v1")
|
||||
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
|
||||
VllmLLM(BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct"))
|
||||
|
||||
base_url = mock_openai.call_args.kwargs["base_url"]
|
||||
assert base_url == "http://remote-vllm:8000/v1"
|
||||
assert base_url != "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_generate_response_without_tools(mock_vllm_client):
|
||||
config = BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = VllmLLM(config)
|
||||
|
||||
@@ -192,3 +192,16 @@ class TestProcessTelemetryFilters:
|
||||
class TestRemoveCodeBlocks:
|
||||
def test_none_content_returns_empty_string(self):
|
||||
assert remove_code_blocks(None) == ""
|
||||
|
||||
def test_list_content_from_langchain_aimessage_is_joined(self):
|
||||
assert remove_code_blocks([{"type": "text", "text": "hello"}]) == "hello"
|
||||
|
||||
def test_list_content_joins_multiple_blocks_and_bare_strings(self):
|
||||
content = [{"type": "text", "text": "hello "}, "world", {"type": "thinking", "thinking": "ignored"}]
|
||||
assert remove_code_blocks(content) == "hello world"
|
||||
|
||||
def test_list_content_still_strips_code_fences(self):
|
||||
assert remove_code_blocks([{"type": "text", "text": "```json\n{}\n```"}]) == "{}"
|
||||
|
||||
def test_unsupported_content_type_returns_empty_string(self):
|
||||
assert remove_code_blocks(42) == ""
|
||||
|
||||
+113
-21
@@ -56,6 +56,7 @@ def notice_harness(monkeypatch):
|
||||
config = {}
|
||||
telemetry = MagicMock()
|
||||
telemetry.user_id = "oss-user"
|
||||
evaluate_mock = MagicMock()
|
||||
|
||||
def write_config(updated):
|
||||
saved = deepcopy(updated)
|
||||
@@ -66,13 +67,15 @@ def notice_harness(monkeypatch):
|
||||
monkeypatch.setattr(notices, "_write_config", write_config)
|
||||
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True)
|
||||
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry)
|
||||
monkeypatch.setattr(notices, "_evaluate_notice_flags", evaluate_mock)
|
||||
telemetry._evaluate_mock = evaluate_mock
|
||||
|
||||
return config, telemetry
|
||||
|
||||
|
||||
def configure_flag(telemetry, variant, payload):
|
||||
flags = FakeFlags(variant, payload)
|
||||
telemetry.posthog.evaluate_flags.return_value = flags
|
||||
telemetry._evaluate_mock.return_value = flags
|
||||
return flags
|
||||
|
||||
|
||||
@@ -176,7 +179,7 @@ def test_displayed_notice_logs_once_and_captures_event(notice_harness, capsys):
|
||||
config, telemetry, flags = display_notice(notice_harness)
|
||||
|
||||
assert capsys.readouterr().err == "Mem0 OSS notice\n"
|
||||
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
|
||||
telemetry._evaluate_mock.assert_called_once_with("oss-user")
|
||||
telemetry.capture_event.assert_called_once()
|
||||
event_name, props = telemetry.capture_event.call_args.args
|
||||
assert event_name == notices.NOTICE_EVENT
|
||||
@@ -254,7 +257,7 @@ def test_telemetry_disabled_does_not_touch_posthog_or_state(monkeypatch, capsys)
|
||||
|
||||
def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys):
|
||||
config, telemetry = notice_harness
|
||||
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
|
||||
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
|
||||
|
||||
notices.display_first_run_notice(MagicMock(), "sync", "add")
|
||||
|
||||
@@ -265,7 +268,7 @@ def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys
|
||||
|
||||
def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness):
|
||||
_, telemetry = notice_harness
|
||||
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
|
||||
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
|
||||
memory = Memory.__new__(Memory)
|
||||
memory.config = SimpleNamespace(llm=SimpleNamespace(config={}))
|
||||
memory._add_to_vector_store = MagicMock(return_value=[{"event": "ADD", "memory": "likes tea"}])
|
||||
@@ -277,7 +280,7 @@ def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness):
|
||||
|
||||
def test_public_search_succeeds_when_first_run_flag_eval_fails(notice_harness, monkeypatch):
|
||||
_, telemetry = notice_harness
|
||||
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
|
||||
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
|
||||
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
|
||||
memory = Memory.__new__(Memory)
|
||||
memory.api_version = "v1.1"
|
||||
@@ -325,7 +328,7 @@ def test_temporal_feature_displayed_returns_payload_copy_and_captures_event(noti
|
||||
|
||||
assert message == "Temporal CTA"
|
||||
assert capsys.readouterr().err == ""
|
||||
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
|
||||
telemetry._evaluate_mock.assert_called_once_with("oss-user")
|
||||
telemetry.capture_event.assert_called_once()
|
||||
event_name, props = telemetry.capture_event.call_args.args
|
||||
assert event_name == notices.NOTICE_EVENT
|
||||
@@ -413,7 +416,7 @@ def test_temporal_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch,
|
||||
|
||||
def test_temporal_feature_posthog_failure_returns_plain_error(notice_harness, capsys):
|
||||
_, telemetry = notice_harness
|
||||
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
|
||||
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
|
||||
|
||||
message = notices.get_temporal_feature_error_message("sync", "add", "timestamp")
|
||||
|
||||
@@ -429,14 +432,14 @@ def test_temporal_feature_cap_blocks_repeated_posthog_evaluation(notice_harness)
|
||||
for _ in range(notices.FEATURE_ERROR_CAP):
|
||||
assert notices.get_temporal_feature_error_message("sync", "add", "timestamp") == "Temporal CTA"
|
||||
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert len(config["notice_state"]["temporal_stub"]["events"]) == notices.FEATURE_ERROR_CAP
|
||||
|
||||
message = notices.get_temporal_feature_error_message("sync", "add", "timestamp")
|
||||
|
||||
assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"]
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
|
||||
|
||||
|
||||
@@ -465,7 +468,7 @@ def test_decay_feature_displayed_returns_payload_copy_and_captures_event(notice_
|
||||
|
||||
assert message == "Decay CTA"
|
||||
assert capsys.readouterr().err == ""
|
||||
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
|
||||
telemetry._evaluate_mock.assert_called_once_with("oss-user")
|
||||
telemetry.capture_event.assert_called_once()
|
||||
event_name, props = telemetry.capture_event.call_args.args
|
||||
assert event_name == notices.NOTICE_EVENT
|
||||
@@ -552,7 +555,7 @@ def test_decay_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch, ca
|
||||
|
||||
def test_decay_feature_posthog_failure_returns_plain_error(notice_harness, capsys):
|
||||
_, telemetry = notice_harness
|
||||
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
|
||||
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
|
||||
|
||||
message = notices.get_decay_feature_error_message("sync", "project.update", "decay")
|
||||
|
||||
@@ -582,14 +585,14 @@ def test_decay_feature_cap_blocks_repeated_posthog_evaluation(notice_harness):
|
||||
for _ in range(notices.FEATURE_ERROR_CAP):
|
||||
assert notices.get_decay_feature_error_message("sync", "project.update", "decay") == "Decay CTA"
|
||||
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert len(config["notice_state"]["decay_stub"]["events"]) == notices.FEATURE_ERROR_CAP
|
||||
|
||||
message = notices.get_decay_feature_error_message("sync", "project.update", "decay")
|
||||
|
||||
assert message == notices.DECAY_FEATURE_ERROR_MESSAGE
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
|
||||
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
|
||||
|
||||
|
||||
@@ -670,7 +673,7 @@ def test_temporal_usage_displayed_logs_and_captures_event(notice_harness, capsys
|
||||
notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase")
|
||||
|
||||
assert capsys.readouterr().err == "Temporal usage CTA\n"
|
||||
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
|
||||
telemetry._evaluate_mock.assert_called_once_with("oss-user")
|
||||
telemetry.capture_event.assert_called_once()
|
||||
event_name, props = telemetry.capture_event.call_args.args
|
||||
assert event_name == notices.NOTICE_EVENT
|
||||
@@ -771,7 +774,7 @@ def test_temporal_usage_cap_blocks_before_posthog_eval(notice_harness, capsys):
|
||||
notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase")
|
||||
|
||||
assert capsys.readouterr().err == "Temporal usage CTA\n" * notices.TEMPORAL_USAGE_CAP
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.TEMPORAL_USAGE_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.TEMPORAL_USAGE_CAP
|
||||
assert telemetry.capture_event.call_count == notices.TEMPORAL_USAGE_CAP
|
||||
assert len(config["notice_state"]["temporal_usage"]["events"]) == notices.TEMPORAL_USAGE_CAP
|
||||
|
||||
@@ -869,7 +872,7 @@ def test_decay_usage_displayed_logs_and_captures_event(notice_harness, capsys):
|
||||
)
|
||||
|
||||
assert capsys.readouterr().err == "Decay usage CTA\n"
|
||||
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
|
||||
telemetry._evaluate_mock.assert_called_once_with("oss-user")
|
||||
telemetry.capture_event.assert_called_once()
|
||||
event_name, props = telemetry.capture_event.call_args.args
|
||||
assert event_name == notices.NOTICE_EVENT
|
||||
@@ -994,7 +997,7 @@ def test_decay_usage_telemetry_disabled_does_not_touch_posthog_or_state(monkeypa
|
||||
|
||||
def test_decay_usage_posthog_failure_does_not_consume_cap(notice_harness, capsys):
|
||||
config, telemetry = notice_harness
|
||||
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("posthog down")
|
||||
telemetry._evaluate_mock.side_effect = RuntimeError("posthog down")
|
||||
|
||||
notices.display_decay_usage_notice(
|
||||
MagicMock(),
|
||||
@@ -1034,7 +1037,7 @@ def test_decay_usage_cap_blocks_before_posthog_eval(notice_harness, capsys):
|
||||
)
|
||||
|
||||
assert capsys.readouterr().err == "Decay usage CTA\n" * notices.DECAY_USAGE_CAP
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.DECAY_USAGE_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.DECAY_USAGE_CAP
|
||||
assert telemetry.capture_event.call_count == notices.DECAY_USAGE_CAP
|
||||
assert len(config["notice_state"]["decay_usage"]["events"]) == notices.DECAY_USAGE_CAP
|
||||
|
||||
@@ -1111,7 +1114,7 @@ def test_scale_threshold_displayed_logs_and_captures_event(notice_harness, capsy
|
||||
)
|
||||
|
||||
assert capsys.readouterr().err == "Scale top 50\n"
|
||||
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
|
||||
telemetry._evaluate_mock.assert_called_once_with("oss-user")
|
||||
telemetry.capture_event.assert_called_once()
|
||||
event_name, props = telemetry.capture_event.call_args.args
|
||||
assert event_name == notices.NOTICE_EVENT
|
||||
@@ -1238,7 +1241,7 @@ def test_scale_threshold_telemetry_disabled_does_not_touch_posthog_or_state(monk
|
||||
|
||||
def test_scale_threshold_posthog_failure_does_not_consume_cap(notice_harness, capsys):
|
||||
config, telemetry = notice_harness
|
||||
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable")
|
||||
telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
|
||||
|
||||
notices.display_scale_threshold_notice(
|
||||
MagicMock(),
|
||||
@@ -1281,7 +1284,7 @@ def test_scale_threshold_cap_blocks_before_posthog_eval(notice_harness, capsys):
|
||||
)
|
||||
|
||||
assert capsys.readouterr().err == "Scale top 50\n" * notices.SCALE_THRESHOLD_CAP
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.SCALE_THRESHOLD_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.SCALE_THRESHOLD_CAP
|
||||
assert telemetry.capture_event.call_count == notices.SCALE_THRESHOLD_CAP
|
||||
assert len(config["notice_state"]["scale_threshold"]["events"]) == notices.SCALE_THRESHOLD_CAP
|
||||
|
||||
@@ -1549,3 +1552,92 @@ def test_notice_priority_scale_beats_first_run(monkeypatch):
|
||||
)
|
||||
|
||||
assert calls == ["scale"]
|
||||
|
||||
|
||||
class TestStaticFlagEvaluation:
|
||||
def test_static_flag_result_exposes_posthog_event_properties(self):
|
||||
flags = notices.StaticFlagResult(notices.DISPLAYED_VARIANT, {"notices": {}})
|
||||
|
||||
assert flags._get_event_properties() == {
|
||||
f"$feature/{notices.FLAG_KEY}": notices.DISPLAYED_VARIANT,
|
||||
"$active_feature_flags": [notices.FLAG_KEY],
|
||||
}
|
||||
|
||||
def test_evaluate_notice_flags_returns_displayed_or_holdout(self):
|
||||
result = notices._evaluate_notice_flags("user-abc")
|
||||
variant = result.get_flag(notices.FLAG_KEY)
|
||||
assert variant in (notices.DISPLAYED_VARIANT, notices.HOLDOUT_VARIANT)
|
||||
|
||||
def test_evaluate_notice_flags_deterministic(self):
|
||||
a = notices._evaluate_notice_flags("user-123").get_flag(notices.FLAG_KEY)
|
||||
b = notices._evaluate_notice_flags("user-123").get_flag(notices.FLAG_KEY)
|
||||
assert a == b
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_id", "expected_variant"),
|
||||
[("user-0", notices.HOLDOUT_VARIANT), ("user-1", notices.DISPLAYED_VARIANT)],
|
||||
)
|
||||
def test_evaluate_notice_flags_preserves_posthog_cohorts(self, monkeypatch, user_id, expected_variant):
|
||||
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 0.5, "notices": {}})
|
||||
monkeypatch.setattr(notices, "_cached_config_ts", float("inf"))
|
||||
|
||||
assert notices._evaluate_notice_flags(user_id).get_flag(notices.FLAG_KEY) == expected_variant
|
||||
|
||||
def test_evaluate_notice_flags_returns_payload_with_notices(self):
|
||||
result = notices._evaluate_notice_flags("user-abc")
|
||||
payload = result.get_flag_payload(notices.FLAG_KEY)
|
||||
assert "notices" in payload
|
||||
|
||||
def test_get_notice_config_uses_bundled_fallback(self, monkeypatch):
|
||||
monkeypatch.setattr(notices, "_cached_config", None)
|
||||
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
|
||||
monkeypatch.setattr(notices, "_fetch_remote_config", lambda: None)
|
||||
config = notices._get_notice_config()
|
||||
assert "notices" in config
|
||||
assert config.get("version") == 1
|
||||
|
||||
def test_remote_config_fetch_uses_telemetry_timeout(self, monkeypatch):
|
||||
timeouts = []
|
||||
|
||||
def fail_fetch(_request, timeout):
|
||||
timeouts.append(timeout)
|
||||
raise TimeoutError
|
||||
|
||||
monkeypatch.setattr(notices.urllib.request, "urlopen", fail_fetch)
|
||||
|
||||
assert notices._fetch_remote_config() is None
|
||||
assert timeouts == [telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS]
|
||||
|
||||
def test_get_notice_config_prefers_remote(self, monkeypatch):
|
||||
remote = {"version": 2, "variant_split": 0.5, "notices": {"first_run": {"copy": "remote", "enabled": True}}}
|
||||
monkeypatch.setattr(notices, "_cached_config", None)
|
||||
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
|
||||
monkeypatch.setattr(notices, "_fetch_remote_config", lambda: remote)
|
||||
config = notices._get_notice_config()
|
||||
assert config["version"] == 2
|
||||
|
||||
def test_get_notice_config_caches_result(self, monkeypatch):
|
||||
call_count = 0
|
||||
remote = {"version": 3, "variant_split": 0.5, "notices": {}}
|
||||
|
||||
def counting_fetch():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return remote
|
||||
|
||||
monkeypatch.setattr(notices, "_cached_config", None)
|
||||
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
|
||||
monkeypatch.setattr(notices, "_fetch_remote_config", counting_fetch)
|
||||
notices._get_notice_config()
|
||||
notices._get_notice_config()
|
||||
assert call_count == 1
|
||||
|
||||
def test_variant_split_respects_config(self, monkeypatch):
|
||||
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 1.0, "notices": {}})
|
||||
monkeypatch.setattr(notices, "_cached_config_ts", float("inf"))
|
||||
result = notices._evaluate_notice_flags("any-user")
|
||||
assert result.get_flag(notices.FLAG_KEY) == notices.DISPLAYED_VARIANT
|
||||
|
||||
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 0.0, "notices": {}})
|
||||
result = notices._evaluate_notice_flags("any-user")
|
||||
assert result.get_flag(notices.FLAG_KEY) == notices.HOLDOUT_VARIANT
|
||||
|
||||
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.memory import notices
|
||||
from mem0.memory import main as memory_main
|
||||
from mem0.memory import notices
|
||||
from mem0.memory.main import AsyncMemory, Memory
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ def notice_harness(monkeypatch):
|
||||
config = {}
|
||||
telemetry = MagicMock()
|
||||
telemetry.user_id = "oss-user"
|
||||
evaluate_mock = MagicMock()
|
||||
|
||||
def write_config(updated):
|
||||
saved = deepcopy(updated)
|
||||
@@ -44,12 +45,14 @@ def notice_harness(monkeypatch):
|
||||
monkeypatch.setattr(notices, "_write_config", write_config)
|
||||
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True)
|
||||
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry)
|
||||
monkeypatch.setattr(notices, "_evaluate_notice_flags", evaluate_mock)
|
||||
telemetry._evaluate_mock = evaluate_mock
|
||||
return config, telemetry
|
||||
|
||||
|
||||
def configure_flag(telemetry, variant, payload):
|
||||
flags = FakeFlags(variant, payload)
|
||||
telemetry.posthog.evaluate_flags.return_value = flags
|
||||
telemetry._evaluate_mock.return_value = flags
|
||||
return flags
|
||||
|
||||
|
||||
@@ -274,7 +277,7 @@ def test_performance_slow_query_displayed_logs_and_captures_event(notice_harness
|
||||
)
|
||||
|
||||
assert capsys.readouterr().err == "Performance CTA\n"
|
||||
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
|
||||
telemetry._evaluate_mock.assert_called_once_with("oss-user")
|
||||
telemetry.capture_event.assert_called_once()
|
||||
event_name, props = telemetry.capture_event.call_args.args
|
||||
assert event_name == notices.NOTICE_EVENT
|
||||
@@ -421,7 +424,7 @@ def test_performance_slow_query_cap_blocks_before_posthog_eval(notice_harness, c
|
||||
)
|
||||
|
||||
assert capsys.readouterr().err == "Performance CTA\n" * notices.PERFORMANCE_SLOW_QUERY_CAP
|
||||
assert telemetry.posthog.evaluate_flags.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
|
||||
assert telemetry._evaluate_mock.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
|
||||
assert telemetry.capture_event.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
|
||||
assert len(config["notice_state"]["performance_slow_query"]["events"]) == notices.PERFORMANCE_SLOW_QUERY_CAP
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.memory.main import Memory, _build_session_scope, _escape_scope_value
|
||||
|
||||
DELIMITER_VALUES = [
|
||||
"u1",
|
||||
"r1",
|
||||
"a1",
|
||||
"%",
|
||||
"&",
|
||||
"=",
|
||||
"%25",
|
||||
"%26",
|
||||
"%3D",
|
||||
"a==",
|
||||
"a1&run_id=r1",
|
||||
"a1&user_id=u1",
|
||||
"r1&user_id=u1",
|
||||
"a1&run_id=r1&user_id=u1",
|
||||
]
|
||||
|
||||
|
||||
class TestBuildSessionScope:
|
||||
"""Tests that _build_session_scope produces a unique key per id combination."""
|
||||
|
||||
def test_ordinary_ids_produce_unchanged_scope_keys(self):
|
||||
"""Ids without delimiter characters keep producing the pre-fix key format."""
|
||||
cases = [
|
||||
({"user_id": "550e8400-e29b-41d4-a716-446655440000"}, "user_id=550e8400-e29b-41d4-a716-446655440000"),
|
||||
({"agent_id": "agent.assistant:v2"}, "agent_id=agent.assistant:v2"),
|
||||
({"run_id": "12345"}, "run_id=12345"),
|
||||
(
|
||||
{"user_id": "user@example.com", "agent_id": "support-bot"},
|
||||
"agent_id=support-bot&user_id=user@example.com",
|
||||
),
|
||||
(
|
||||
{"user_id": "u1", "agent_id": "a1", "run_id": "r1"},
|
||||
"agent_id=a1&run_id=r1&user_id=u1",
|
||||
),
|
||||
]
|
||||
for filters, expected in cases:
|
||||
assert _build_session_scope(filters) == expected
|
||||
|
||||
def test_ids_containing_delimiters_do_not_collide(self):
|
||||
"""A value that embeds the join syntax no longer maps to the same key as the equivalent split filters."""
|
||||
collapsed_run = {"run_id": "proj-x&user_id=u1"}
|
||||
split_run = {"user_id": "u1", "run_id": "proj-x"}
|
||||
assert _build_session_scope(collapsed_run) != _build_session_scope(split_run)
|
||||
assert _build_session_scope(collapsed_run) == "run_id=proj-x%26user_id%3Du1"
|
||||
|
||||
collapsed_agent = {"run_id": "proj-y&agent_id=a1"}
|
||||
split_agent = {"agent_id": "a1", "run_id": "proj-y"}
|
||||
assert _build_session_scope(collapsed_agent) != _build_session_scope(split_agent)
|
||||
|
||||
def test_ids_containing_delimiters_get_a_new_key_format(self):
|
||||
"""Ids holding a delimiter character map to a new key, so their buffer starts empty once after upgrade."""
|
||||
assert _build_session_scope({"user_id": "dXNlcl9pZDE="}) == "user_id=dXNlcl9pZDE%3D"
|
||||
assert _build_session_scope({"agent_id": "x&y"}) == "agent_id=x%26y"
|
||||
assert _build_session_scope({"run_id": "50% off"}) == "run_id=50%25 off"
|
||||
|
||||
def test_scope_keys_are_unique_per_filter_combination(self):
|
||||
"""Every distinct filter dict built from delimiter-heavy id values maps to a distinct scope key."""
|
||||
keys = ["user_id", "agent_id", "run_id"]
|
||||
seen = {}
|
||||
for size in range(1, len(keys) + 1):
|
||||
for key_subset in itertools.combinations(keys, size):
|
||||
for combo in itertools.product(DELIMITER_VALUES, repeat=size):
|
||||
filters = dict(zip(key_subset, combo))
|
||||
scope = _build_session_scope(filters)
|
||||
if scope in seen:
|
||||
assert seen[scope] == filters, f"{seen[scope]} and {filters} both map to {scope!r}"
|
||||
else:
|
||||
seen[scope] = filters
|
||||
|
||||
|
||||
class TestEscapeScopeValue:
|
||||
"""Tests for the low-level per-value escaping helper."""
|
||||
|
||||
def test_non_string_input_is_stringified(self):
|
||||
assert _escape_scope_value(42) == "42"
|
||||
|
||||
def test_percent_is_escaped_before_other_delimiters(self):
|
||||
assert _escape_scope_value("%26") == "%2526"
|
||||
assert _escape_scope_value("%26") != "%26"
|
||||
|
||||
def test_each_delimiter_is_escaped(self):
|
||||
assert _escape_scope_value("%") == "%25"
|
||||
assert _escape_scope_value("&") == "%26"
|
||||
assert _escape_scope_value("=") == "%3D"
|
||||
|
||||
|
||||
class TestSessionScopeWiring:
|
||||
"""Tests that the add pipeline keys the conversation buffer through the builder."""
|
||||
|
||||
@pytest.fixture
|
||||
def memory(self, mocker):
|
||||
mocker.patch("mem0.memory.main.capture_event")
|
||||
mock_embedder = mocker.MagicMock()
|
||||
mock_embedder.return_value.embed.return_value = [0.1, 0.2, 0.3]
|
||||
mocker.patch("mem0.utils.factory.EmbedderFactory.create", mock_embedder)
|
||||
mock_vector_store = mocker.MagicMock()
|
||||
mock_vector_store.return_value.search.return_value = []
|
||||
mocker.patch(
|
||||
"mem0.utils.factory.VectorStoreFactory.create",
|
||||
side_effect=[mock_vector_store.return_value, mocker.MagicMock()],
|
||||
)
|
||||
mocker.patch("mem0.utils.factory.LlmFactory.create", mocker.MagicMock())
|
||||
mocker.patch("mem0.memory.storage.SQLiteManager", mocker.MagicMock())
|
||||
|
||||
memory = Memory()
|
||||
memory.config = mocker.MagicMock()
|
||||
memory.config.custom_instructions = None
|
||||
memory.custom_instructions = None
|
||||
memory.api_version = "v1.1"
|
||||
memory.db.get_last_messages = mocker.MagicMock(return_value=[])
|
||||
memory.db.save_messages = mocker.MagicMock()
|
||||
memory.llm.generate_response.return_value = '{"memory": []}'
|
||||
return memory
|
||||
|
||||
def test_add_pipeline_uses_the_escaped_key(self, memory):
|
||||
"""The pipeline must route through the builder, not assemble the key inline."""
|
||||
memory._add_to_vector_store(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
metadata={},
|
||||
filters={"run_id": "proj-x&user_id=u1"},
|
||||
infer=True,
|
||||
)
|
||||
assert memory.db.get_last_messages.call_args[0][0] == "run_id=proj-x%26user_id%3Du1"
|
||||
assert memory.db.save_messages.call_args[0][1] == "run_id=proj-x%26user_id%3Du1"
|
||||
@@ -8,6 +8,7 @@ import httpx
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from mem0.client.main import AsyncMemoryClient
|
||||
from mem0.client.types import GetAllMemoryOptions, SearchMemoryOptions
|
||||
|
||||
|
||||
@@ -181,6 +182,150 @@ class TestGetAllTypedOptionsParity:
|
||||
)
|
||||
|
||||
|
||||
class TestGetAllPageSizeQueryParams:
|
||||
"""MEM-6227: page and page_size must reach the API as query params independently."""
|
||||
|
||||
def test_get_all_page_size_alone_lands_in_query_params(self, mock_memory_client):
|
||||
"""get_all(page_size=...) without page should send page_size as a query param."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"}, page_size=50)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page_size": 50},
|
||||
)
|
||||
|
||||
def test_get_all_page_alone_lands_in_query_params(self, mock_memory_client):
|
||||
"""get_all(page=...) without page_size should send page as a query param."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"}, page=2)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 2},
|
||||
)
|
||||
|
||||
def test_get_all_page_and_page_size_together(self, mock_memory_client):
|
||||
"""get_all(page=..., page_size=...) should send both as query params."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"}, page=2, page_size=50)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 2, "page_size": 50},
|
||||
)
|
||||
|
||||
def test_get_all_neither_page_nor_page_size_sends_no_query_params(self, mock_memory_client):
|
||||
"""get_all() without page or page_size should not pass a params kwarg."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"})
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
)
|
||||
|
||||
def test_async_get_all_page_size_alone_lands_in_query_params(self):
|
||||
asyncio.run(self._assert_async_get_all_page_size_alone())
|
||||
|
||||
async def _assert_async_get_all_page_size_alone(self):
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"}, page_size=50)
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page_size": 50},
|
||||
)
|
||||
|
||||
def test_async_get_all_page_alone_lands_in_query_params(self):
|
||||
asyncio.run(self._assert_async_get_all_page_alone())
|
||||
|
||||
async def _assert_async_get_all_page_alone(self):
|
||||
"""get_all(page=0) without page_size should still send page=0 as a query param."""
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"}, page=0)
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 0},
|
||||
)
|
||||
|
||||
def test_async_get_all_page_and_page_size_together(self):
|
||||
asyncio.run(self._assert_async_get_all_page_and_page_size_together())
|
||||
|
||||
async def _assert_async_get_all_page_and_page_size_together(self):
|
||||
"""get_all(page=0, page_size=0) should send both as query params despite being falsy."""
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"}, page=0, page_size=0)
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 0, "page_size": 0},
|
||||
)
|
||||
|
||||
def test_async_get_all_neither_page_nor_page_size_sends_no_query_params(self):
|
||||
asyncio.run(self._assert_async_get_all_neither_page_nor_page_size())
|
||||
|
||||
async def _assert_async_get_all_neither_page_nor_page_size(self):
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"})
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateExpirationDate:
|
||||
"""Tests for update expiration_date payload handling."""
|
||||
|
||||
@@ -440,3 +585,51 @@ class TestValidateApiKeyHttpError:
|
||||
|
||||
assert not isinstance(exc_info.value, requests.exceptions.JSONDecodeError)
|
||||
assert "Error:" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestAddAgentCustomInstructions:
|
||||
"""Per-request override of the project-level setting, forwarded to the add payload."""
|
||||
|
||||
def _mock_add(self, client):
|
||||
response = MagicMock()
|
||||
response.json.return_value = {"results": []}
|
||||
response.raise_for_status.return_value = None
|
||||
client.client.post.return_value = response
|
||||
return response
|
||||
|
||||
def test_kwarg_reaches_the_add_payload(self, mock_memory_client):
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add(
|
||||
"hello",
|
||||
filters={"agent_id": "a1"},
|
||||
agent_custom_instructions="remember tool failures",
|
||||
)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
|
||||
|
||||
def test_typed_option_reaches_the_add_payload(self, mock_memory_client):
|
||||
from mem0.client.types import AddMemoryOptions
|
||||
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add(
|
||||
"hello",
|
||||
AddMemoryOptions(
|
||||
filters={"agent_id": "a1"},
|
||||
agent_custom_instructions="remember tool failures",
|
||||
),
|
||||
)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
|
||||
|
||||
def test_absent_when_not_passed(self, mock_memory_client):
|
||||
"""Callers that don't use the feature send an unchanged payload."""
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add("hello", filters={"user_id": "u1"})
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert "agent_custom_instructions" not in kwargs["json"]
|
||||
|
||||
+93
-2
@@ -1235,9 +1235,10 @@ class TestHybridSearchWarning:
|
||||
def test_warning_for_store_without_keyword_search(
|
||||
self, mock_vs_factory, mock_emb, mock_llm, mock_sqlite, _cap, caplog
|
||||
):
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
import logging
|
||||
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
class StoreWithoutKeywordSearch(VectorStoreBase):
|
||||
def create_col(self, *a, **kw): pass
|
||||
def insert(self, *a, **kw): pass
|
||||
@@ -1271,9 +1272,10 @@ class TestHybridSearchWarning:
|
||||
def test_no_warning_for_store_with_keyword_search(
|
||||
self, mock_vs_factory, mock_emb, mock_llm, mock_sqlite, _cap, caplog
|
||||
):
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
import logging
|
||||
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
class StoreWithKeywordSearch(VectorStoreBase):
|
||||
def keyword_search(self, query, top_k=5, filters=None):
|
||||
return []
|
||||
@@ -1574,3 +1576,92 @@ async def test_async_procedural_memory_default_path_without_langchain(mock_llm_f
|
||||
|
||||
assert result["results"][0]["event"] == "ADD"
|
||||
memory.llm.generate_response.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.memory.main.VectorStoreFactory")
|
||||
@patch("mem0.memory.main.EmbedderFactory")
|
||||
@patch("mem0.memory.main.LlmFactory")
|
||||
async def test_async_procedural_memory_langchain_list_content(mock_llm_factory, mock_emb, mock_vs):
|
||||
"""Regression #6150: a LangChain block list must be extracted, not dropped."""
|
||||
mock_vs.return_value = MagicMock()
|
||||
mock_emb.return_value = MagicMock()
|
||||
mock_emb.return_value.embed.return_value = [0.1] * 1536
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import AsyncMemory
|
||||
|
||||
memory = AsyncMemory(MemoryConfig())
|
||||
memory.vector_store = MagicMock()
|
||||
memory.vector_store.insert = MagicMock()
|
||||
|
||||
mock_langchain_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [{"type": "text", "text": "- deploy with the release script"}]
|
||||
mock_langchain_llm.invoke.return_value = mock_response
|
||||
|
||||
await memory._create_procedural_memory(
|
||||
[{"role": "user", "content": "how do we deploy"}],
|
||||
metadata={"user_id": "test_user"},
|
||||
llm=mock_langchain_llm,
|
||||
)
|
||||
|
||||
stored_data = memory.vector_store.insert.call_args[1]["payloads"][0]["data"]
|
||||
assert stored_data == "- deploy with the release script"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.memory.main.VectorStoreFactory")
|
||||
@patch("mem0.memory.main.EmbedderFactory")
|
||||
@patch("mem0.memory.main.LlmFactory")
|
||||
async def test_async_procedural_memory_empty_content_raises(mock_llm_factory, mock_emb, mock_vs):
|
||||
"""Regression #6150: a refusal (content=None) must raise, not store an empty memory."""
|
||||
mock_vs.return_value = MagicMock()
|
||||
mock_emb.return_value = MagicMock()
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import AsyncMemory
|
||||
|
||||
memory = AsyncMemory(MemoryConfig())
|
||||
memory.vector_store = MagicMock()
|
||||
memory.vector_store.insert = MagicMock()
|
||||
|
||||
mock_langchain_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = None
|
||||
mock_langchain_llm.invoke.return_value = mock_response
|
||||
|
||||
with pytest.raises(ValueError, match="no content for the procedural memory summary"):
|
||||
await memory._create_procedural_memory(
|
||||
[{"role": "user", "content": "how do we deploy"}],
|
||||
metadata={"user_id": "test_user"},
|
||||
llm=mock_langchain_llm,
|
||||
)
|
||||
|
||||
memory.vector_store.insert.assert_not_called()
|
||||
|
||||
|
||||
@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_sync_procedural_memory_empty_content_raises(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""Regression #6150: the sync path must raise on empty LLM content too."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
memory = Memory(MemoryConfig())
|
||||
memory.llm.generate_response = Mock(return_value=None)
|
||||
|
||||
with pytest.raises(ValueError, match="no content for the procedural memory summary"):
|
||||
memory._create_procedural_memory(
|
||||
[{"role": "user", "content": "how do we deploy"}],
|
||||
metadata={"user_id": "test_user"},
|
||||
)
|
||||
|
||||
mock_vector_store.insert.assert_not_called()
|
||||
|
||||
+46
-2
@@ -3,8 +3,8 @@ parameter-passthrough surface.
|
||||
|
||||
Verifies the kwarg → JSON payload mapping for every supported field
|
||||
(``custom_instructions``, ``custom_categories``, ``multilingual``,
|
||||
``decay``), the ValueError when no field is provided, and the
|
||||
URL/method shape. The HTTP layer is mocked.
|
||||
``decay``, ``agent_custom_instructions``), the ValueError when no field
|
||||
is provided, and the URL/method shape. The HTTP layer is mocked.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -84,6 +84,50 @@ class TestProjectUpdateDecay:
|
||||
assert args[0] == "/api/v1/orgs/organizations/org1/projects/proj1/"
|
||||
|
||||
|
||||
class TestProjectUpdateAgentCustomInstructions:
|
||||
def test_agent_custom_instructions_sent_in_payload(self, project):
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="remember tool failures")
|
||||
assert _patch_payload(http) == {"agent_custom_instructions": "remember tool failures"}
|
||||
|
||||
def test_agent_custom_instructions_alone_satisfies_the_guard(self, project):
|
||||
"""It is a standalone field, so setting only it must not raise."""
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="remember tool failures")
|
||||
assert http.patch.called
|
||||
|
||||
def test_empty_string_round_trips_to_clear_the_field(self, project):
|
||||
"""An empty string must survive the ``is not None`` filter, not be dropped as falsy."""
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="")
|
||||
assert _patch_payload(http) == {"agent_custom_instructions": ""}
|
||||
|
||||
def test_combined_with_custom_instructions(self, project):
|
||||
"""Both sets in one PATCH: the split-instruction configuration."""
|
||||
proj, http = project
|
||||
proj.update(
|
||||
custom_instructions="remember user preferences",
|
||||
agent_custom_instructions="remember tool failures",
|
||||
)
|
||||
assert _patch_payload(http) == {
|
||||
"custom_instructions": "remember user preferences",
|
||||
"agent_custom_instructions": "remember tool failures",
|
||||
}
|
||||
|
||||
def test_omitted_when_none(self, project):
|
||||
"""Callers that don't pass it must send an unchanged payload."""
|
||||
proj, http = project
|
||||
proj.update(custom_instructions="be concise")
|
||||
payload = _patch_payload(http)
|
||||
assert payload == {"custom_instructions": "be concise"}
|
||||
assert "agent_custom_instructions" not in payload
|
||||
|
||||
def test_no_args_raises_with_agent_instructions_in_message(self, project):
|
||||
proj, _ = project
|
||||
with pytest.raises(ValueError, match=r"agent_custom_instructions"):
|
||||
proj.update()
|
||||
|
||||
|
||||
class TestProjectUpdateBackwardsCompat:
|
||||
def test_multilingual_only_still_works(self, project):
|
||||
"""Pre-decay callers (multilingual only) keep working unchanged."""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import builtins
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -138,3 +141,20 @@ def test_completions_create_messages_default_does_not_leak_between_calls(mock_me
|
||||
f"Completions.create(messages=...) must default to None to avoid the "
|
||||
f"B006 shared-default-list bug; got {messages_default!r}."
|
||||
)
|
||||
|
||||
|
||||
def test_missing_litellm_raises_actionable_import_error(monkeypatch):
|
||||
monkeypatch.delitem(sys.modules, "mem0.proxy.main", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "litellm", raising=False)
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "litellm":
|
||||
raise ImportError("No module named 'litellm'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
with pytest.raises(ImportError, match="pip install litellm"):
|
||||
importlib.import_module("mem0.proxy.main")
|
||||
|
||||
@@ -249,9 +249,23 @@ REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
|
||||
|
||||
|
||||
def _redis_search_available(host, port, timeout=2):
|
||||
"""RediSearch (the FT.* command family) is an optional module, not part of vanilla Redis."""
|
||||
if not _tcp_reachable(host, port, timeout=timeout):
|
||||
return False
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.Redis(host=host, port=port, socket_connect_timeout=timeout, socket_timeout=timeout)
|
||||
modules = {m["name"].lower() for m in client.module_list()}
|
||||
return "search" in modules
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _tcp_reachable(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"Redis not reachable at {REDIS_HOST}:{REDIS_PORT}",
|
||||
not _redis_search_available(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"RediSearch module not available at {REDIS_HOST}:{REDIS_PORT} (requires Redis Stack, not vanilla Redis)",
|
||||
)
|
||||
class TestRedisThreshold:
|
||||
def test_threshold_filtering(self):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Copyright (c) 2026, Oracle and/or its affiliates.
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
@@ -267,6 +268,16 @@ def test_index_parameters_reject_non_string_keys():
|
||||
)
|
||||
|
||||
|
||||
def test_index_accuracy_rejects_zero():
|
||||
with pytest.raises(ValueError, match="index_accuracy.*between 1 and 100"):
|
||||
OracleAIVectorSearchConfig(
|
||||
collection_name=_unique_collection_name(),
|
||||
embedding_model_dims=DIM,
|
||||
client=object(),
|
||||
index_accuracy=0,
|
||||
)
|
||||
|
||||
|
||||
def test_index_parameters_canonicalize_int_subclasses():
|
||||
class FormattedInt(int):
|
||||
def __format__(self, format_spec):
|
||||
@@ -331,6 +342,27 @@ def test_config_rejects_none_for_non_optional_fields(field, value):
|
||||
OracleAIVectorSearchConfig(client=object(), **{field: value})
|
||||
|
||||
|
||||
def test_init_closes_owned_client_when_post_connect_setup_fails(monkeypatch):
|
||||
fake_connection = MagicMock(spec=oracledb.Connection)
|
||||
fake_connection.thin = True
|
||||
fake_connection.version = "23.4.0.0"
|
||||
|
||||
monkeypatch.setattr(oracledb, "connect", MagicMock(return_value=fake_connection))
|
||||
monkeypatch.setattr(OracleAIVectorSearch, "create_col", MagicMock(side_effect=RuntimeError("boom")))
|
||||
monkeypatch.setattr(OracleAIVectorSearch, "__del__", lambda self: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
OracleAIVectorSearch(
|
||||
collection_name=_unique_collection_name(),
|
||||
embedding_model_dims=DIM,
|
||||
connection_params={"user": "u", "password": "p", "dsn": "d"},
|
||||
use_connection_pool=False,
|
||||
do_create_index=False,
|
||||
)
|
||||
|
||||
fake_connection.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("metric", "distance", "expected_score"),
|
||||
[
|
||||
@@ -1049,7 +1081,7 @@ def test_documentation():
|
||||
},
|
||||
]
|
||||
m.add(messages, user_id="alice", metadata={"category": "movies"})
|
||||
results = m.search("What movie to watch?", user_id="alice", limit=2)["results"]
|
||||
results = m.search("What movie to watch?", filters={"user_id": "alice"}, top_k=2)["results"]
|
||||
assert len(results) == 2
|
||||
assert all(res["user_id"] == "alice" for res in results)
|
||||
m.reset()
|
||||
|
||||
@@ -2508,3 +2508,20 @@ class TestBuildFilterConditions(unittest.TestCase):
|
||||
def test_numeric_scalar_becomes_string(self):
|
||||
conditions, params = _build_filter_conditions({"priority": 42})
|
||||
self.assertEqual(params, ["priority", "42"])
|
||||
|
||||
def test_in_rejects_string_value(self):
|
||||
"""Passing a string to 'in' would iterate characters and produce a misleading ANY() clause."""
|
||||
with self.assertRaises(ValueError, msg="Expected ValueError for non-list 'in' value"):
|
||||
_build_filter_conditions({"user_id": {"in": "alice"}})
|
||||
|
||||
def test_in_rejects_dict_value(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_build_filter_conditions({"user_id": {"in": {"$gt": 0}}})
|
||||
|
||||
def test_nin_rejects_string_value(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_build_filter_conditions({"user_id": {"nin": "alice"}})
|
||||
|
||||
def test_in_accepts_list_value(self):
|
||||
conditions, params = _build_filter_conditions({"user_id": {"in": ["alice", "bob"]}})
|
||||
self.assertEqual(params, ["user_id", ["alice", "bob"]])
|
||||
|
||||
@@ -239,9 +239,23 @@ REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
|
||||
|
||||
|
||||
def _redis_search_available(host, port, timeout=2):
|
||||
"""RediSearch (the FT.* command family) is an optional module, not part of vanilla Redis."""
|
||||
if not _tcp_reachable(host, port, timeout=timeout):
|
||||
return False
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.Redis(host=host, port=port, socket_connect_timeout=timeout, socket_timeout=timeout)
|
||||
modules = {m["name"].lower() for m in client.module_list()}
|
||||
return "search" in modules
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _tcp_reachable(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"Redis not reachable at {REDIS_HOST}:{REDIS_PORT}",
|
||||
not _redis_search_available(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"RediSearch module not available at {REDIS_HOST}:{REDIS_PORT} (requires Redis Stack, not vanilla Redis)",
|
||||
)
|
||||
class TestRedis:
|
||||
"""Redis returns cosine distance [0,2]. Conversion: score = max(0, 1 - dist)."""
|
||||
|
||||
Reference in New Issue
Block a user