diff --git a/docs/semantic-search.md b/docs/semantic-search.md index 414ea3876..41053547f 100644 --- a/docs/semantic-search.md +++ b/docs/semantic-search.md @@ -115,7 +115,7 @@ All settings are fields on `BasicMemoryConfig` and can be set via environment va | `milvus_timeout_seconds` | `BASIC_MEMORY_MILVUS_TIMEOUT_SECONDS` | `30.0` | Finite per-operation timeout for Milvus and Zilliz client calls. Increase it for unusually slow deployments. | | `milvus_collection_prefix` | `BASIC_MEMORY_MILVUS_COLLECTION_PREFIX` | `"basic_memory"` | Prefix for deterministic project-isolated Milvus collections. | | `milvus_database` | `BASIC_MEMORY_MILVUS_DATABASE` | `"default"` | Milvus database name. | -| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local), `"openai"` (API), or `"litellm"` (multi-provider API, **experimental** — advanced users only). | +| `semantic_embedding_provider` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `"fastembed"` | Embedding provider: `"fastembed"` (local), `"openai"` (API), `"orcarouter"` (API), or `"litellm"` (multi-provider API, **experimental** — advanced users only). | | `semantic_embedding_model` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_MODEL` | `"bge-small-en-v1.5"` | Model identifier. Auto-adjusted per provider if left at default. | | `semantic_embedding_api_base` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_API_BASE` | Unset | Optional custom endpoint for the LiteLLM provider, including local or self-hosted OpenAI-compatible servers. | | `semantic_embedding_api_key` | `BASIC_MEMORY_SEMANTIC_EMBEDDING_API_KEY` | Unset | Optional API key passed directly to the LiteLLM provider. When unset, LiteLLM continues to read provider credential env vars such as `OPENAI_API_KEY`. | @@ -158,6 +158,20 @@ export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=openai export OPENAI_API_KEY=sk-... ``` +### OrcaRouter + +Uses [OrcaRouter](https://www.orcarouter.ai) as the embedding backend through its OpenAI-compatible `/v1/embeddings` endpoint. OrcaRouter is a model routing gateway that exposes 150+ models from OpenAI, Anthropic, Google, DeepSeek, Qwen, MiniMax and xAI behind a single API key, and it also provides gateway-level security controls for AI agents. Model ids use the gateway's `provider/model` form, e.g. `openai/text-embedding-3-small`. + +- **Model**: `openai/text-embedding-3-small` +- **Dimensions**: 1536 +- **Tradeoff**: Same OpenAI-compatible wire as the OpenAI provider, but the request goes through OrcaRouter, so the endpoint, key, and routing stay gateway-managed. + +```bash +export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true +export BASIC_MEMORY_SEMANTIC_EMBEDDING_PROVIDER=orcarouter +export ORCAROUTER_API_KEY=sk-orca-... +``` + ### LiteLLM > **Experimental — advanced users only.** The LiteLLM provider is experimental and aimed at users comfortable operating remote embedding backends: paid API calls, per-model dimension and input-role configuration, and slower reindexing of large corpora. For most users, FastEmbed (local, default) is recommended. See [LiteLLM Provider](litellm-provider.md) for the caveats and tuning. diff --git a/src/basic_memory/config_models.py b/src/basic_memory/config_models.py index 0ba872290..242a7bc98 100644 --- a/src/basic_memory/config_models.py +++ b/src/basic_memory/config_models.py @@ -291,7 +291,9 @@ def __init__(self, **data: Any) -> None: ... ) semantic_embedding_provider: str = Field( default="fastembed", - description="Embedding provider for local semantic indexing/search.", + description="Embedding provider for local semantic indexing/search: " + "'fastembed' (local), 'openai' (API), 'orcarouter' (API), or 'litellm' " + "(multi-provider API).", ) semantic_embedding_model: str = Field( default="bge-small-en-v1.5", diff --git a/src/basic_memory/repository/embedding_provider_factory.py b/src/basic_memory/repository/embedding_provider_factory.py index 96d4c32a5..35e08b1d7 100644 --- a/src/basic_memory/repository/embedding_provider_factory.py +++ b/src/basic_memory/repository/embedding_provider_factory.py @@ -162,6 +162,17 @@ def configured_embedding_provider_identity(app_config: BasicMemoryConfig) -> str model_name = "text-embedding-3-small" dimensions = configured_dimensions or 1536 provider_identity = f"{model_name}:{dimensions}" + elif provider_name == "orcarouter": + from basic_memory.repository.orcarouter_provider import ( + ORCAROUTER_DEFAULT_MODEL, + ) + + provider_type_name = "OrcaRouterEmbeddingProvider" + model_name = app_config.semantic_embedding_model or ORCAROUTER_DEFAULT_MODEL + if model_name == "bge-small-en-v1.5": + model_name = ORCAROUTER_DEFAULT_MODEL + dimensions = configured_dimensions or 1536 + provider_identity = f"{model_name}:{dimensions}" elif provider_name == "litellm": from basic_memory.repository.litellm_provider import ( _default_input_types, @@ -270,6 +281,22 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide request_concurrency=app_config.semantic_embedding_request_concurrency, **extra_kwargs, ) + elif provider_name == "orcarouter": + # Deferred import: openai may not be installed + from basic_memory.repository.orcarouter_provider import ( + ORCAROUTER_DEFAULT_MODEL, + OrcaRouterEmbeddingProvider, + ) + + model_name = app_config.semantic_embedding_model or ORCAROUTER_DEFAULT_MODEL + if model_name == "bge-small-en-v1.5": + model_name = ORCAROUTER_DEFAULT_MODEL + provider = OrcaRouterEmbeddingProvider( + model_name=model_name, + batch_size=app_config.semantic_embedding_batch_size, + request_concurrency=app_config.semantic_embedding_request_concurrency, + **extra_kwargs, + ) elif provider_name == "litellm": from basic_memory.repository.litellm_provider import LiteLLMEmbeddingProvider diff --git a/src/basic_memory/repository/orcarouter_provider.py b/src/basic_memory/repository/orcarouter_provider.py new file mode 100644 index 000000000..cbc3675e8 --- /dev/null +++ b/src/basic_memory/repository/orcarouter_provider.py @@ -0,0 +1,74 @@ +"""OrcaRouter-based embedding provider for cloud or API-backed semantic indexing.""" + +from __future__ import annotations + +import os +from typing import Any, override + +from basic_memory.repository.openai_provider import OpenAIEmbeddingProvider +from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError + +ORCAROUTER_DEFAULT_BASE_URL = "https://api.orcarouter.ai/v1" +ORCAROUTER_DEFAULT_MODEL = "openai/text-embedding-3-small" + + +class OrcaRouterEmbeddingProvider(OpenAIEmbeddingProvider): + """Embedding provider backed by OrcaRouter's OpenAI-compatible embeddings API. + + OrcaRouter is an OpenAI-compatible model routing gateway. This provider points + the OpenAI-compatible embedding client at ``https://api.orcarouter.ai/v1`` and + authenticates with ``ORCAROUTER_API_KEY`` (keys start with ``sk-orca-``). + Model ids use the gateway's ``provider/model`` form, e.g. ``openai/text-embedding-3-small``. + """ + + def __init__( + self, + model_name: str = ORCAROUTER_DEFAULT_MODEL, + *, + batch_size: int = 64, + request_concurrency: int = 4, + dimensions: int = 1536, + api_key: str | None = None, + base_url: str | None = None, + timeout: float = 30.0, + ) -> None: + super().__init__( + model_name=model_name, + batch_size=batch_size, + request_concurrency=request_concurrency, + dimensions=dimensions, + api_key=api_key, + base_url=base_url or ORCAROUTER_DEFAULT_BASE_URL, + timeout=timeout, + ) + + @override + async def _get_client(self) -> Any: + if self._client is not None: + return self._client + + async with self._client_lock: + if self._client is not None: + return self._client + + try: + from openai import AsyncOpenAI + except ImportError as exc: # pragma: no cover - covered via monkeypatch tests + raise SemanticDependenciesMissingError( + "OpenAI dependency is missing. " + "Install/update basic-memory to include semantic dependencies: " + "pip install -U basic-memory" + ) from exc + + api_key = self._api_key or os.getenv("ORCAROUTER_API_KEY") + if not api_key: + raise SemanticDependenciesMissingError( + "OrcaRouter embedding provider requires ORCAROUTER_API_KEY." + ) + + self._client = AsyncOpenAI( + api_key=api_key, + base_url=self._base_url, + timeout=self._timeout, + ) + return self._client diff --git a/tests/repository/test_orcarouter_provider.py b/tests/repository/test_orcarouter_provider.py new file mode 100644 index 000000000..df3e53e44 --- /dev/null +++ b/tests/repository/test_orcarouter_provider.py @@ -0,0 +1,220 @@ +"""Tests for OrcaRouterEmbeddingProvider and its embedding provider factory branch.""" + +import builtins +import sys +from types import SimpleNamespace + +import pytest + +from basic_memory.config import BasicMemoryConfig +from basic_memory.repository.embedding_provider_factory import ( + create_embedding_provider, + reset_embedding_provider_cache, +) +from basic_memory.repository.orcarouter_provider import ( + ORCAROUTER_DEFAULT_BASE_URL, + ORCAROUTER_DEFAULT_MODEL, + OrcaRouterEmbeddingProvider, +) +from basic_memory.repository.semantic_errors import SemanticDependenciesMissingError + + +class _StubEmbeddingsApi: + def __init__(self): + self.calls: list[tuple[str, list[str]]] = [] + + async def create(self, *, model: str, input: list[str]): + self.calls.append((model, input)) + vectors = [] + for index, value in enumerate(input): + base = float(len(value)) + vectors.append(SimpleNamespace(index=index, embedding=[base, base + 1.0, base + 2.0])) + return SimpleNamespace(data=vectors) + + +class _StubAsyncOpenAI: + init_count = 0 + + def __init__(self, *, api_key: str, base_url=None, timeout=30.0): + self.api_key = api_key + self.base_url = base_url + self.timeout = timeout + self.embeddings = _StubEmbeddingsApi() + _StubAsyncOpenAI.init_count += 1 + + +@pytest.fixture(autouse=True) +def _reset_embedding_provider_cache_fixture(): + reset_embedding_provider_cache() + yield + reset_embedding_provider_cache() + + +def _install_stub_openai(monkeypatch) -> None: + module = type(sys)("openai") + setattr(module, "AsyncOpenAI", _StubAsyncOpenAI) + monkeypatch.setitem(sys.modules, "openai", module) + + +def _make_config(**overrides) -> BasicMemoryConfig: + defaults = { + "env": "test", + "projects": {"test-project": "/tmp/basic-memory-test"}, + "default_project": "test-project", + "semantic_search_enabled": True, + } + defaults.update(overrides) + return BasicMemoryConfig(**defaults) + + +# --- Provider behavior -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_orcarouter_provider_lazy_loads_and_reuses_client(monkeypatch): + """Provider should instantiate AsyncOpenAI lazily, use OrcaRouter base URL, and reuse a single client.""" + _install_stub_openai(monkeypatch) + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + _StubAsyncOpenAI.init_count = 0 + + provider = OrcaRouterEmbeddingProvider( + model_name=ORCAROUTER_DEFAULT_MODEL, batch_size=2, dimensions=3 + ) + assert provider._client is None + + first = await provider.embed_query("auth query") + second = await provider.embed_documents(["queue task", "relation sync"]) + + assert _StubAsyncOpenAI.init_count == 1 + assert provider._client is not None + client = provider._client + assert client.base_url == ORCAROUTER_DEFAULT_BASE_URL + assert client.api_key == "sk-orca-test" + assert len(first) == 3 + assert len(second) == 2 + assert len(second[0]) == 3 + + +@pytest.mark.asyncio +async def test_orcarouter_provider_respects_explicit_api_key_and_base_url(monkeypatch): + """Explicit api_key/base_url should win over env/defaults.""" + _install_stub_openai(monkeypatch) + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-env") + _StubAsyncOpenAI.init_count = 0 + + provider = OrcaRouterEmbeddingProvider( + model_name=ORCAROUTER_DEFAULT_MODEL, + api_key="sk-orca-explicit", + base_url="https://custom.example/v1", + dimensions=3, + ) + await provider.embed_query("test") + + assert provider._client is not None + client = provider._client + assert client.api_key == "sk-orca-explicit" + assert client.base_url == "https://custom.example/v1" + + +@pytest.mark.asyncio +async def test_orcarouter_provider_dimension_mismatch_raises_error(monkeypatch): + """Provider should fail fast when response dimensions differ from configured dimensions.""" + _install_stub_openai(monkeypatch) + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + + provider = OrcaRouterEmbeddingProvider(dimensions=2) + with pytest.raises(RuntimeError, match="3-dimensional vectors"): + await provider.embed_documents(["semantic note"]) + + +@pytest.mark.asyncio +async def test_orcarouter_provider_missing_dependency_raises_actionable_error(monkeypatch): + """Missing openai package should raise SemanticDependenciesMissingError.""" + monkeypatch.delitem(sys.modules, "openai", raising=False) + monkeypatch.setenv("ORCAROUTER_API_KEY", "sk-orca-test") + original_import = builtins.__import__ + + def _raising_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "openai": + raise ImportError("openai not installed") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _raising_import) + + provider = OrcaRouterEmbeddingProvider(model_name=ORCAROUTER_DEFAULT_MODEL) + with pytest.raises(SemanticDependenciesMissingError) as error: + await provider.embed_query("test") + + assert "pip install -U basic-memory" in str(error.value) + + +@pytest.mark.asyncio +async def test_orcarouter_provider_missing_api_key_raises_error(monkeypatch): + """ORCAROUTER_API_KEY is required unless api_key is passed explicitly.""" + _install_stub_openai(monkeypatch) + monkeypatch.delenv("ORCAROUTER_API_KEY", raising=False) + + provider = OrcaRouterEmbeddingProvider(model_name=ORCAROUTER_DEFAULT_MODEL) + with pytest.raises(SemanticDependenciesMissingError) as error: + await provider.embed_query("test") + + assert "ORCAROUTER_API_KEY" in str(error.value) + + +# --- Factory selection -------------------------------------------------------- + + +def test_embedding_provider_factory_selects_orcarouter_and_applies_default_model(): + """Factory should map local default model to OrcaRouter default when provider is orcarouter.""" + config = _make_config( + semantic_embedding_provider="orcarouter", + semantic_embedding_model="bge-small-en-v1.5", + ) + provider = create_embedding_provider(config) + assert isinstance(provider, OrcaRouterEmbeddingProvider) + assert provider.model_name == ORCAROUTER_DEFAULT_MODEL + assert provider._base_url == ORCAROUTER_DEFAULT_BASE_URL + + +def test_embedding_provider_factory_orcarouter_uses_default_dimensions(): + """Factory should use OrcaRouter default 1536 dimensions when unset.""" + config = _make_config(semantic_embedding_provider="orcarouter") + provider = create_embedding_provider(config) + assert isinstance(provider, OrcaRouterEmbeddingProvider) + assert provider.dimensions == 1536 + + +def test_embedding_provider_factory_passes_custom_dimensions_to_orcarouter(): + """Factory should forward semantic_embedding_dimensions to the OrcaRouter provider.""" + config = _make_config( + semantic_embedding_provider="orcarouter", + semantic_embedding_dimensions=3072, + ) + provider = create_embedding_provider(config) + assert isinstance(provider, OrcaRouterEmbeddingProvider) + assert provider.dimensions == 3072 + + +def test_embedding_provider_factory_orcarouter_forwards_request_concurrency(): + """Factory should forward provider request concurrency for API-backed batching.""" + config = _make_config( + semantic_embedding_provider="orcarouter", + semantic_embedding_request_concurrency=6, + ) + provider = create_embedding_provider(config) + assert isinstance(provider, OrcaRouterEmbeddingProvider) + assert provider.request_concurrency == 6 + + +def test_embedding_provider_identity_orcarouter(): + """configured_embedding_provider_identity should name OrcaRouterEmbeddingProvider.""" + from basic_memory.repository.embedding_provider_factory import ( + configured_embedding_provider_identity, + ) + + config = _make_config( + semantic_embedding_provider="orcarouter", + semantic_embedding_model="openai/text-embedding-3-small", + ) + identity = configured_embedding_provider_identity(config) + assert identity == "OrcaRouterEmbeddingProvider:openai/text-embedding-3-small:1536"