diff --git a/docs/contributing.md b/docs/contributing.md index ebb42cad..6b58e886 100755 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -102,6 +102,7 @@ make verify | `SURREAL_MEMORY_EMBEDDING_ENABLED` | No | `false` | Enable vector embeddings | | `SURREAL_MEMORY_EMBEDDING_PROVIDER` | Embeddings only | `sentence_transformer` | `sentence_transformer`, `openai`, `openrouter`, `gemini`, `ollama`, `bge_m3`, `auto` | | `SURREAL_MEMORY_EMBEDDING_ENDPOINT` | No | — | Base URL of a local OpenAI-compatible embedding server (e.g. llamastash bge-m3 at `http://127.0.0.1:11435/v1`). Config key `[embedding] endpoint` wins over this. | +| `SURREAL_MEMORY_EMBEDDING_API_KEY` | No | — | API key for OpenAI-compatible embedding endpoints; takes precedence over `OPENAI_API_KEY` for OpenAI embeddings. Explicit provider arguments and OpenRouter's own key remain separate. | | `SURREAL_MEMORY_EMBEDDING_MODEL` | No | — | Model name for embeddings | | `GEMINI_API_KEY` | Gemini only | — | Google Gemini API key | | `OPENAI_API_KEY` | OpenAI only | — | OpenAI API key | diff --git a/docs/guides/embedding-setup.md b/docs/guides/embedding-setup.md index 025629dd..b3ab3f87 100755 --- a/docs/guides/embedding-setup.md +++ b/docs/guides/embedding-setup.md @@ -32,6 +32,13 @@ Auto-detection checks (in order): 4. **OPENAI_API_KEY** set → uses OpenAI's embedding API 5. **OPENROUTER_API_KEY** set → uses OpenRouter's OpenAI-compatible embedding API +For an OpenAI-compatible embedding endpoint configured with `[embedding] endpoint` +or `SURREAL_MEMORY_EMBEDDING_ENDPOINT`, set `SURREAL_MEMORY_EMBEDDING_API_KEY` +to its credential. It takes precedence over `OPENAI_API_KEY` for embeddings, so +other tools can keep using their own ambient OpenAI-compatible credentials. If it +is unset, `OPENAI_API_KEY` remains the fallback. OpenRouter continues to use +`OPENROUTER_API_KEY`. + If none are available, embedding stays disabled and recall falls back to graph-only (which works great for single-language use). ## Providers diff --git a/docs/reference/config.md b/docs/reference/config.md index d0d72bd4..9e6a246a 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -390,7 +390,7 @@ places, the environment wins. | `SURREAL_MEMORY_DASHBOARD_CACHE_TTL` | `src/surreal_memory/server/dashboard_cache.py` | | `SURREAL_MEMORY_DIR` | `src/surreal_memory/cli/config.py`, `src/surreal_memory/cli/update_check.py`, `src/surreal_memory/engine/reasoning_injection.py`, +4 more | | `SURREAL_MEMORY_DISABLE_SUPERSEDED_FILTER` | `src/surreal_memory/mcp/recall_handler.py` | -| `SURREAL_MEMORY_EMBEDDING_API_KEY` | `src/surreal_memory/engine/embedding/bge_m3_embedding.py` | +| `SURREAL_MEMORY_EMBEDDING_API_KEY` | `src/surreal_memory/engine/embedding/bge_m3_embedding.py`, `src/surreal_memory/engine/embedding/openai_embedding.py` | | `SURREAL_MEMORY_EMBEDDING_DIMENSION` | `src/surreal_memory/engine/embedding/bge_m3_embedding.py`, `src/surreal_memory/unified_config.py` | | `SURREAL_MEMORY_EMBEDDING_ENABLED` | `src/surreal_memory/unified_config.py` | | `SURREAL_MEMORY_EMBEDDING_ENDPOINT` | `src/surreal_memory/engine/embedding/bge_m3_embedding.py`, `src/surreal_memory/engine/embedding/openai_embedding.py`, `src/surreal_memory/engine/reasoning_distiller.py`, +2 more | diff --git a/src/surreal_memory/engine/embedding/openai_embedding.py b/src/surreal_memory/engine/embedding/openai_embedding.py index d4910f74..0ed3934a 100755 --- a/src/surreal_memory/engine/embedding/openai_embedding.py +++ b/src/surreal_memory/engine/embedding/openai_embedding.py @@ -56,7 +56,17 @@ def __init__( self._base_url = resolved_base.rstrip("/") if resolved_base else None self._api_key_env = api_key_env self._provider_label = provider_label - self._api_key = api_key or os.getenv(api_key_env) + # A dedicated embedding credential must not be shadowed by an + # unrelated ambient OPENAI_API_KEY (for example, one used by another + # tool through OPENAI_BASE_URL). Keep explicit keys first, and apply + # this override only to the OpenAI-compatible provider that uses the + # default key variable; subclasses such as OpenRouter retain theirs. + embedding_api_key = ( + os.getenv("SURREAL_MEMORY_EMBEDDING_API_KEY") + if api_key_env == "OPENAI_API_KEY" + else None + ) + self._api_key = api_key or embedding_api_key or os.getenv(api_key_env) # A locally-configured endpoint (SURREAL_MEMORY_EMBEDDING_ENDPOINT, e.g. # llamastash / llama.cpp bge-m3) needs no real key, but the OpenAI SDK still # requires a non-empty string — fall back to a placeholder instead of failing diff --git a/tests/unit/test_embedding_provider.py b/tests/unit/test_embedding_provider.py index e9de67a7..6d125b21 100755 --- a/tests/unit/test_embedding_provider.py +++ b/tests/unit/test_embedding_provider.py @@ -1039,8 +1039,13 @@ def test_accepts_openrouter_env_key(self) -> None: from surreal_memory.engine.embedding.openrouter_embedding import OpenRouterEmbedding - env = {k: v for k, v in os.environ.items() if k != "OPENROUTER_API_KEY"} + env = { + k: v + for k, v in os.environ.items() + if k not in ("OPENROUTER_API_KEY", "SURREAL_MEMORY_EMBEDDING_API_KEY") + } env["OPENROUTER_API_KEY"] = "env-openrouter-key" + env["SURREAL_MEMORY_EMBEDDING_API_KEY"] = "embedding-key" with unittest.mock.patch.dict(os.environ, env, clear=True): provider = OpenRouterEmbedding() assert provider._api_key == "env-openrouter-key" @@ -1168,6 +1173,47 @@ def test_ambient_openai_base_url_is_ignored(self, monkeypatch: pytest.MonkeyPatc assert "evil.example" not in base assert base == "https://api.openai.com/v1" + def test_dedicated_embedding_key_wins_and_endpoint_is_used( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Embedding credentials and endpoint stay separate from ambient OpenAI settings.""" + captured: dict[str, object] = {} + + class _StubAsyncOpenAI: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + stub = types.ModuleType("openai") + stub.AsyncOpenAI = _StubAsyncOpenAI # type: ignore[attr-defined] + monkeypatch.setenv("SURREAL_MEMORY_EMBEDDING_API_KEY", "embedding-key") + monkeypatch.setenv("OPENAI_API_KEY", "unrelated-key") + monkeypatch.setenv("SURREAL_MEMORY_EMBEDDING_ENDPOINT", "https://litellm.example/v1") + with unittest.mock.patch.dict(sys.modules, {"openai": stub}): + OpenAIEmbedding()._ensure_client() + + assert captured["api_key"] == "embedding-key" + assert str(captured["base_url"]).rstrip("/") == "https://litellm.example/v1" + + def test_explicit_key_wins_over_dedicated_embedding_key( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("SURREAL_MEMORY_EMBEDDING_API_KEY", "embedding-key") + monkeypatch.setenv("OPENAI_API_KEY", "fallback-key") + + provider = OpenAIEmbedding(api_key="explicit-key") + + assert provider._api_key == "explicit-key" + + def test_openai_key_is_fallback_when_dedicated_key_is_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("SURREAL_MEMORY_EMBEDDING_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "fallback-key") + + provider = OpenAIEmbedding() + + assert provider._api_key == "fallback-key" + def test_configured_endpoint_still_wins(self, monkeypatch: pytest.MonkeyPatch) -> None: """An explicitly configured endpoint is the whole point — keep honouring it.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-test") @@ -1202,6 +1248,7 @@ def test_openrouter_subclass_keeps_its_own_base_url( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + monkeypatch.setenv("SURREAL_MEMORY_EMBEDDING_API_KEY", "embedding-key") monkeypatch.setenv("OPENAI_BASE_URL", "https://evil.example/v1") base = self._captured_base_url(_cls=OpenRouterEmbedding, model="text-embedding-3-small")