Compare commits

..
Author SHA1 Message Date
kartik-mem0 00354cc74d Merge branch 'main' into fix/ts-oss-camelcase-filters 2026-08-14 17:13:37 +05:30
kartik-mem0 ac76eb7568 fix(ts-oss): accept camelCase entity ids inside search/getAll filters
Both methods reject top-level entity params with "Use filters: { userId:
"..." } instead", and docs/open-source/node-quickstart.mdx teaches that same
shape. But search() and getAll() only read filters.user_id / .agent_id /
.run_id, so following either one throws a second, contradictory error:
"filters must contain at least one of: user_id, agent_id, run_id".

Normalize the three camelCase aliases to snake_case before validation in both
methods, so the shape the SDK tells callers to use actually works. An explicit
snake_case value wins if both are supplied, and the camelCase key is dropped
rather than passed through to the vector store as a stray metadata field.
2026-08-13 18:24:40 +05:30
9 changed files with 102 additions and 156 deletions
+37 -8
View File
@@ -149,6 +149,27 @@ function rejectTopLevelEntityParams(
}
}
const ENTITY_ID_ALIASES: Record<string, string> = {
userId: "user_id",
agentId: "agent_id",
runId: "run_id",
};
/** Rewrites camelCase entity ids inside filters to the snake_case keys vector stores index on. */
function normalizeEntityFilterKeys(
filters: Record<string, any>,
): Record<string, any> {
const normalized = { ...filters };
for (const [camelKey, snakeKey] of Object.entries(ENTITY_ID_ALIASES)) {
if (!(camelKey in normalized)) continue;
if (normalized[snakeKey] === undefined) {
normalized[snakeKey] = normalized[camelKey];
}
delete normalized[camelKey];
}
return normalized;
}
/**
* Validates and normalizes an entity ID.
* - Coerces non-string ids (e.g. numeric database keys) to string
@@ -1357,16 +1378,20 @@ export class Memory {
// receive `agent_id: undefined` / `run_id: undefined` and fail
// (Qdrant rejects the malformed match, pgvector binds NULL, Redis
// emits a literal "undefined" string in TAG filters).
const requestedFilters = normalizeEntityFilterKeys(config.filters ?? {});
const normalizedFilters: Record<string, any> = config.filters
? Object.fromEntries(
Object.entries({
...config.filters,
user_id: validateAndTrimEntityId(config.filters.user_id, "user_id"),
...requestedFilters,
user_id: validateAndTrimEntityId(
requestedFilters.user_id,
"user_id",
),
agent_id: validateAndTrimEntityId(
config.filters.agent_id,
requestedFilters.agent_id,
"agent_id",
),
run_id: validateAndTrimEntityId(config.filters.run_id, "run_id"),
run_id: validateAndTrimEntityId(requestedFilters.run_id, "run_id"),
}).filter(([, v]) => v !== undefined),
)
: {};
@@ -1853,12 +1878,16 @@ export class Memory {
// Validate and trim entity IDs in filters. Drop keys that resolve to
// undefined so downstream vector stores don't receive
// `agent_id: undefined` / `run_id: undefined` and fail.
const requestedFilters = normalizeEntityFilterKeys(config.filters || {});
const filters: Record<string, any> = Object.fromEntries(
Object.entries({
...(config.filters || {}),
user_id: validateAndTrimEntityId(config.filters?.user_id, "user_id"),
agent_id: validateAndTrimEntityId(config.filters?.agent_id, "agent_id"),
run_id: validateAndTrimEntityId(config.filters?.run_id, "run_id"),
...requestedFilters,
user_id: validateAndTrimEntityId(requestedFilters.user_id, "user_id"),
agent_id: validateAndTrimEntityId(
requestedFilters.agent_id,
"agent_id",
),
run_id: validateAndTrimEntityId(requestedFilters.run_id, "run_id"),
}).filter(([, v]) => v !== undefined),
);
@@ -281,6 +281,66 @@ describe("Memory Input Validation", () => {
});
});
describe("camelCase entity IDs in filters", () => {
it("maps camelCase filter keys to snake_case in search", async () => {
const searchSpy = jest
.spyOn((memory as any).vectorStore, "search")
.mockResolvedValue([]);
await memory.search("q", {
filters: { userId: "alice", agentId: "bot", runId: "run-1" },
});
const passedFilters = searchSpy.mock.calls[0][2] as Record<string, any>;
expect(passedFilters).toMatchObject({
user_id: "alice",
agent_id: "bot",
run_id: "run-1",
});
expect(passedFilters.userId).toBeUndefined();
expect(passedFilters.agentId).toBeUndefined();
expect(passedFilters.runId).toBeUndefined();
searchSpy.mockRestore();
});
it("maps camelCase filter keys to snake_case in getAll", async () => {
const listSpy = jest
.spyOn((memory as any).vectorStore, "list")
.mockResolvedValue([[], 0]);
await memory.getAll({ filters: { userId: "alice" } });
const passedFilters = listSpy.mock.calls[0][0] as Record<string, any>;
expect(passedFilters.user_id).toBe("alice");
expect(passedFilters.userId).toBeUndefined();
listSpy.mockRestore();
});
it("prefers an explicit snake_case value over its camelCase alias", async () => {
const listSpy = jest
.spyOn((memory as any).vectorStore, "list")
.mockResolvedValue([[], 0]);
await memory.getAll({
filters: { user_id: "snake", userId: "camel" },
});
const passedFilters = listSpy.mock.calls[0][0] as Record<string, any>;
expect(passedFilters.user_id).toBe("snake");
expect(passedFilters.userId).toBeUndefined();
listSpy.mockRestore();
});
it("validates camelCase filter values like their snake_case counterparts", async () => {
await expect(
memory.search("q", { filters: { userId: "user 123" } }),
).rejects.toThrow("Invalid user_id: cannot contain whitespace");
});
});
describe("search() filter entity ID validation", () => {
it("should throw error when user_id in filters is whitespace-only", async () => {
await expect(
-5
View File
@@ -20,8 +20,6 @@ class BaseEmbedderConfig(ABC):
ollama_base_url: Optional[str] = None,
# Openai specific
openai_base_url: Optional[str] = None,
# Together specific
together_base_url: Optional[str] = None,
# Huggingface specific
model_kwargs: Optional[dict] = None,
huggingface_base_url: Optional[str] = None,
@@ -60,8 +58,6 @@ class BaseEmbedderConfig(ABC):
:type huggingface_base_url: Optional[str], optional
:param openai_base_url: Openai base URL to be use, defaults to "https://api.openai.com/v1"
:type openai_base_url: Optional[str], optional
:param together_base_url: Together base URL to be use, defaults to None
:type together_base_url: Optional[str], optional
:param azure_kwargs: key-value arguments for the AzureOpenAI embedding model, defaults a dict inside init
:type azure_kwargs: Optional[Dict[str, Any]], defaults a dict inside init
:param http_client_proxies: The proxy server settings used to create self.http_client, defaults to None
@@ -81,7 +77,6 @@ class BaseEmbedderConfig(ABC):
self.model = model
self.api_key = api_key
self.openai_base_url = openai_base_url
self.together_base_url = together_base_url
self.embedding_dims = embedding_dims
# AzureOpenAI specific
-56
View File
@@ -1,56 +0,0 @@
from typing import Optional
from mem0.configs.llms.base import BaseLlmConfig
class TogetherConfig(BaseLlmConfig):
"""
Configuration class for Together-specific parameters.
Inherits from BaseLlmConfig and adds Together-specific settings.
"""
def __init__(
self,
# Base parameters
model: Optional[str] = None,
temperature: float = 0.1,
api_key: Optional[str] = None,
max_tokens: int = 2000,
top_p: float = 0.1,
top_k: int = 1,
enable_vision: bool = False,
vision_details: Optional[str] = "auto",
http_client_proxies: Optional[dict] = None,
# Together-specific parameters
together_base_url: Optional[str] = None,
):
"""
Initialize Together configuration.
Args:
model: Together model to use, defaults to None
temperature: Controls randomness, defaults to 0.1
api_key: Together API key, defaults to None
max_tokens: Maximum tokens to generate, defaults to 2000
top_p: Nucleus sampling parameter, defaults to 0.1
top_k: Top-k sampling parameter, defaults to 1
enable_vision: Enable vision capabilities, defaults to False
vision_details: Vision detail level, defaults to "auto"
http_client_proxies: HTTP client proxy settings, defaults to None
together_base_url: Together API base URL, defaults to None
"""
# Initialize base parameters
super().__init__(
model=model,
temperature=temperature,
api_key=api_key,
max_tokens=max_tokens,
top_p=top_p,
top_k=top_k,
enable_vision=enable_vision,
vision_details=vision_details,
http_client_proxies=http_client_proxies,
)
# Together-specific parameters
self.together_base_url = together_base_url
+1 -2
View File
@@ -13,9 +13,8 @@ class TogetherEmbedding(EmbeddingBase):
self.config.model = self.config.model or "intfloat/multilingual-e5-large-instruct"
api_key = self.config.api_key or os.getenv("TOGETHER_API_KEY")
base_url = self.config.together_base_url or os.getenv("TOGETHER_API_BASE") or "https://api.together.ai/v1"
self.config.embedding_dims = self.config.embedding_dims or 1024
self.client = Together(api_key=api_key, base_url=base_url)
self.client = Together(api_key=api_key)
def embed(self, text, memory_action: Optional[Literal["add", "search", "update"]] = None):
"""
+3 -24
View File
@@ -1,6 +1,6 @@
import json
import os
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional
try:
from together import Together
@@ -8,40 +8,19 @@ except ImportError:
raise ImportError("The 'together' library is required. Please install it using 'pip install together'.")
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.together import TogetherConfig
from mem0.llms.base import LLMBase
from mem0.memory.utils import extract_json
class TogetherLLM(LLMBase):
def __init__(self, config: Optional[Union[BaseLlmConfig, TogetherConfig, Dict]] = None):
# Convert to TogetherConfig if needed
if config is None:
config = TogetherConfig()
elif isinstance(config, dict):
config = TogetherConfig(**config)
elif isinstance(config, BaseLlmConfig) and not isinstance(config, TogetherConfig):
# Convert BaseLlmConfig to TogetherConfig
config = TogetherConfig(
model=config.model,
temperature=config.temperature,
api_key=config.api_key,
max_tokens=config.max_tokens,
top_p=config.top_p,
top_k=config.top_k,
enable_vision=config.enable_vision,
vision_details=config.vision_details,
http_client_proxies=config.http_client_proxies,
)
def __init__(self, config: Optional[BaseLlmConfig] = None):
super().__init__(config)
if not self.config.model:
self.config.model = "MiniMaxAI/MiniMax-M3"
api_key = self.config.api_key or os.getenv("TOGETHER_API_KEY")
base_url = self.config.together_base_url or os.getenv("TOGETHER_API_BASE") or "https://api.together.ai/v1"
self.client = Together(api_key=api_key, base_url=base_url)
self.client = Together(api_key=api_key)
def _parse_response(self, response, tools):
"""
+1 -2
View File
@@ -13,7 +13,6 @@ from mem0.configs.llms.lmstudio import LMStudioConfig
from mem0.configs.llms.minimax import MinimaxConfig
from mem0.configs.llms.ollama import OllamaConfig
from mem0.configs.llms.openai import OpenAIConfig
from mem0.configs.llms.together import TogetherConfig
from mem0.configs.llms.vllm import VllmConfig
from mem0.configs.llms.xai import XAIConfig
from mem0.configs.rerankers.base import BaseRerankerConfig
@@ -44,7 +43,7 @@ class LlmFactory:
"ollama": ("mem0.llms.ollama.OllamaLLM", OllamaConfig),
"openai": ("mem0.llms.openai.OpenAILLM", OpenAIConfig),
"groq": ("mem0.llms.groq.GroqLLM", BaseLlmConfig),
"together": ("mem0.llms.together.TogetherLLM", TogetherConfig),
"together": ("mem0.llms.together.TogetherLLM", BaseLlmConfig),
"aws_bedrock": ("mem0.llms.aws_bedrock.AWSBedrockLLM", AWSBedrockConfig),
"litellm": ("mem0.llms.litellm.LiteLLM", BaseLlmConfig),
"azure_openai": ("mem0.llms.azure_openai.AzureOpenAILLM", AzureOpenAIConfig),
@@ -85,29 +85,3 @@ def test_explicit_config_overrides_defaults(mock_together_client):
mock_together_client.embeddings.create.return_value = Mock(data=[Mock(embedding=[0.0] * 768)])
embedder.embed("hello")
mock_together_client.embeddings.create.assert_called_once_with(model="BAAI/bge-base-en-v1.5", input="hello")
def test_together_embedding_base_url(monkeypatch):
# case1: default config uses Together's official base url
monkeypatch.delenv("TOGETHER_API_BASE", raising=False)
config = BaseEmbedderConfig(model=DEFAULT_MODEL, embedding_dims=DEFAULT_EMBEDDING_DIMS, api_key="api_key")
embedder = TogetherEmbedding(config)
assert str(embedder.client.base_url) == "https://api.together.ai/v1/"
# case2: with env variable TOGETHER_API_BASE
provider_base_url = "https://api.provider.com/v1/"
monkeypatch.setenv("TOGETHER_API_BASE", provider_base_url)
config = BaseEmbedderConfig(model=DEFAULT_MODEL, embedding_dims=DEFAULT_EMBEDDING_DIMS, api_key="api_key")
embedder = TogetherEmbedding(config)
assert str(embedder.client.base_url) == provider_base_url
# case3: with config.together_base_url (explicit config beats env var)
config_base_url = "https://api.config.com/v1/"
config = BaseEmbedderConfig(
model=DEFAULT_MODEL,
embedding_dims=DEFAULT_EMBEDDING_DIMS,
api_key="api_key",
together_base_url=config_base_url,
)
embedder = TogetherEmbedding(config)
assert str(embedder.client.base_url) == config_base_url
-33
View File
@@ -3,7 +3,6 @@ from unittest.mock import Mock, patch
import pytest
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.together import TogetherConfig
from mem0.llms.together import TogetherLLM
@@ -15,38 +14,6 @@ def mock_together_client():
yield mock_client
def test_together_llm_base_url(monkeypatch):
# case1: default config uses Together's official base url
monkeypatch.delenv("TOGETHER_API_BASE", raising=False)
config = BaseLlmConfig(
model="mistralai/Mixtral-8x7B-Instruct-v0.1", temperature=0.7, max_tokens=100, top_p=1.0, api_key="api_key"
)
llm = TogetherLLM(config)
assert str(llm.client.base_url) == "https://api.together.ai/v1/"
# case2: with env variable TOGETHER_API_BASE
provider_base_url = "https://api.provider.com/v1/"
monkeypatch.setenv("TOGETHER_API_BASE", provider_base_url)
config = TogetherConfig(
model="mistralai/Mixtral-8x7B-Instruct-v0.1", temperature=0.7, max_tokens=100, top_p=1.0, api_key="api_key"
)
llm = TogetherLLM(config)
assert str(llm.client.base_url) == provider_base_url
# case3: with config.together_base_url (explicit config beats env var)
config_base_url = "https://api.config.com/v1/"
config = TogetherConfig(
model="mistralai/Mixtral-8x7B-Instruct-v0.1",
temperature=0.7,
max_tokens=100,
top_p=1.0,
api_key="api_key",
together_base_url=config_base_url,
)
llm = TogetherLLM(config)
assert str(llm.client.base_url) == config_base_url
def test_generate_response_without_tools(mock_together_client):
config = BaseLlmConfig(model="mistralai/Mixtral-8x7B-Instruct-v0.1", temperature=0.7, max_tokens=100, top_p=1.0)
llm = TogetherLLM(config)