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
16 changes: 15 additions & 1 deletion docs/semantic-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/basic_memory/config_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/basic_memory/repository/embedding_provider_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions src/basic_memory/repository/orcarouter_provider.py
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +39 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Forward OrcaRouter dimensions before declaring 1536

When semantic_embedding_provider=orcarouter uses the default model, this constructor records dimensions=1536 only as Basic Memory's expected vector size, but the inherited embed_documents() call never sends a dimensions parameter to OrcaRouter. OrcaRouter's current model card for openai/text-embedding-3-small documents a 512-dimensional default unless a dimension is requested, so the normal bm reindex --embeddings path will create/expect 1536-dimensional vector storage and then fail on the first 512-dimensional response with the provider's dimension-mismatch error. Please either request dimensions=self.dimensions for OrcaRouter models that support it or fail fast unless the configured dimensions match the gateway default.

Useful? React with 👍 / 👎.

)

@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
220 changes: 220 additions & 0 deletions tests/repository/test_orcarouter_provider.py
Original file line number Diff line number Diff line change
@@ -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"