diff --git a/config.example.yaml b/config.example.yaml index 26b96d06..34bb3528 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 /config/cron/ # (system.yaml, jobs.yaml, gates/) and is resolved from the workspace, with a diff --git a/docs/config.md b/docs/config.md index af122d04..2d1ab0e6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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.). | diff --git a/docs/memory.md b/docs/memory.md index 7ba424ab..914f26fd 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -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 diff --git a/nerve/bootstrap.py b/nerve/bootstrap.py index b0770fd0..3f63dcab 100644 --- a/nerve/bootstrap.py +++ b/nerve/bootstrap.py @@ -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" @@ -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) @@ -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 —" @@ -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 @@ -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() @@ -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") diff --git a/nerve/cli.py b/nerve/cli.py index 97acedb1..85141e6a 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -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)") diff --git a/nerve/config.py b/nerve/config.py index dc192688..91a92bb6 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -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) @@ -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), diff --git a/nerve/memory/memu_bridge.py b/nerve/memory/memu_bridge.py index e00b15bf..d908384a 100644 --- a/nerve/memory/memu_bridge.py +++ b/nerve/memory/memu_bridge.py @@ -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. @@ -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) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 839423d6..b121eb03 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -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.""" diff --git a/tests/test_memu_bridge.py b/tests/test_memu_bridge.py index ed5ec6f6..61ea00f1 100644 --- a/tests/test_memu_bridge.py +++ b/tests/test_memu_bridge.py @@ -19,6 +19,8 @@ _SEMANTIC_DEDUP_THRESHOLD, _content_hash_reinforce, _is_sqlite_locked_error, + _OPENAI_EMBED_BASE_URL, + build_embedding_profile, ) @@ -446,6 +448,75 @@ def test_semantic_dedup_threshold_from_dict_default(self): config = MemoryConfig.from_dict({}) assert config.semantic_dedup_threshold == 0.85 + def test_embed_base_url_default_empty(self): + assert MemoryConfig().embed_base_url == "" + assert MemoryConfig.from_dict({}).embed_base_url == "" + + def test_embed_base_url_from_dict(self): + config = MemoryConfig.from_dict({"embed_base_url": "https://llm.internal/v1"}) + assert config.embed_base_url == "https://llm.internal/v1" + + def test_embed_base_url_is_stripped(self): + config = MemoryConfig.from_dict({"embed_base_url": " https://llm.internal/v1 "}) + assert config.embed_base_url == "https://llm.internal/v1" + + def test_embed_base_url_none_coerces_to_empty(self): + """A YAML key present with no value parses as None, not a string.""" + assert MemoryConfig.from_dict({"embed_base_url": None}).embed_base_url == "" + + +class TestBuildEmbeddingProfile: + """Gating, endpoint override and key fallback for the memU profile.""" + + def test_no_key_and_no_endpoint_disables_embeddings(self): + assert build_embedding_profile( + openai_api_key="", embed_base_url="", embed_model="text-embedding-3-small", + ) is None + + def test_openai_key_alone_uses_openai_endpoint(self): + profile = build_embedding_profile( + openai_api_key="sk-test", embed_base_url="", + embed_model="text-embedding-3-small", + ) + assert profile == { + "base_url": _OPENAI_EMBED_BASE_URL, + "api_key": "sk-test", + "embed_model": "text-embedding-3-small", + "client_backend": "sdk", + } + + def test_endpoint_alone_enables_embeddings_without_a_key(self): + """Covers Bedrock installs, which have no OpenAI credential.""" + profile = build_embedding_profile( + openai_api_key="", embed_base_url="https://llm.internal/v1", + embed_model="bge-large-en", + ) + assert profile is not None + assert profile["base_url"] == "https://llm.internal/v1" + assert profile["embed_model"] == "bge-large-en" + # AsyncOpenAI rejects a None key, so a placeholder stands in. + assert profile["api_key"] == "placeholder" + + def test_endpoint_overrides_openai_even_when_key_is_set(self): + profile = build_embedding_profile( + openai_api_key="sk-test", embed_base_url="https://llm.internal/v1", + embed_model="bge-large-en", + ) + assert profile["base_url"] == "https://llm.internal/v1" + assert profile["api_key"] == "sk-test" + + def test_endpoint_without_model_disables_rather_than_guessing(self): + """An empty model would be sent to the API as model="".""" + assert build_embedding_profile( + openai_api_key="", embed_base_url="https://llm.internal/v1", + embed_model="", + ) is None + + def test_key_without_model_disables(self): + assert build_embedding_profile( + openai_api_key="sk-test", embed_base_url="", embed_model="", + ) is None + class TestKnowledgeCustomPrompts: """Test that custom knowledge extraction prompts are defined correctly."""