Skip to content
Open
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
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ sync:
memory:
recall_model: claude-sonnet-4-6
# embed_model: text-embedding-3-small # Only needed with openai_api_key
# embed_base_url: https://llm.internal/v1 # OpenAI-compatible embeddings endpoint

# Cron — no path keys needed. Cron config lives in <workspace>/config/cron/
# (system.yaml, jobs.yaml, gates/) and is resolved from the workspace, with a
Expand Down
3 changes: 2 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,8 @@ Sources pull data from external services on a schedule. See [sources.md](sources
| `memory.recall_model` | string | `claude-sonnet-4-6` | Model for recall routing |
| `memory.memorize_model` | string | `claude-sonnet-4-6` | Model for extraction & preprocessing |
| `memory.fast_model` | string | `claude-haiku-4-5-20251001` | Model for categorization, date resolution, knowledge filtering |
| `memory.embed_model` | string | *(empty)* | Embedding model (only used when `openai_api_key` is set, e.g. `text-embedding-3-small`) |
| `memory.embed_model` | string | *(empty)* | Embedding model (e.g. `text-embedding-3-small`). Required when embeddings are enabled |
| `memory.embed_base_url` | string | *(empty)* | OpenAI-compatible `/v1` base for embeddings; empty uses `https://api.openai.com/v1`. Set for a self-hosted or proxied endpoint, which also makes `openai_api_key` optional. Set at `nerve init` time with `NERVE_EMBEDDINGS_API_ENDPOINT` |
| `memory.semantic_dedup_threshold` | float | `0.85` | Cosine similarity threshold for semantic deduplication (0 to disable) |
| `memory.knowledge_filter` | bool | `false` | Post-extraction LLM filter that deletes generic knowledge items (extra Haiku API call per memorize) |
| `memory.categories` | list | `[]` | Seed categories — each entry has `name` and `description` fields. Used for semantic routing when memorizing and recalling facts. `nerve init` populates mode-appropriate defaults (personal: relationships, finances, health, etc.; worker: patterns, procedures, approvals, etc.). |
Expand Down
11 changes: 10 additions & 1 deletion docs/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,19 @@ memory:
recall_model: claude-sonnet-4-6 # recall routing
memorize_model: claude-sonnet-4-6 # writing memories back
fast_model: claude-haiku-4-5-20251001 # extraction & categorization
# embed_model: text-embedding-3-small # only needed with openai_api_key
# embed_model: text-embedding-3-small # required when embeddings are enabled
# embed_base_url: https://llm.internal/v1 # OpenAI-compatible endpoint; empty = OpenAI
categories: [...] # see Categories section above
```

Embeddings can come from any endpoint that implements OpenAI
`/v1/embeddings`. Set `memory.embed_base_url`, or use
`NERVE_EMBEDDINGS_API_ENDPOINT` and `NERVE_EMBEDDINGS_MODEL` with
`nerve init --non-interactive`. A base URL alone is enough — the endpoint
authenticates however it likes, so `openai_api_key` is then optional and
memory text stays on your own infrastructure. On **Bedrock** installs this is
the only way to get vector recall.

> On a **Bedrock** install the three model keys are machine-local instead:
> `nerve init` writes region-scoped inference-profile IDs into `config.yaml`,
> which shadows `settings.yaml`. The prefix must match the configured region
Expand Down
44 changes: 33 additions & 11 deletions nerve/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ class SetupChoices:
mode: str = "personal"
anthropic_api_key: str = ""
openai_api_key: str = ""
embeddings_api_endpoint: str = "" # /v1 base; empty = api.openai.com
embeddings_model: str = "" # Empty = text-embedding-3-small
use_proxy: bool = False # Use CLIProxyAPI instead of direct API key
# Provider
provider_type: str = "anthropic" # "anthropic" | "bedrock"
Expand Down Expand Up @@ -404,18 +406,24 @@ def _telegram_get_me(token: str, timeout: float = 7.0) -> tuple[bool, str]:
return (False, str(e))


def _check_openai_key(key: str, timeout: float = 7.0) -> tuple[bool, str]:
"""Validate an OpenAI API key with a models list call."""
def _check_openai_key(
key: str, timeout: float = 7.0, base_url: str = "",
) -> tuple[bool, str]:
"""Validate an embeddings endpoint with a models list call.

``base_url`` is a ``/v1`` base; empty means OpenAI.
"""
import httpx

root = (base_url or "https://api.openai.com/v1").rstrip("/")
try:
resp = httpx.get(
"https://api.openai.com/v1/models",
f"{root}/models",
headers={"Authorization": f"Bearer {key}"},
timeout=timeout,
)
if resp.status_code == 200:
return (True, "key valid")
return (True, "reachable")
detail = f"HTTP {resp.status_code}"
try:
detail = resp.json().get("error", {}).get("message", detail)
Expand Down Expand Up @@ -1550,7 +1558,9 @@ def _step_review(self) -> None:
api_status = "API key ✓"
else:
api_status = "—"
if self.choices.openai_api_key:
if self.choices.embeddings_api_endpoint:
api_status += " Embeddings ✓ (self-hosted)"
elif self.choices.openai_api_key:
api_status += " OpenAI ✓"
else:
api_status += " OpenAI —"
Expand Down Expand Up @@ -2057,7 +2067,11 @@ def _build_config_layers(
"recall_model": "claude-sonnet-4-6",
"memorize_model": "claude-sonnet-4-6",
"fast_model": "claude-haiku-4-5-20251001",
"embed_model": "text-embedding-3-small",
"embed_model": self.choices.embeddings_model or "text-embedding-3-small",
**(
{"embed_base_url": self.choices.embeddings_api_endpoint}
if self.choices.embeddings_api_endpoint else {}
),
"categories": (
_PERSONAL_MEMORY_CATEGORIES if self.choices.mode == "personal"
else _WORKER_MEMORY_CATEGORIES
Expand Down Expand Up @@ -2458,15 +2472,19 @@ def _preflight(self) -> None:
click.secho(f" ✗ {detail}", fg="red")
failures.append("Telegram")

# --- OpenAI (optional embeddings) ---
if self.choices.openai_api_key:
click.echo(" · OpenAI API: testing...", nl=False)
ok, detail = _check_openai_key(self.choices.openai_api_key)
# --- Embeddings (optional) ---
if self.choices.openai_api_key or self.choices.embeddings_api_endpoint:
endpoint = self.choices.embeddings_api_endpoint
label = endpoint or "OpenAI API"
click.echo(f" · Embeddings ({label}): testing...", nl=False)
ok, detail = _check_openai_key(
self.choices.openai_api_key, base_url=endpoint,
)
if ok:
click.secho(f" ✓ {detail}", fg="green")
else:
click.secho(f" ✗ {detail}", fg="red")
failures.append("OpenAI")
failures.append("Embeddings")

if failures:
click.echo()
Expand Down Expand Up @@ -2566,6 +2584,10 @@ def run_non_interactive(config_dir: Path) -> SetupChoices:
# Optional
choices.mode = os.environ.get("NERVE_MODE", "personal")
choices.openai_api_key = os.environ.get("OPENAI_API_KEY", "")
choices.embeddings_api_endpoint = os.environ.get(
"NERVE_EMBEDDINGS_API_ENDPOINT", "",
).strip()
choices.embeddings_model = os.environ.get("NERVE_EMBEDDINGS_MODEL", "").strip()
default_ws = _DOCKER_WORKSPACE if is_docker else "~/nerve-workspace"
choices.workspace_path = Path(os.environ.get("NERVE_WORKSPACE", default_ws))
choices.timezone = os.environ.get("NERVE_TIMEZONE", "America/New_York")
Expand Down
12 changes: 11 additions & 1 deletion nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,17 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s
else:
errors.append(f"[ERR] Claude API: {detail}")

if config.openai_api_key:
embed_endpoint = config.memory.embed_base_url
if embed_endpoint and not config.memory.embed_model:
errors.append(
f"[ERR] Embeddings endpoint {embed_endpoint} set but memory.embed_model "
"is empty (embeddings disabled)"
)
elif embed_endpoint:
lines.append(
f"[OK] Embeddings: {config.memory.embed_model} via {embed_endpoint}"
)
elif config.openai_api_key:
lines.append(f"[OK] OpenAI API key: ...{config.openai_api_key[-4:]} (vector embeddings enabled)")
else:
lines.append("[--] OpenAI API key not set (using LLM-based memory recall)")
Expand Down
2 changes: 2 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1466,6 +1466,7 @@ class MemoryConfig:
memorize_model: str = "claude-sonnet-4-6" # Extraction & preprocessing
fast_model: str = "claude-haiku-4-5-20251001" # Category summaries, date resolution
embed_model: str = ""
embed_base_url: str = "" # OpenAI-compatible /v1 base; empty = api.openai.com
sqlite_dsn: str = ""
semantic_dedup_threshold: float = 0.85 # Cosine similarity threshold for semantic dedup
knowledge_filter: bool = False # Post-extraction LLM filter for generic knowledge (extra API call)
Expand All @@ -1482,6 +1483,7 @@ def from_dict(cls, d: dict) -> MemoryConfig:
memorize_model=d.get("memorize_model", "claude-sonnet-4-6"),
fast_model=d.get("fast_model", "claude-haiku-4-5-20251001"),
embed_model=d.get("embed_model", ""),
embed_base_url=str(d.get("embed_base_url", "") or "").strip(),
sqlite_dsn=d.get("sqlite_dsn", default_dsn),
semantic_dedup_threshold=d.get("semantic_dedup_threshold", 0.85),
knowledge_filter=d.get("knowledge_filter", False),
Expand Down
44 changes: 37 additions & 7 deletions nerve/memory/memu_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,42 @@ def _is_sqlite_locked_error(exc: BaseException) -> bool:
# Semantic dedup threshold — set from config at init time.
_SEMANTIC_DEDUP_THRESHOLD = 0.85

# Fallback embeddings endpoint when memory.embed_base_url is unset.
_OPENAI_EMBED_BASE_URL = "https://api.openai.com/v1"

# Max characters for a category breadcrumb in recall() output. A category
# summary is a whole rolled-up topic document (often 5–20KB); recall must
# surface it as a short navigable pointer, not dump the document.
_CATEGORY_BREADCRUMB_MAXLEN = 200


def build_embedding_profile(
*, openai_api_key: str, embed_base_url: str, embed_model: str,
) -> dict[str, str] | None:
"""memU ``embedding`` LLM profile, or None for LLM-based recall.

Requires ``embed_model`` plus either a key or a base URL. Any endpoint
implementing OpenAI ``/v1/embeddings`` is accepted.
"""
if not (openai_api_key or embed_base_url):
return None
base_url = embed_base_url or _OPENAI_EMBED_BASE_URL
if not embed_model:
# Without this the API is called with model="".
logger.warning(
"Embeddings endpoint %s configured but memory.embed_model is empty "
"— embeddings disabled, falling back to LLM-based recall", base_url,
)
return None
return {
"base_url": base_url,
# AsyncOpenAI rejects an unset key; the endpoint may ignore it.
"api_key": openai_api_key or "placeholder",
"embed_model": embed_model,
"client_backend": "sdk",
}


def _category_breadcrumb(name: str, description: str, summary: str) -> str:
"""Build a short one-line breadcrumb for a category recall hit.

Expand Down Expand Up @@ -1663,13 +1693,13 @@ async def _initialize_impl(self) -> bool:
},
}

if self.config.openai_api_key:
llm_profiles["embedding"] = {
"base_url": "https://api.openai.com/v1",
"api_key": self.config.openai_api_key,
"embed_model": self.config.memory.embed_model,
"client_backend": "sdk",
}
embedding_profile = build_embedding_profile(
openai_api_key=self.config.openai_api_key,
embed_base_url=self.config.memory.embed_base_url,
embed_model=self.config.memory.embed_model,
)
if embedding_profile is not None:
llm_profiles["embedding"] = embedding_profile

resources_dir = paths.nerve_path("memu-resources")
resources_dir.mkdir(parents=True, exist_ok=True)
Expand Down
77 changes: 77 additions & 0 deletions tests/test_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,83 @@ def test_us_region_writes_us_models(self, tmp_path: Path, monkeypatch) -> None:
assert settings["agent"]["model"].startswith("us.anthropic.")


class TestNonInteractiveEmbeddingsEndpoint:
"""NERVE_EMBEDDINGS_API_ENDPOINT / NERVE_EMBEDDINGS_MODEL env wiring."""

def _settings(self, tmp_path: Path) -> dict:
return yaml.safe_load(
(tmp_path / "ws" / "config" / "settings.yaml").read_text()
)

def test_endpoint_written_to_settings(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
env = {
"NERVE_PROVIDER": "bedrock",
"NERVE_AWS_REGION": "eu-central-1",
"NERVE_WORKSPACE": str(tmp_path / "ws"),
"NERVE_EMBEDDINGS_API_ENDPOINT": "https://llm.internal/v1",
"NERVE_EMBEDDINGS_MODEL": "bge-large-en",
}
with patch.dict(os.environ, env, clear=False):
choices = run_non_interactive(tmp_path)

assert choices.embeddings_api_endpoint == "https://llm.internal/v1"
memory = self._settings(tmp_path)["memory"]
assert memory["embed_base_url"] == "https://llm.internal/v1"
assert memory["embed_model"] == "bge-large-en"

def test_endpoint_survives_bedrock_model_rewrite(
self, tmp_path: Path, monkeypatch,
) -> None:
"""Bedrock rewrites the chat models but not the embed keys."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
env = {
"NERVE_PROVIDER": "bedrock",
"NERVE_AWS_REGION": "eu-central-1",
"NERVE_WORKSPACE": str(tmp_path / "ws"),
"NERVE_EMBEDDINGS_API_ENDPOINT": "https://llm.internal/v1",
"NERVE_EMBEDDINGS_MODEL": "bge-large-en",
}
with patch.dict(os.environ, env, clear=False):
run_non_interactive(tmp_path)

memory = self._settings(tmp_path)["memory"]
assert memory["fast_model"].startswith("eu.anthropic.")
assert memory["embed_base_url"] == "https://llm.internal/v1"
assert memory["embed_model"] == "bge-large-en"

def test_endpoint_is_stripped(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
env = {
"NERVE_PROVIDER": "bedrock",
"NERVE_AWS_REGION": "eu-central-1",
"NERVE_WORKSPACE": str(tmp_path / "ws"),
"NERVE_EMBEDDINGS_API_ENDPOINT": " https://llm.internal/v1 ",
}
with patch.dict(os.environ, env, clear=False):
choices = run_non_interactive(tmp_path)

assert choices.embeddings_api_endpoint == "https://llm.internal/v1"

def test_omitted_endpoint_leaves_key_absent(
self, tmp_path: Path, monkeypatch,
) -> None:
"""No env var means no config key, leaving OpenAI as the default."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
env = {
"NERVE_PROVIDER": "bedrock",
"NERVE_AWS_REGION": "eu-central-1",
"NERVE_WORKSPACE": str(tmp_path / "ws"),
}
with patch.dict(os.environ, env, clear=False):
choices = run_non_interactive(tmp_path)

assert choices.embeddings_api_endpoint == ""
memory = self._settings(tmp_path)["memory"]
assert "embed_base_url" not in memory
assert memory["embed_model"] == "text-embedding-3-small"


class TestNonInteractiveTelegramAllowedUsers:
"""NERVE_TELEGRAM_ALLOWED_USERS env wiring."""

Expand Down
Loading
Loading