Compare commits

..
Author SHA1 Message Date
kartik-mem0 65f42bbf43 Merge branch 'main' into fix/lmstudio-embeddings-base-url 2026-08-14 17:13:37 +05:30
kartik-mem0 2b8e4ea059 fix(llms/lmstudio): honor LMSTUDIO_BASE_URL in the LLM too
The embedder fix in this branch left the sibling LLM still hardcoded, so
a self-hosted LM Studio behind a non-default host worked for embeddings
and silently fell back to localhost for generation.

LMStudioConfig baked "http://localhost:1234/v1" into __init__, which
makes any env-var lookup downstream dead code. The field now defaults to
None and LMStudioLLM resolves config value, then LMSTUDIO_BASE_URL, then
the localhost default, matching the embedder and DeepSeek.
2026-08-13 19:45:57 +05:30
kartik-mem0 a5dab4279b fix(ts-oss): honor LMSTUDIO_BASE_URL env var in LM Studio LLM and embedder
Both the TS OSS LM Studio LLM and embedder hardcoded the base URL default
without checking the LMSTUDIO_BASE_URL environment variable, unlike the
Together provider which already supports TOGETHER_API_BASE. Precedence is
now explicit config value > LMSTUDIO_BASE_URL env var > existing default.
2026-08-13 19:42:03 +05:30
kartik-mem0 08e9cc476c fix(embeddings/lmstudio): honor LMSTUDIO_BASE_URL env var
BaseEmbedderConfig baked "http://localhost:1234/v1" into the
lmstudio_base_url default, so the attribute was never falsy and the
embedder had no way to tell "unset" from "explicitly localhost". Setting
LMSTUDIO_BASE_URL had no effect.

The default moves to the provider, which now resolves explicit config,
then LMSTUDIO_BASE_URL, then the localhost default, matching how the
OpenAI embedder already reads OPENAI_BASE_URL. The resolved value is
written back to config.lmstudio_base_url, so the attribute still reads
the same after construction.

Closes #6692
2026-08-13 17:36:59 +05:30
16 changed files with 189 additions and 162 deletions
+5 -1
View File
@@ -12,7 +12,11 @@ export class LMStudioEmbedder implements Embedder {
private model: string;
constructor(config: EmbeddingConfig) {
const baseURL = config.baseURL ?? config.url ?? DEFAULT_BASE_URL;
const baseURL =
config.baseURL ??
config.url ??
process.env.LMSTUDIO_BASE_URL ??
DEFAULT_BASE_URL;
const apiKey = config.apiKey || DEFAULT_LMSTUDIO_API_KEY;
this.openai = new OpenAI({ apiKey, baseURL: String(baseURL) });
this.model = config.model || DEFAULT_MODEL;
+2 -1
View File
@@ -12,7 +12,8 @@ export class LMStudioLLM extends OpenAILLM {
super({
...config,
apiKey: config.apiKey || DEFAULT_LMSTUDIO_API_KEY,
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
baseURL:
config.baseURL ?? process.env.LMSTUDIO_BASE_URL ?? DEFAULT_BASE_URL,
model: config.model || DEFAULT_MODEL,
});
}
@@ -10,14 +10,64 @@ const mockCreate = jest.fn().mockResolvedValue({
data: [{ embedding: mockEmbedding }],
});
let capturedConstructorArgs: any;
jest.mock("openai", () => {
return jest.fn().mockImplementation(() => ({
embeddings: { create: mockCreate },
}));
return jest.fn().mockImplementation((args: any) => {
capturedConstructorArgs = args;
return {
embeddings: { create: mockCreate },
};
});
});
describe("LMStudioEmbedder (unit)", () => {
beforeEach(() => mockCreate.mockClear());
beforeEach(() => {
mockCreate.mockClear();
capturedConstructorArgs = undefined;
delete process.env.LMSTUDIO_BASE_URL;
});
afterEach(() => {
delete process.env.LMSTUDIO_BASE_URL;
});
it("uses the default base URL when neither config nor env var is set", async () => {
const embedder = new LMStudioEmbedder({ model: "test-model" });
await embedder.embed("hello");
expect(capturedConstructorArgs).toMatchObject({
baseURL: "http://localhost:1234/v1",
});
});
it("uses LMSTUDIO_BASE_URL when config does not provide a base URL", async () => {
process.env.LMSTUDIO_BASE_URL = "http://example.test:5678/v1";
const embedder = new LMStudioEmbedder({ model: "test-model" });
await embedder.embed("hello");
expect(capturedConstructorArgs).toMatchObject({
baseURL: "http://example.test:5678/v1",
});
});
it("prefers an explicit config baseURL over LMSTUDIO_BASE_URL", async () => {
process.env.LMSTUDIO_BASE_URL = "http://example.test:5678/v1";
const embedder = new LMStudioEmbedder({
model: "test-model",
baseURL: "http://config.test:9999/v1",
});
await embedder.embed("hello");
expect(capturedConstructorArgs).toMatchObject({
baseURL: "http://config.test:9999/v1",
});
});
it("embed() calls OpenAI with encoding_format float and returns vector", async () => {
const embedder = new LMStudioEmbedder({
+44 -4
View File
@@ -5,16 +5,56 @@
import { LMStudioLLM } from "../src/llms/lmstudio";
let capturedConstructorArgs: any;
const mockCreate = jest.fn();
jest.mock("openai", () => {
return jest.fn().mockImplementation(() => ({
chat: { completions: { create: mockCreate } },
}));
return jest.fn().mockImplementation((args: any) => {
capturedConstructorArgs = args;
return {
chat: { completions: { create: mockCreate } },
};
});
});
describe("LMStudioLLM (unit)", () => {
beforeEach(() => mockCreate.mockClear());
beforeEach(() => {
mockCreate.mockClear();
capturedConstructorArgs = undefined;
delete process.env.LMSTUDIO_BASE_URL;
});
afterEach(() => {
delete process.env.LMSTUDIO_BASE_URL;
});
it("uses the default base URL when neither config nor env var is set", () => {
new LMStudioLLM({});
expect(capturedConstructorArgs).toMatchObject({
baseURL: "http://localhost:1234/v1",
});
});
it("uses LMSTUDIO_BASE_URL when config does not provide a base URL", () => {
process.env.LMSTUDIO_BASE_URL = "http://example.test:5678/v1";
new LMStudioLLM({});
expect(capturedConstructorArgs).toMatchObject({
baseURL: "http://example.test:5678/v1",
});
});
it("prefers an explicit config baseURL over LMSTUDIO_BASE_URL", () => {
process.env.LMSTUDIO_BASE_URL = "http://example.test:5678/v1";
new LMStudioLLM({ baseURL: "http://config.test:9999/v1" });
expect(capturedConstructorArgs).toMatchObject({
baseURL: "http://config.test:9999/v1",
});
});
it("generateResponse() returns a text response", async () => {
mockCreate.mockResolvedValueOnce({
+3 -7
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,
@@ -36,7 +34,7 @@ class BaseEmbedderConfig(ABC):
# Gemini specific
output_dimensionality: Optional[str] = None,
# LM Studio specific
lmstudio_base_url: Optional[str] = "http://localhost:1234/v1",
lmstudio_base_url: Optional[str] = None,
# AWS Bedrock specific
aws_access_key_id: Optional[str] = None,
aws_secret_access_key: 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
@@ -74,14 +70,14 @@ class BaseEmbedderConfig(ABC):
:type memory_update_embedding_type: Optional[str], optional
:param memory_search_embedding_type: The type of embedding to use for the search memory action, defaults to None
:type memory_search_embedding_type: Optional[str], optional
:param lmstudio_base_url: LM Studio base URL to be use, defaults to "http://localhost:1234/v1"
:param lmstudio_base_url: LM Studio base URL to be use, defaults to None
(resolved by the provider from LMSTUDIO_BASE_URL, then "http://localhost:1234/v1")
:type lmstudio_base_url: Optional[str], optional
"""
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
+2 -1
View File
@@ -39,6 +39,7 @@ class LMStudioConfig(BaseLlmConfig):
vision_details: Vision detail level, defaults to "auto"
http_client_proxies: HTTP client proxy settings, defaults to None
lmstudio_base_url: LM Studio base URL, defaults to None
(resolved by the provider from LMSTUDIO_BASE_URL, then "http://localhost:1234/v1")
lmstudio_response_format: LM Studio response format, defaults to None
"""
# Initialize base parameters
@@ -55,5 +56,5 @@ class LMStudioConfig(BaseLlmConfig):
)
# LM Studio-specific parameters
self.lmstudio_base_url = lmstudio_base_url or "http://localhost:1234/v1"
self.lmstudio_base_url = lmstudio_base_url
self.lmstudio_response_format = lmstudio_response_format
-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
+4
View File
@@ -1,3 +1,4 @@
import os
from typing import Literal, Optional
from openai import OpenAI
@@ -13,6 +14,9 @@ class LMStudioEmbedding(EmbeddingBase):
self.config.model = self.config.model or "nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf"
self.config.embedding_dims = self.config.embedding_dims or 1536
self.config.api_key = self.config.api_key or "lm-studio"
self.config.lmstudio_base_url = (
self.config.lmstudio_base_url or os.getenv("LMSTUDIO_BASE_URL") or "http://localhost:1234/v1"
)
self.client = OpenAI(base_url=self.config.lmstudio_base_url, api_key=self.config.api_key)
+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):
"""
+4
View File
@@ -1,4 +1,5 @@
import json
import os
from typing import Dict, List, Optional, Union
from openai import OpenAI
@@ -37,6 +38,9 @@ class LMStudioLLM(LLMBase):
or "lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/Meta-Llama-3.1-70B-Instruct-IQ2_M.gguf"
)
self.config.api_key = self.config.api_key or "lm-studio"
self.config.lmstudio_base_url = (
self.config.lmstudio_base_url or os.getenv("LMSTUDIO_BASE_URL") or "http://localhost:1234/v1"
)
self.client = OpenAI(base_url=self.config.lmstudio_base_url, api_key=self.config.api_key)
+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),
+28 -1
View File
@@ -6,7 +6,6 @@ from mem0.configs.embeddings.base import BaseEmbedderConfig
from mem0.embeddings.lmstudio import LMStudioEmbedding
@pytest.fixture
def mock_lm_studio_client():
with patch("mem0.embeddings.lmstudio.OpenAI") as mock_openai:
@@ -80,3 +79,31 @@ def test_embed_batch_count_mismatch_raises(mock_lm_studio_client):
with pytest.raises(ValueError, match="returned 1 embeddings for 2 texts"):
embedder.embed_batch(["first text", "second text"])
def test_base_url_honors_lmstudio_base_url_env(mock_lm_studio_client, monkeypatch):
monkeypatch.setenv("LMSTUDIO_BASE_URL", "http://lmstudio.internal:9000/v1")
with patch("mem0.embeddings.lmstudio.OpenAI") as mock_openai:
LMStudioEmbedding(BaseEmbedderConfig())
assert mock_openai.call_args.kwargs["base_url"] == "http://lmstudio.internal:9000/v1"
def test_base_url_prefers_explicit_config_over_env(mock_lm_studio_client, monkeypatch):
monkeypatch.setenv("LMSTUDIO_BASE_URL", "http://lmstudio.internal:9000/v1")
with patch("mem0.embeddings.lmstudio.OpenAI") as mock_openai:
LMStudioEmbedding(BaseEmbedderConfig(lmstudio_base_url="http://explicit:1234/v1"))
assert mock_openai.call_args.kwargs["base_url"] == "http://explicit:1234/v1"
def test_base_url_falls_back_to_localhost_default(mock_lm_studio_client, monkeypatch):
monkeypatch.delenv("LMSTUDIO_BASE_URL", raising=False)
with patch("mem0.embeddings.lmstudio.OpenAI") as mock_openai:
embedder = LMStudioEmbedding(BaseEmbedderConfig())
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:1234/v1"
assert embedder.config.lmstudio_base_url == "http://localhost:1234/v1"
@@ -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
+38
View File
@@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
import pytest
from mem0.configs.llms.base import BaseLlmConfig
from mem0.configs.llms.lmstudio import LMStudioConfig
from mem0.llms.lmstudio import LMStudioLLM
@@ -70,3 +71,40 @@ def test_generate_response_specifying_response_format(mock_lm_studio_client):
)
assert response == "I'm doing well, thank you for asking!"
def test_base_url_honors_lmstudio_base_url_env(monkeypatch):
monkeypatch.setenv("LMSTUDIO_BASE_URL", "http://lmstudio.internal:9000/v1")
with patch("mem0.llms.lmstudio.OpenAI") as mock_openai:
LMStudioLLM(LMStudioConfig())
assert mock_openai.call_args.kwargs["base_url"] == "http://lmstudio.internal:9000/v1"
def test_base_url_prefers_explicit_config_over_env(monkeypatch):
monkeypatch.setenv("LMSTUDIO_BASE_URL", "http://lmstudio.internal:9000/v1")
with patch("mem0.llms.lmstudio.OpenAI") as mock_openai:
LMStudioLLM(LMStudioConfig(lmstudio_base_url="http://explicit:1234/v1"))
assert mock_openai.call_args.kwargs["base_url"] == "http://explicit:1234/v1"
def test_base_url_falls_back_to_localhost_default(monkeypatch):
monkeypatch.delenv("LMSTUDIO_BASE_URL", raising=False)
with patch("mem0.llms.lmstudio.OpenAI") as mock_openai:
llm = LMStudioLLM(LMStudioConfig())
assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:1234/v1"
assert llm.config.lmstudio_base_url == "http://localhost:1234/v1"
def test_base_url_honors_env_via_base_llm_config(monkeypatch):
monkeypatch.setenv("LMSTUDIO_BASE_URL", "http://lmstudio.internal:9000/v1")
with patch("mem0.llms.lmstudio.OpenAI") as mock_openai:
LMStudioLLM(BaseLlmConfig(model="some-model"))
assert mock_openai.call_args.kwargs["base_url"] == "http://lmstudio.internal:9000/v1"
-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)