-
Notifications
You must be signed in to change notification settings - Fork 254
feat: add OrcaRouter as a named embedding provider #1252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
XiaoHuo888-hue
wants to merge
1
commit into
basicmachines-co:main
Choose a base branch
from
XiaoHuo888-hue:feat/orcarouter-embedding-provider
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
|
|
||
| @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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
semantic_embedding_provider=orcarouteruses the default model, this constructor recordsdimensions=1536only as Basic Memory's expected vector size, but the inheritedembed_documents()call never sends adimensionsparameter to OrcaRouter. OrcaRouter's current model card foropenai/text-embedding-3-smalldocuments a 512-dimensional default unless a dimension is requested, so the normalbm reindex --embeddingspath 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 requestdimensions=self.dimensionsfor OrcaRouter models that support it or fail fast unless the configured dimensions match the gateway default.Useful? React with 👍 / 👎.