diff --git a/.env.example b/.env.example index 61bb845..135a1eb 100644 --- a/.env.example +++ b/.env.example @@ -9,11 +9,24 @@ OLLAMA_CHAT_MODEL=llama3.1:8b INFER_API_KEY= # store runtime (canonical contract) -# Internal default for one-node compose is service name `infer`. +# Shared defaults — used as fallback when per-purpose vars are not set. OPENAI_BASE_URL=http://infer:8081/v1 OPENAI_API_KEY= + +# Per-purpose inference config. +# Each purpose (main LLM, embedder, graph LLM) can target a different endpoint. +# Falls back to shared OPENAI_BASE_URL / OPENAI_API_KEY when not set. +# This allows e.g. local inference for the main LLM with cloud for embeddings. + +# Main LLM (fact extraction + memory decisions) MEM0_LLM_MODEL=gpt-4.1-nano +# MEM0_LLM_BASE_URL= # falls back to OPENAI_BASE_URL +# MEM0_LLM_API_KEY= # falls back to OPENAI_API_KEY + +# Embedder — CANNOT change model after the collection is created MEM0_EMBED_MODEL=text-embedding-3-small +# MEM0_EMBED_BASE_URL= # falls back to OPENAI_BASE_URL +# MEM0_EMBED_API_KEY= # falls back to OPENAI_API_KEY # Qdrant config # Prefer QDRANT_URL when supported by the service runtime. @@ -38,3 +51,6 @@ NEO4J_URL=bolt://neo4j:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=changeme MEM0_GRAPH_LLM_MODEL=gpt-4.1-mini +# MEM0_GRAPH_LLM_BASE_URL= # falls back to OPENAI_BASE_URL +# MEM0_GRAPH_LLM_API_KEY= # falls back to OPENAI_API_KEY +# MEM0_GRAPH_EXTRACTION_STRATEGY=tool_calling # or "json_prompting" for models without tool support diff --git a/README.md b/README.md index 3aabc05..ccdfee5 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,22 @@ The `.env` file configures image tags, model names, and provider keys. | `QDRANT_API_KEY` | Qdrant auth key (optional) | store runtime | `` | | `QDRANT_COLLECTION` | Collection name for memory vectors | `store` service | `foxmemory` | +### Per-purpose inference (optional) + +Each inference purpose can target a different endpoint. All fall back to `OPENAI_BASE_URL` / `OPENAI_API_KEY` when not set — existing deployments are unaffected. + +| Variable | Purpose | Fallback | Example | +|---|---|---|---| +| `MEM0_LLM_BASE_URL` | Main LLM endpoint | `OPENAI_BASE_URL` | `http://exo:8000/v1` | +| `MEM0_LLM_API_KEY` | Main LLM API key | `OPENAI_API_KEY` | `not-needed` | +| `MEM0_EMBED_BASE_URL` | Embedder endpoint | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | +| `MEM0_EMBED_API_KEY` | Embedder API key | `OPENAI_API_KEY` | `sk-...` | +| `MEM0_GRAPH_LLM_BASE_URL` | Graph LLM endpoint | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | +| `MEM0_GRAPH_LLM_API_KEY` | Graph LLM API key | `OPENAI_API_KEY` | `sk-...` | +| `MEM0_GRAPH_EXTRACTION_STRATEGY` | Graph extraction method | `tool_calling` | `json_prompting` | + +`MEM0_GRAPH_EXTRACTION_STRATEGY` controls how entities and relationships are extracted from text. Use `tool_calling` (default) for models that support OpenAI-style function calling. Use `json_prompting` for models that don't (e.g. small local models served via EXO). + Backward-compatible variables still present in some scripts/builds: - `QDRANT_HOST` diff --git a/compose.external.yml b/compose.external.yml index 45b9268..91bd096 100644 --- a/compose.external.yml +++ b/compose.external.yml @@ -18,7 +18,11 @@ services: - OPENAI_BASE_URL=${OPENAI_BASE_URL} - OPENAI_API_KEY=${OPENAI_API_KEY} - MEM0_LLM_MODEL=${MEM0_LLM_MODEL} + - MEM0_LLM_BASE_URL=${MEM0_LLM_BASE_URL:-} + - MEM0_LLM_API_KEY=${MEM0_LLM_API_KEY:-} - MEM0_EMBED_MODEL=${MEM0_EMBED_MODEL} + - MEM0_EMBED_BASE_URL=${MEM0_EMBED_BASE_URL:-} + - MEM0_EMBED_API_KEY=${MEM0_EMBED_API_KEY:-} - QDRANT_HOST=qdrant - QDRANT_PORT=6333 - QDRANT_COLLECTION=${QDRANT_COLLECTION} diff --git a/compose.graph.yml b/compose.graph.yml index 264b76a..8a7d7a9 100644 --- a/compose.graph.yml +++ b/compose.graph.yml @@ -35,7 +35,11 @@ services: OPENAI_BASE_URL: ${OPENAI_BASE_URL} OPENAI_API_KEY: ${OPENAI_API_KEY} MEM0_LLM_MODEL: ${MEM0_LLM_MODEL:-gpt-4.1-nano} + MEM0_LLM_BASE_URL: ${MEM0_LLM_BASE_URL:-} + MEM0_LLM_API_KEY: ${MEM0_LLM_API_KEY:-} MEM0_EMBED_MODEL: ${MEM0_EMBED_MODEL:-text-embedding-3-small} + MEM0_EMBED_BASE_URL: ${MEM0_EMBED_BASE_URL:-} + MEM0_EMBED_API_KEY: ${MEM0_EMBED_API_KEY:-} QDRANT_HOST: qdrant QDRANT_PORT: "6333" QDRANT_COLLECTION: ${QDRANT_COLLECTION:-foxmemory} @@ -46,6 +50,9 @@ services: NEO4J_USERNAME: ${NEO4J_USERNAME:-neo4j} NEO4J_PASSWORD: ${NEO4J_PASSWORD:-} MEM0_GRAPH_LLM_MODEL: ${MEM0_GRAPH_LLM_MODEL:-gpt-4.1-mini} + MEM0_GRAPH_LLM_BASE_URL: ${MEM0_GRAPH_LLM_BASE_URL:-} + MEM0_GRAPH_LLM_API_KEY: ${MEM0_GRAPH_LLM_API_KEY:-} + MEM0_GRAPH_EXTRACTION_STRATEGY: ${MEM0_GRAPH_EXTRACTION_STRATEGY:-} volumes: - foxmemory_analytics_data:/data depends_on: diff --git a/compose.one.yml b/compose.one.yml index 1320bd7..20f65bf 100644 --- a/compose.one.yml +++ b/compose.one.yml @@ -33,7 +33,11 @@ services: - OPENAI_BASE_URL=${OPENAI_BASE_URL} - OPENAI_API_KEY=${INFER_API_KEY} - MEM0_LLM_MODEL=${MEM0_LLM_MODEL} + - MEM0_LLM_BASE_URL=${MEM0_LLM_BASE_URL:-} + - MEM0_LLM_API_KEY=${MEM0_LLM_API_KEY:-} - MEM0_EMBED_MODEL=${MEM0_EMBED_MODEL} + - MEM0_EMBED_BASE_URL=${MEM0_EMBED_BASE_URL:-} + - MEM0_EMBED_API_KEY=${MEM0_EMBED_API_KEY:-} - QDRANT_HOST=127.0.0.1 - QDRANT_PORT=6333 - QDRANT_COLLECTION=${QDRANT_COLLECTION} @@ -45,6 +49,9 @@ services: - NEO4J_USERNAME=${NEO4J_USERNAME:-neo4j} - NEO4J_PASSWORD=${NEO4J_PASSWORD:-} - MEM0_GRAPH_LLM_MODEL=${MEM0_GRAPH_LLM_MODEL:-gpt-4.1-mini} + - MEM0_GRAPH_LLM_BASE_URL=${MEM0_GRAPH_LLM_BASE_URL:-} + - MEM0_GRAPH_LLM_API_KEY=${MEM0_GRAPH_LLM_API_KEY:-} + - MEM0_GRAPH_EXTRACTION_STRATEGY=${MEM0_GRAPH_EXTRACTION_STRATEGY:-} volumes: - foxmemory_store_data:/qdrant/storage depends_on: diff --git a/compose.split.yml b/compose.split.yml index 79c7e0f..fb66a79 100644 --- a/compose.split.yml +++ b/compose.split.yml @@ -31,7 +31,11 @@ services: - OPENAI_BASE_URL=${OPENAI_BASE_URL} - OPENAI_API_KEY=${OPENAI_API_KEY} - MEM0_LLM_MODEL=${MEM0_LLM_MODEL} + - MEM0_LLM_BASE_URL=${MEM0_LLM_BASE_URL:-} + - MEM0_LLM_API_KEY=${MEM0_LLM_API_KEY:-} - MEM0_EMBED_MODEL=${MEM0_EMBED_MODEL} + - MEM0_EMBED_BASE_URL=${MEM0_EMBED_BASE_URL:-} + - MEM0_EMBED_API_KEY=${MEM0_EMBED_API_KEY:-} - QDRANT_HOST=127.0.0.1 - QDRANT_PORT=6333 - QDRANT_COLLECTION=${QDRANT_COLLECTION} @@ -43,6 +47,9 @@ services: - NEO4J_USERNAME=${NEO4J_USERNAME:-neo4j} - NEO4J_PASSWORD=${NEO4J_PASSWORD:-} - MEM0_GRAPH_LLM_MODEL=${MEM0_GRAPH_LLM_MODEL:-gpt-4.1-mini} + - MEM0_GRAPH_LLM_BASE_URL=${MEM0_GRAPH_LLM_BASE_URL:-} + - MEM0_GRAPH_LLM_API_KEY=${MEM0_GRAPH_LLM_API_KEY:-} + - MEM0_GRAPH_EXTRACTION_STRATEGY=${MEM0_GRAPH_EXTRACTION_STRATEGY:-} volumes: - foxmemory_store_data:/qdrant/storage depends_on: diff --git a/docs/NEO4J_AUTH_RUNBOOK.md b/docs/NEO4J_AUTH_RUNBOOK.md new file mode 100644 index 0000000..b690bfa --- /dev/null +++ b/docs/NEO4J_AUTH_RUNBOOK.md @@ -0,0 +1,34 @@ +# Neo4j Auth Runbook (Environment-Agnostic) + +## Goals +- Keep a single source of truth in `.env` +- Prevent auth drift between compose config and Neo4j persisted auth state +- Provide deterministic preflight and rotation helpers + +## Files +- `scripts/lib/common.sh` +- `scripts/neo4j-auth-preflight.sh` +- `scripts/neo4j-auth-rotate.sh` + +## Required env contract +In `.env`: +- `NEO4J_PASSWORD=` +- `NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}` + +## Preflight check +```bash +scripts/neo4j-auth-preflight.sh +``` +Expected output: `NEO4J_AUTH_OK` + +## Rotate password safely +```bash +scripts/neo4j-auth-rotate.sh --new-pass 'your-new-strong-password' +``` +Expected output: +- `NEO4J_AUTH_OK` +- `NEO4J_PASSWORD_ROTATED_OK` + +## Notes +- These scripts avoid hardcoded host paths by deriving compose/env paths from script location, with overrides via `PROJECT_DIR`, `COMPOSE_FILE`, and `ENV_FILE`. +- If your container names differ, set `NEO4J_CONTAINER` before running. diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..ab58e72 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR_DEFAULT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PROJECT_DIR="${PROJECT_DIR:-$PROJECT_DIR_DEFAULT}" +COMPOSE_FILE="${COMPOSE_FILE:-$PROJECT_DIR/compose.graph.yml}" +ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env}" +SERVICE_NEO4J="${SERVICE_NEO4J:-neo4j}" +SERVICE_STORE="${SERVICE_STORE:-store}" + +require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1" >&2; exit 1; }; } + +require_env() { + local k="$1" + if ! grep -q "^${k}=" "$ENV_FILE"; then + echo "missing required env var in $ENV_FILE: $k" >&2 + exit 1 + fi +} + +env_get() { + local k="$1" + grep -E "^${k}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- +} + +compose() { + docker compose -f "$COMPOSE_FILE" "$@" +} diff --git a/scripts/neo4j-auth-preflight.sh b/scripts/neo4j-auth-preflight.sh new file mode 100755 index 0000000..0a2f6a3 --- /dev/null +++ b/scripts/neo4j-auth-preflight.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib/common.sh" + +require_cmd docker +require_env NEO4J_PASSWORD +PASS="$(env_get NEO4J_PASSWORD)" +CONTAINER="${NEO4J_CONTAINER:-foxmemory-neo4j}" + +echo "[preflight] checking neo4j auth via cypher-shell..." +if docker exec "$CONTAINER" cypher-shell -u neo4j -p "$PASS" "RETURN 1 as ok;" >/tmp/neo4j-preflight.out 2>&1; then + echo "NEO4J_AUTH_OK" +else + echo "NEO4J_AUTH_FAIL" + tail -n 20 /tmp/neo4j-preflight.out || true + exit 1 +fi diff --git a/scripts/neo4j-auth-rotate.sh b/scripts/neo4j-auth-rotate.sh new file mode 100755 index 0000000..4046139 --- /dev/null +++ b/scripts/neo4j-auth-rotate.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/lib/common.sh" + +usage() { + cat <' [--no-restart] + +Rotates Neo4j password using compose/.env contract, then verifies login. +USAGE +} + +NEW_PASS="" +RESTART=1 +while [[ $# -gt 0 ]]; do + case "$1" in + --new-pass) NEW_PASS="$2"; shift 2;; + --no-restart) RESTART=0; shift;; + -h|--help) usage; exit 0;; + *) echo "unknown arg: $1"; usage; exit 1;; + esac +done + +[[ -n "$NEW_PASS" ]] || { echo "--new-pass is required"; usage; exit 1; } +require_cmd docker +require_env NEO4J_PASSWORD + +# Update .env source-of-truth +if grep -q '^NEO4J_PASSWORD=' "$ENV_FILE"; then + sed -i.bak "s#^NEO4J_PASSWORD=.*#NEO4J_PASSWORD=${NEW_PASS}#" "$ENV_FILE" +else + echo "NEO4J_PASSWORD=${NEW_PASS}" >> "$ENV_FILE" +fi +if grep -q '^NEO4J_AUTH=' "$ENV_FILE"; then + sed -i.bak "s#^NEO4J_AUTH=.*#NEO4J_AUTH=neo4j/${NEW_PASS}#" "$ENV_FILE" +else + echo "NEO4J_AUTH=neo4j/${NEW_PASS}" >> "$ENV_FILE" +fi + +echo "[rotate] updated $ENV_FILE" + +if [[ "$RESTART" -eq 1 ]]; then + compose stop "$SERVICE_STORE" || true + + # Temporarily disable auth, set password, restore auth contract + python3 - </tmp/neo4j-rotate.out 2>&1 || { + echo "[rotate] failed to set password"; tail -n 30 /tmp/neo4j-rotate.out; exit 1; + } + + python3 - <