Skip to content
Merged
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 docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/guides/embedding-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 11 additions & 1 deletion src/surreal_memory/engine/embedding/openai_embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion tests/unit/test_embedding_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading