From d3fc24b70d3495bc9cd9946c239c960270ceca6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:51:45 +0000 Subject: [PATCH 1/3] feat: support local Ollama for embeddings and fact-extraction LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in config for running mem0's embedder and/or LLM against a local Ollama server — zero per-call cost and no content leaving the host — while keeping anthropic+openai as the default. - config.py: new OLLAMA_BASE_URL setting (default http://localhost:11434). The existing provider-key validator already skips key checks for non anthropic/openai providers, so ollama needs no API key. - memory.py: _provider_config() is now provider-aware — cloud providers get the API key injected; an "ollama" provider gets mem0's ollama_base_url (and embedding_dims for the embedder) and no key. Wired through _build_config for both llm and embedder, so fully-local and mixed cloud/local setups work. - .env.example / docker-compose.yml: documented vars plus a commented-out optional bundled `ollama` service for the Compose stack. - USER_GUIDE: "Running fully local with Ollama" section + config-table rows, prominently restating the MEM0_EMBED_DIMS-must-match invariant (recreate the Qdrant collection when switching embed models). DEVELOPER_GUIDE: updated the _provider_config internals note. - Tests: ollama config building (fully-local + mixed), OLLAMA_BASE_URL default/override, and Ollama config validated against the real mem0 MemoryConfig schema so a key rename is caught in CI. Closes #51. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XQXUARh5h5hRFg67toenEX --- .env.example | 12 ++++++++ app/config.py | 5 ++++ app/memory.py | 43 ++++++++++++++++++++++----- docker-compose.yml | 23 +++++++++++++++ docs/DEVELOPER_GUIDE.md | 11 +++++-- docs/USER_GUIDE.md | 65 +++++++++++++++++++++++++++++++++++++---- tests/test_config.py | 8 +++++ tests/test_memory.py | 59 +++++++++++++++++++++++++++++++++++++ 8 files changed, 211 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index d0fd76f..f49580a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/config.py b/app/config.py index 2de8cd1..d9055e2 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/memory.py b/app/memory.py index e841f0f..1a95302 100644 --- a/app/memory.py +++ b/app/memory.py @@ -13,11 +13,29 @@ _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 @@ -39,11 +57,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=s.anthropic_api_key, + 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=s.openai_api_key, + base_url=s.ollama_base_url, + embedding_dims=s.mem0_embed_dims, + ), }, "version": "v1.1", } diff --git a/docker-compose.yml b/docker-compose.yml index 72cb119..037ed9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index b555947..289719b 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -264,9 +264,14 @@ provider is Anthropic, and `OPENAI_API_KEY` when the embed provider is OpenAI 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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f415bbd..37dfba5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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) @@ -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. | +| `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 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. | +| `MEM0_EMBED_PROVIDER` | no | `openai` | Embedding provider. `openai` or `ollama` (local). | +| `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 provider=openai | — | Required when the embed provider is OpenAI. | +| `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. | @@ -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: diff --git a/tests/test_config.py b/tests/test_config.py index aac506c..1fa687e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -55,3 +55,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" + ) diff --git a/tests/test_memory.py b/tests/test_memory.py index 36f51c8..6717d57 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -38,12 +38,71 @@ def test_build_config_shape(): assert cfg["version"] == "v1.1" +def test_build_config_ollama_provider(): + # Opt-in local Ollama for both LLM and embedder: no API keys, base URL wired + # under mem0's `ollama_base_url` key, and the embedder carries the vector dim. + cfg = _build_config( + Settings( + mem0_llm_provider="ollama", + mem0_llm_model="llama3.1:8b", + mem0_embed_provider="ollama", + mem0_embed_model="nomic-embed-text", + mem0_embed_dims=768, + ollama_base_url="http://ollama:11434", + anthropic_api_key=None, + openai_api_key=None, + ) + ) + + llm = cfg["llm"] + assert llm["provider"] == "ollama" + assert llm["config"]["model"] == "llama3.1:8b" + assert llm["config"]["ollama_base_url"] == "http://ollama:11434" + assert "api_key" not in llm["config"] + + emb = cfg["embedder"] + assert emb["provider"] == "ollama" + assert emb["config"]["model"] == "nomic-embed-text" + assert emb["config"]["ollama_base_url"] == "http://ollama:11434" + assert emb["config"]["embedding_dims"] == 768 + assert "api_key" not in emb["config"] + # The Qdrant collection dimension must track the embed model's real output. + assert cfg["vector_store"]["config"]["embedding_model_dims"] == 768 + + +def test_build_config_mixed_ollama_llm_openai_embed(): + # A mixed setup (local LLM, cloud embedder) keeps each provider's own keys. + cfg = _build_config( + Settings(mem0_llm_provider="ollama", mem0_llm_model="llama3.1:8b") + ) + assert cfg["llm"]["config"]["ollama_base_url"] == "http://localhost:11434" + assert "api_key" not in cfg["llm"]["config"] + assert cfg["embedder"]["provider"] == "openai" + assert cfg["embedder"]["config"]["api_key"] == "test-openai" + assert "ollama_base_url" not in cfg["embedder"]["config"] + + def test_build_config_accepted_by_mem0_schema(): # Non-mocked check: validate the config against the real (pinned) mem0 # MemoryConfig so method/config drift is caught in CI rather than at runtime. from mem0.configs.base import MemoryConfig MemoryConfig(**_build_config(Settings())) + # Also validate the opt-in Ollama shape, so a mem0 rename of the Ollama + # config keys (e.g. ollama_base_url) is caught in CI, not at deploy time. + MemoryConfig( + **_build_config( + Settings( + mem0_llm_provider="ollama", + mem0_llm_model="llama3.1:8b", + mem0_embed_provider="ollama", + mem0_embed_model="nomic-embed-text", + mem0_embed_dims=768, + anthropic_api_key=None, + openai_api_key=None, + ) + ) + ) # --- content fingerprint ----------------------------------------------------- From 6ee5cad7f23bfd1144b7c6d2bf0e96b5c4af1341 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 18:02:38 +0000 Subject: [PATCH 2/3] fix: route provider API keys by provider, not by role Addresses PR review: _build_config previously hardcoded the Anthropic key into the LLM config and the OpenAI key into the embedder config regardless of the configured provider. With openai now documented as a valid MEM0_LLM_PROVIDER, an OpenAI LLM would have received the Anthropic key (and no OpenAI key), silently failing at first request. - memory.py: add _api_key_for(provider) so each provider gets its own key (anthropic->ANTHROPIC_API_KEY, openai->OPENAI_API_KEY, ollama/other->none). - config.py: _require_provider_keys now requires a provider's key when *either* role selects it, so openai-LLM + ollama-embed no longer passes validation without OPENAI_API_KEY. - Tests: openai-LLM key routing, and validation raises for a missing key when openai/anthropic is the LLM (or embed) provider. - Docs: config-table key requirements and the DEVELOPER_GUIDE validator note updated to reflect either-role enforcement. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XQXUARh5h5hRFg67toenEX --- app/config.py | 22 ++++++++++++++-------- app/memory.py | 20 ++++++++++++++++++-- docs/DEVELOPER_GUIDE.md | 6 +++--- docs/USER_GUIDE.md | 4 ++-- tests/test_config.py | 23 +++++++++++++++++++++++ tests/test_memory.py | 10 ++++++++++ 6 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/config.py b/app/config.py index d9055e2..5a80e67 100644 --- a/app/config.py +++ b/app/config.py @@ -67,17 +67,23 @@ 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. mem0 reads keys only from the config we build, not + # os.environ, so a missing key would fail silently 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 diff --git a/app/memory.py b/app/memory.py index 1a95302..057cfbf 100644 --- a/app/memory.py +++ b/app/memory.py @@ -40,6 +40,22 @@ def _provider_config( 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. @@ -60,7 +76,7 @@ def _build_config(s: Settings) -> dict: "config": _provider_config( s.mem0_llm_provider, s.mem0_llm_model, - api_key=s.anthropic_api_key, + api_key=_api_key_for(s.mem0_llm_provider, s), base_url=s.ollama_base_url, ), }, @@ -69,7 +85,7 @@ def _build_config(s: Settings) -> dict: "config": _provider_config( s.mem0_embed_provider, s.mem0_embed_model, - api_key=s.openai_api_key, + api_key=_api_key_for(s.mem0_embed_provider, s), base_url=s.ollama_base_url, embedding_dims=s.mem0_embed_dims, ), diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 289719b..9a3c7cb 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -259,9 +259,9 @@ 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()` builds each provider's mem0 `config` block keyed to the diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 37dfba5..e3eb83f 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -156,11 +156,11 @@ runs, or set these in the CapRover app's **App Configs** panel for production. | `MEM0_DEFAULT_USER_ID` | yes | — | The single user, e.g. `default-user`. | | `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 provider=anthropic | — | Required when the LLM provider is Anthropic. | +| `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). | | `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 provider=openai | — | Required when the embed provider is OpenAI. | +| `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. | diff --git a/tests/test_config.py b/tests/test_config.py index 1fa687e..793ebf9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -43,6 +43,29 @@ 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 — mem0 won't read it from os.environ. + 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_missing_anthropic_key_rejected_for_embed_provider(): + # Symmetric: Anthropic as the embedder requires ANTHROPIC_API_KEY. + with pytest.raises(ValidationError, match="ANTHROPIC_API_KEY"): + Settings( + mem0_llm_provider="ollama", + mem0_embed_provider="anthropic", + anthropic_api_key=None, + openai_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 diff --git a/tests/test_memory.py b/tests/test_memory.py index 6717d57..5fae407 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -70,6 +70,16 @@ def test_build_config_ollama_provider(): assert cfg["vector_store"]["config"]["embedding_model_dims"] == 768 +def test_build_config_openai_llm_uses_openai_key(): + # An OpenAI LLM must receive OPENAI_API_KEY, never the Anthropic key — the + # key is chosen by provider, not by role (LLM vs embedder). + cfg = _build_config( + Settings(mem0_llm_provider="openai", mem0_llm_model="gpt-4o-mini") + ) + assert cfg["llm"]["provider"] == "openai" + assert cfg["llm"]["config"]["api_key"] == "test-openai" + + def test_build_config_mixed_ollama_llm_openai_embed(): # A mixed setup (local LLM, cloud embedder) keeps each provider's own keys. cfg = _build_config( From 17f360b3fc938691cf2d703fb38185348df5d571 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 18:37:05 +0000 Subject: [PATCH 3/3] docs: correct validator comment and drop contrived embed test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review follow-ups: - config.py: reword the _require_provider_keys comment — mem0's clients DO read keys from os.environ; the reason we inject is that a key loaded from .env via pydantic-settings never lands in os.environ. The old wording said the opposite and contradicted the note in app/memory.py. - tests: remove test_missing_anthropic_key_rejected_for_embed_provider. It implied Anthropic is a valid embed provider, but Anthropic has no embeddings API and the docs list only openai/ollama for embedding — the real key-backed cases (anthropic LLM, openai LLM or embed) stay covered. Also drop the same misleading os.environ phrasing from the openai-LLM test comment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XQXUARh5h5hRFg67toenEX --- app/config.py | 6 ++++-- tests/test_config.py | 13 +------------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/app/config.py b/app/config.py index 5a80e67..5dc8f43 100644 --- a/app/config.py +++ b/app/config.py @@ -69,8 +69,10 @@ def _missing(key: str | None) -> bool: # 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. mem0 reads keys only from the config we build, not - # os.environ, so a missing key would fail silently at first request. + # 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(), diff --git a/tests/test_config.py b/tests/test_config.py index 793ebf9..d92e9e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -45,7 +45,7 @@ def test_provider_match_is_case_insensitive(): 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 — mem0 won't read it from os.environ. + # embedder is a keyless local provider like Ollama. with pytest.raises(ValidationError, match="OPENAI_API_KEY"): Settings( mem0_llm_provider="openai", @@ -55,17 +55,6 @@ def test_missing_openai_key_rejected_for_llm_provider(): ) -def test_missing_anthropic_key_rejected_for_embed_provider(): - # Symmetric: Anthropic as the embedder requires ANTHROPIC_API_KEY. - with pytest.raises(ValidationError, match="ANTHROPIC_API_KEY"): - Settings( - mem0_llm_provider="ollama", - mem0_embed_provider="anthropic", - anthropic_api_key=None, - openai_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