Compare commits

..
Author SHA1 Message Date
kartik-mem0 b8f7e1dc6e Merge branch 'main' into fix/together-base-url-py 2026-08-14 17:13:36 +05:30
kartik-mem0 2ef6291307 fix(llms,embeddings): honor TOGETHER_API_BASE for Together LLM and embedder
The Python Together LLM and embedder ignored TOGETHER_API_BASE, unlike
their TypeScript siblings, so users behind a gateway or proxy could not
redirect Together traffic. Add together_base_url to a new TogetherConfig
(LLM side) and to BaseEmbedderConfig (embedder side, matching the existing
openai_base_url/huggingface_base_url pattern), and resolve base_url with
explicit config value > TOGETHER_API_BASE env var > hardcoded default in
both mem0/llms/together.py and mem0/embeddings/together.py. Also register
TogetherConfig in LlmFactory.provider_to_class so config dicts routed
through Memory pick it up.

Closes #6587
2026-08-13 19:41:09 +05:30
13 changed files with 263 additions and 101 deletions
+2
View File
@@ -2,6 +2,8 @@
module.exports = {
...require("./jest.config"),
testMatch: ["**/integration/**/*.test.ts"],
globalSetup: "<rootDir>/src/client/tests/integration/global-setup.ts",
globalTeardown: "<rootDir>/src/client/tests/integration/global-teardown.ts",
// Run integration tests serially to avoid rate limiting and race conditions
maxWorkers: 1,
};
@@ -0,0 +1,40 @@
/**
* Jest global setup for integration tests.
*
* Runs a full project cleanup before any integration test starts,
* then waits 10 seconds for the async cleanup to propagate.
*/
import { MemoryClient } from "../../mem0";
export default async function globalSetup() {
const apiKey = process.env.MEM0_API_KEY;
if (!apiKey) return; // skip if no key — tests will be skipped too
const client = new MemoryClient({ apiKey });
await client.ping();
console.log("[integration] Running pre-test cleanup...");
// Full project wipe — all four filters set explicitly
try {
await client.deleteAll({
userId: "*",
agentId: "*",
appId: "*",
runId: "*",
});
} catch {
// ignore — may 404 if no data exists
}
try {
await client.deleteUsers();
} catch {
// ignore — may throw "No entities to delete"
}
// Wait 10 seconds for async cleanup to propagate
console.log("[integration] Waiting 10s for cleanup to propagate...");
await new Promise((r) => setTimeout(r, 10_000));
console.log("[integration] Pre-test cleanup done.");
}
@@ -0,0 +1,36 @@
/**
* Jest global teardown for integration tests.
*
* Runs a full project cleanup after all integration tests complete
* so no test data is left behind.
*/
import { MemoryClient } from "../../mem0";
export default async function globalTeardown() {
const apiKey = process.env.MEM0_API_KEY;
if (!apiKey) return;
const client = new MemoryClient({ apiKey });
await client.ping();
console.log("[integration] Running post-test cleanup...");
try {
await client.deleteAll({
userId: "*",
agentId: "*",
appId: "*",
runId: "*",
});
} catch {
// ignore
}
try {
await client.deleteUsers();
} catch {
// ignore
}
console.log("[integration] Post-test cleanup done.");
}
+36 -25
View File
@@ -52,17 +52,13 @@ export async function withRetry<T>(
}
/**
* Poll getAll until a user's memories reach minCount and the ID set
* stops changing between polls.
*
* The Mem0 API processes memories asynchronously, and consolidating a
* new fact replaces a memory (delete + add) instead of editing it in
* place. Returning on the first non-empty read hands callers IDs the
* pipeline then invalidates, so compare IDs rather than length: a
* replacement leaves the count unchanged.
* Poll getAll until memories appear for a user.
* The Mem0 API processes memories asynchronously — after add()
* we need to wait for them to be available.
*
* Polls every 15 seconds with a maximum of 4 retries to avoid
* hitting rate limits. Throws if minCount is never reached.
* hitting rate limits. Throws if results aren't available after
* all retries.
*/
export async function waitForMemories(
client: MemoryClient,
@@ -70,32 +66,18 @@ export async function waitForMemories(
minCount: number,
maxRetries = 4,
): Promise<Memory[]> {
let memories: Memory[] = [];
let previousIds = "";
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const response = await withRetry(() =>
client.getAll({ filters: { user_id: userId } }),
);
memories = response.results ?? [];
const ids = memories
.map((m) => m.id)
.sort()
.join(",");
if (memories.length >= minCount && ids === previousIds) {
const memories = response.results ?? [];
if (memories.length >= minCount) {
return memories;
}
previousIds = ids;
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 15_000));
}
}
if (memories.length >= minCount) {
return memories;
}
throw new Error(
`waitForMemories: expected at least ${minCount} memories for user "${userId}" but did not get them after ${maxRetries} attempts`,
);
@@ -220,3 +202,32 @@ export async function cleanupTestUser(
// ignore
}
}
/**
* Full project wipe — deletes all memories and all entities.
* Equivalent to Python SDK's:
* client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
*
* Used as cleanup before and after integration test runs so tests
* start from a clean slate and don't leave data behind.
*/
export async function fullProjectCleanup(client: MemoryClient): Promise<void> {
// Delete all memories — all four filters set explicitly
try {
await client.deleteAll({
userId: "*",
agentId: "*",
appId: "*",
runId: "*",
});
} catch {
// ignore — may 404 if no data exists
}
// Delete all entities (users, agents, apps, runs)
try {
await client.deleteUsers();
} catch {
// ignore — may throw "No entities to delete"
}
}
@@ -100,7 +100,6 @@ describeIntegration("MemoryClient Integration — Users & Project", () => {
const hookName = `test-hook-${randomUUID().slice(0, 8)}`;
const hookUrl = `https://example.com/webhook/${randomUUID().slice(0, 8)}`;
const updatedName = `updated-hook-${randomUUID().slice(0, 8)}`;
const updatedUrl = `https://example.com/updated/${randomUUID().slice(0, 8)}`;
afterAll(async () => {
if (createdWebhookId) {
@@ -169,7 +168,7 @@ describeIntegration("MemoryClient Integration — Users & Project", () => {
client.updateWebhook({
webhookId: createdWebhookId,
name: updatedName,
url: updatedUrl,
url: "https://example.com/updated",
eventTypes: [WebhookEvent.MEMORY_DELETED],
}),
);
@@ -1,69 +0,0 @@
import { waitForMemories } from "./integration/helpers";
const clientReturning = (polls: Array<Array<{ id: string }>>) => {
let call = 0;
return {
getAll: jest.fn(async () => ({
results: polls[Math.min(call++, polls.length - 1)],
})),
} as any;
};
const settle = async <T>(pending: Promise<T>): Promise<T> => {
pending.catch(() => {});
for (let i = 0; i < 4; i++) {
await jest.advanceTimersByTimeAsync(15_000);
}
return pending;
};
describe("waitForMemories", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test("waits for the id set to stop changing", async () => {
const client = clientReturning([
[{ id: "a" }],
[{ id: "a" }, { id: "b" }],
[{ id: "a" }, { id: "b" }],
]);
const memories = await settle(waitForMemories(client, "user", 1));
expect(memories.map((m) => m.id)).toEqual(["a", "b"]);
expect(client.getAll).toHaveBeenCalledTimes(3);
});
test("keeps polling when consolidation replaces a memory at a constant count", async () => {
const client = clientReturning([
[{ id: "a" }, { id: "b" }],
[{ id: "a" }, { id: "c" }],
[{ id: "a" }, { id: "c" }],
]);
const memories = await settle(waitForMemories(client, "user", 1));
expect(memories.map((m) => m.id)).toEqual(["a", "c"]);
});
test("returns the last read when the set never settles but minCount is met", async () => {
const client = clientReturning([
[{ id: "a" }],
[{ id: "b" }],
[{ id: "c" }],
[{ id: "d" }],
]);
const memories = await settle(waitForMemories(client, "user", 1));
expect(memories.map((m) => m.id)).toEqual(["d"]);
});
test("throws when minCount is never reached", async () => {
const client = clientReturning([[]]);
await expect(settle(waitForMemories(client, "user", 1))).rejects.toThrow(
/expected at least 1 memories/,
);
});
});
+5
View File
@@ -20,6 +20,8 @@ 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,
@@ -58,6 +60,8 @@ 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
@@ -77,6 +81,7 @@ 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
@@ -0,0 +1,56 @@
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
+2 -1
View File
@@ -13,8 +13,9 @@ 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)
self.client = Together(api_key=api_key, base_url=base_url)
def embed(self, text, memory_action: Optional[Literal["add", "search", "update"]] = None):
"""
+24 -3
View File
@@ -1,6 +1,6 @@
import json
import os
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Union
try:
from together import Together
@@ -8,19 +8,40 @@ 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[BaseLlmConfig] = None):
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,
)
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")
self.client = Together(api_key=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)
def _parse_response(self, response, tools):
"""
+2 -1
View File
@@ -13,6 +13,7 @@ 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
@@ -43,7 +44,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", BaseLlmConfig),
"together": ("mem0.llms.together.TogetherLLM", TogetherConfig),
"aws_bedrock": ("mem0.llms.aws_bedrock.AWSBedrockLLM", AWSBedrockConfig),
"litellm": ("mem0.llms.litellm.LiteLLM", BaseLlmConfig),
"azure_openai": ("mem0.llms.azure_openai.AzureOpenAILLM", AzureOpenAIConfig),
@@ -85,3 +85,29 @@ 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,6 +3,7 @@ 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
@@ -14,6 +15,38 @@ 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)