Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ MEM0_EMBED_MODEL=text-embedding-3-small
MEM0_EMBED_DIMS=1536
OPENAI_API_KEY=replace-me

# Local Ollama (opt-in, cost-free + private). Point either/both of the LLM and
# embedder at a local Ollama server instead of Anthropic/OpenAI. Uncomment and
# adjust; no API key is needed. MEM0_EMBED_DIMS MUST match the embed model's
# real output dimension (nomic-embed-text=768, mxbai-embed-large=1024), and
# changing the embed model requires recreating the Qdrant collection.
# OLLAMA_BASE_URL=http://localhost:11434
# MEM0_LLM_PROVIDER=ollama
# MEM0_LLM_MODEL=llama3.1:8b
# MEM0_EMBED_PROVIDER=ollama
# MEM0_EMBED_MODEL=nomic-embed-text
# MEM0_EMBED_DIMS=768

# Auth
MEM0_API_KEY=replace-with-openssl-rand-hex-32
PUBLIC_BASE_URL=https://mem0.your-domain.com
Expand Down
29 changes: 21 additions & 8 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ class Settings(BaseSettings):
mem0_embed_dims: int = 1536
openai_api_key: str | None = None

# Ollama (local, opt-in). Used when mem0_llm_provider and/or
# mem0_embed_provider is "ollama"; both talk to the same Ollama server, so a
# single base URL covers LLM and embedder. No API key: it's a local daemon.
ollama_base_url: str = "http://localhost:11434"

# Auth
mem0_api_key: str
public_base_url: str
Expand Down Expand Up @@ -62,17 +67,25 @@ def _require_provider_keys(self) -> "Settings":
def _missing(key: str | None) -> bool:
return not (key and key.strip())

if self.mem0_llm_provider.strip().lower() == "anthropic" and _missing(
self.anthropic_api_key
):
# A provider's key is required whenever *either* role (LLM or embedder)
# selects it — e.g. an OpenAI LLM needs OPENAI_API_KEY even if the
# embedder is Ollama. We inject keys into the mem0 config explicitly (see
# app/memory.py) because a key loaded from .env via pydantic-settings
# does not land in os.environ, where mem0's clients would otherwise read
# it; validating here fails fast at startup instead of at first request.
providers = {
self.mem0_llm_provider.strip().lower(),
self.mem0_embed_provider.strip().lower(),
}
if "anthropic" in providers and _missing(self.anthropic_api_key):
raise ValueError(
"ANTHROPIC_API_KEY is required when MEM0_LLM_PROVIDER=anthropic"
"ANTHROPIC_API_KEY is required when MEM0_LLM_PROVIDER or "
"MEM0_EMBED_PROVIDER is anthropic"
)
if self.mem0_embed_provider.strip().lower() == "openai" and _missing(
self.openai_api_key
):
if "openai" in providers and _missing(self.openai_api_key):
raise ValueError(
"OPENAI_API_KEY is required when MEM0_EMBED_PROVIDER=openai"
"OPENAI_API_KEY is required when MEM0_LLM_PROVIDER or "
"MEM0_EMBED_PROVIDER is openai"
)
return self

Expand Down
59 changes: 52 additions & 7 deletions app/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,49 @@
_log = structlog.get_logger()


def _provider_config(model: str, api_key: str | None) -> dict:
config = {"model": model}
# mem0's provider clients otherwise read the key from os.environ, which is
# not populated when keys come only from a .env file via pydantic-settings.
if api_key:
def _provider_config(
provider: str,
model: str,
*,
api_key: str | None = None,
base_url: str | None = None,
embedding_dims: int | None = None,
) -> dict:
"""Build a mem0 provider `config` block, keyed to the provider.

Cloud providers (anthropic/openai/…) get the API key injected explicitly:
mem0's clients otherwise read it from os.environ, which is not populated when
keys come only from a .env file via pydantic-settings. A local Ollama server
takes no key — it needs its base URL under mem0's `ollama_base_url` key
instead, plus the vector dimension for the embedder.
"""
config: dict = {"model": model}
if provider.strip().lower() == "ollama":
if base_url:
config["ollama_base_url"] = base_url
if embedding_dims is not None:
config["embedding_dims"] = embedding_dims
elif api_key:
config["api_key"] = api_key
return config


def _api_key_for(provider: str, s: Settings) -> str | None:
"""The API key that belongs to `provider`, or None if it needs none.

Keys are provider-specific: an OpenAI LLM must get OPENAI_API_KEY, not the
Anthropic key, and vice versa — so the key is selected by provider, never by
role (LLM vs embedder). Ollama and any other keyless provider get None.
"""
match provider.strip().lower():
case "anthropic":
return s.anthropic_api_key
case "openai":
return s.openai_api_key
case _:
return None


def _build_config(s: Settings) -> dict:
# mem0's Qdrant store has no `https` flag; it only honors scheme via `url`.
# Build a scheme-aware URL so an HTTPS Qdrant on 443 isn't hit over plain HTTP.
Expand All @@ -39,11 +73,22 @@ def _build_config(s: Settings) -> dict:
},
"llm": {
"provider": s.mem0_llm_provider,
"config": _provider_config(s.mem0_llm_model, s.anthropic_api_key),
"config": _provider_config(
s.mem0_llm_provider,
s.mem0_llm_model,
api_key=_api_key_for(s.mem0_llm_provider, s),
base_url=s.ollama_base_url,
),
},
"embedder": {
"provider": s.mem0_embed_provider,
"config": _provider_config(s.mem0_embed_model, s.openai_api_key),
"config": _provider_config(
s.mem0_embed_provider,
s.mem0_embed_model,
api_key=_api_key_for(s.mem0_embed_provider, s),
base_url=s.ollama_base_url,
embedding_dims=s.mem0_embed_dims,
),
},
"version": "v1.1",
}
Expand Down
23 changes: 23 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ services:
# Persists the Phase 2 OAuth SQLite DB across restarts.
- mem0_data:/app/data

# Optional: a local Ollama server for cost-free, private LLM + embeddings.
# Uncomment this service AND the `ollama_data` volume below, then in .env set
# OLLAMA_BASE_URL=http://ollama:11434 (the service name, not localhost) plus
# the provider vars (see .env.example → "Local Ollama"). After `up -d`, pull
# the models once, e.g.:
# docker compose exec ollama ollama pull llama3.1:8b
# docker compose exec ollama ollama pull nomic-embed-text
# ollama:
# image: ollama/ollama:latest
# restart: unless-stopped
# volumes:
# - ollama_data:/root/.ollama
# # For NVIDIA GPU acceleration, install the NVIDIA Container Toolkit and
# # uncomment; otherwise Ollama runs on CPU.
# # deploy:
# # resources:
# # reservations:
# # devices:
# # - driver: nvidia
# # count: all
# # capabilities: [gpu]

volumes:
qdrant_data:
mem0_data:
# ollama_data:
17 changes: 11 additions & 6 deletions docs/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,19 @@ PKCE S256 mandatory and clients public (no secrets). Update `tests/test_oauth.py
## Configuration internals

`Settings` (pydantic-settings) reads from environment and `.env`, with `extra="ignore"`. The
`_require_provider_keys` model validator enforces that `ANTHROPIC_API_KEY` is present when the LLM
provider is Anthropic, and `OPENAI_API_KEY` when the embed provider is OpenAI — failing fast at
startup rather than at first request. `get_settings()` is `@lru_cache`d, so settings are read once
`_require_provider_keys` model validator enforces that a provider's key is present whenever *either*
role selects it — `ANTHROPIC_API_KEY` if Anthropic is the LLM or embed provider, `OPENAI_API_KEY` if
OpenAI is either — failing fast at startup rather than at first request. `get_settings()` is `@lru_cache`d, so settings are read once
per process. `oauth_enabled` and `allowed_redirect_uris_list` are derived properties.

`app/memory.py`'s `_provider_config()` injects the API key into the mem0 provider config explicitly,
because mem0's provider clients otherwise read keys from `os.environ`, which is not populated when
keys come only from a `.env` file via pydantic-settings.
`app/memory.py`'s `_provider_config()` builds each provider's mem0 `config` block keyed to the
provider. For cloud providers it injects the API key explicitly, because mem0's provider clients
otherwise read keys from `os.environ`, which is not populated when keys come only from a `.env` file
via pydantic-settings. For a local `ollama` provider there is no key; it instead emits mem0's
`ollama_base_url` (from `OLLAMA_BASE_URL`) and, for the embedder, `embedding_dims`. Because the
`_require_provider_keys` validator only enforces a provider's key when that provider is selected,
`ollama` (and any future keyless provider) needs no key — so a fully-local or mixed cloud/local
setup validates cleanly.

## Observability internals

Expand Down
69 changes: 62 additions & 7 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ connect clients to it, and use it day to day. If you want to work on the code it
- [How memory works](#how-memory-works)
- [Prerequisites](#prerequisites)
- [Configuration reference](#configuration-reference)
- [Running fully local with Ollama](#running-fully-local-with-ollama)
- [Choosing a deployment method](#choosing-a-deployment-method)
- [Deploying with Docker Compose](#deploying-with-docker-compose)
- [Putting it behind a reverse proxy (HTTPS)](#putting-it-behind-a-reverse-proxy-https)
Expand Down Expand Up @@ -153,13 +154,14 @@ runs, or set these in the CapRover app's **App Configs** panel for production.
| `QDRANT_API_KEY` | yes | — | Qdrant API key. |
| `MEM0_COLLECTION` | no | `memories` | Qdrant collection name. |
| `MEM0_DEFAULT_USER_ID` | yes | — | The single user, e.g. `default-user`. |
| `MEM0_LLM_PROVIDER` | no | `anthropic` | LLM provider for fact extraction. |
| `MEM0_LLM_MODEL` | no | `claude-haiku-4-5-20251001` | LLM model. |
| `ANTHROPIC_API_KEY` | if provider=anthropic | — | Required when the LLM provider is Anthropic. |
| `MEM0_EMBED_PROVIDER` | no | `openai` | Embedding provider. |
| `MEM0_EMBED_MODEL` | no | `text-embedding-3-small` | Embedding model. |
| `MEM0_EMBED_DIMS` | no | `1536` | **Must** match the embedder's real dimension. |
| `OPENAI_API_KEY` | if provider=openai | — | Required when the embed provider is OpenAI. |
| `MEM0_LLM_PROVIDER` | no | `anthropic` | LLM provider for fact extraction. `anthropic`, `openai`, or `ollama` (local — see [Running fully local with Ollama](#running-fully-local-with-ollama)). |
| `MEM0_LLM_MODEL` | no | `claude-haiku-4-5-20251001` | LLM model. For Ollama, e.g. `llama3.1:8b`. |
| `ANTHROPIC_API_KEY` | if using Anthropic | — | Required when Anthropic is the LLM (or embed) provider. |
| `MEM0_EMBED_PROVIDER` | no | `openai` | Embedding provider. `openai` or `ollama` (local). |
Comment thread
imonroe marked this conversation as resolved.
| `MEM0_EMBED_MODEL` | no | `text-embedding-3-small` | Embedding model. For Ollama, e.g. `nomic-embed-text`. |
| `MEM0_EMBED_DIMS` | no | `1536` | **Must** match the embedder's real dimension (Ollama: `nomic-embed-text`=768, `mxbai-embed-large`=1024). |
| `OPENAI_API_KEY` | if using OpenAI | — | Required when OpenAI is the embed or LLM provider. |
| `OLLAMA_BASE_URL` | no | `http://localhost:11434` | Ollama server URL; used when either provider is `ollama`. No API key needed. |
| `MEM0_API_KEY` | yes | — | Static bearer token protecting REST + MCP. Generate with `openssl rand -hex 32`. |
| `PUBLIC_BASE_URL` | yes | — | Public URL, e.g. `https://mem0.your-domain.com`. Used in OAuth metadata. |
| `OAUTH_SIGNING_KEY` | no | empty | PEM RSA private key. **Setting this enables Phase 2 OAuth.** Leave blank for Phase 1. |
Expand Down Expand Up @@ -201,6 +203,59 @@ openssl genrsa 2048
When pasting a multi-line PEM into a single env var, replace newlines with `\n` — the app converts
`\n` back to real newlines at load time.

### Running fully local with Ollama

By default memserv extracts facts with Anthropic and embeds with OpenAI — both are cloud APIs that
cost money per call and see your memory content. You can instead point the **LLM**, the
**embedder**, or **both** at a local [Ollama](https://ollama.com/) server for **zero per-call cost**
and **no content leaving the host** — a natural fit for a self-hosted single-user store. It's
**opt-in**; the default stays `anthropic` + `openai`.

Set the provider(s) to `ollama` and give the models and server URL:

```bash
# Fully local — nothing leaves the host, no API keys needed.
OLLAMA_BASE_URL=http://localhost:11434
MEM0_LLM_PROVIDER=ollama
MEM0_LLM_MODEL=llama3.1:8b
MEM0_EMBED_PROVIDER=ollama
MEM0_EMBED_MODEL=nomic-embed-text
MEM0_EMBED_DIMS=768 # nomic-embed-text is 768-dim — see the warning below
```

Pull the models on the Ollama host first (`ollama pull llama3.1:8b`,
`ollama pull nomic-embed-text`). When a provider is `ollama` its API key (`ANTHROPIC_API_KEY` /
`OPENAI_API_KEY`) is **not required** — memserv only enforces a provider's key when that provider is
actually selected. You can also mix and match: a local Ollama LLM with cloud OpenAI embeddings (keep
`OPENAI_API_KEY`), or cloud Anthropic extraction with local embeddings, whatever balances cost,
privacy, and quality for you.

> **Critical — `MEM0_EMBED_DIMS` must match the Ollama embed model's real output dimension.**
> `nomic-embed-text` is **768**, `mxbai-embed-large` is **1024** (the OpenAI defaults are 1536/3072).
> A mismatch causes **silent** empty search results, not an error. The dimension is baked into the
> Qdrant collection at creation, so **switching embed models (or moving between OpenAI and Ollama)
> means dropping and recreating the collection** — you can't change it in place. Start on Ollama from
> an empty store, or re-import after recreating the collection. This is the same
> [invariant](#prerequisites) that applies to any embed-model change.

**Performance note.** Local models run on your hardware. Fact extraction (`add`) makes an LLM call,
so on a CPU-only box a busy automation pipeline may feel slow; a GPU helps a lot. Embedding models
are lighter. If cost/privacy matter more than latency, local is a good trade; if you need snappy
high-volume writes and don't mind the bill, the cloud defaults are faster.

**Bundled Ollama with Docker Compose.** The `docker-compose.yml` ships a commented-out `ollama`
service. Uncomment it (and the `ollama_data` volume), set `OLLAMA_BASE_URL=http://ollama:11434` (the
Compose **service name**, not `localhost`) and the provider vars in `.env`, then `docker compose up
-d` and pull the models into the container:

```bash
docker compose exec ollama ollama pull llama3.1:8b
docker compose exec ollama ollama pull nomic-embed-text
```

See [Deploying with Docker Compose](#deploying-with-docker-compose). On CapRover, run Ollama as its
own app (or on a reachable host) and set `OLLAMA_BASE_URL` to its URL.

## Choosing a deployment method

There are two supported ways to run mem0-server:
Expand Down
20 changes: 20 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ def test_provider_match_is_case_insensitive():
Settings(mem0_llm_provider="Anthropic", anthropic_api_key=None)


def test_missing_openai_key_rejected_for_llm_provider():
# OPENAI_API_KEY is required when OpenAI is the LLM provider too, even if the
# embedder is a keyless local provider like Ollama.
with pytest.raises(ValidationError, match="OPENAI_API_KEY"):
Settings(
mem0_llm_provider="openai",
mem0_embed_provider="ollama",
openai_api_key=None,
anthropic_api_key=None,
)


def test_non_default_provider_skips_key_check():
# Providers other than the key-backed ones should not require those keys.
# Set both non-default and clear both keys so the test doesn't depend on
Expand All @@ -55,3 +67,11 @@ def test_non_default_provider_skips_key_check():
)
assert s.mem0_llm_provider == "ollama"
assert s.mem0_embed_provider == "ollama"


def test_ollama_base_url_default_and_override():
assert Settings().ollama_base_url == "http://localhost:11434"
assert (
Settings(ollama_base_url="http://ollama:11434").ollama_base_url
== "http://ollama:11434"
)
Comment thread
imonroe marked this conversation as resolved.
Loading
Loading