75c67150d0
build / build (3.13) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build wheels (macos-aarch64) (push) Blocked by required conditions
release-please / build wheels (macos-x86_64) (push) Blocked by required conditions
release-please / build wheels (windows-x86_64) (push) Blocked by required conditions
release-please / build wheels (linux-aarch64) (push) Blocked by required conditions
release-please / build wheels (linux-x86_64) (push) Blocked by required conditions
release-please / build sdist (push) Blocked by required conditions
release-please / publish release artifacts (push) Blocked by required conditions
171 lines
6.4 KiB
Python
171 lines
6.4 KiB
Python
"""Unit tests for the standalone ``memu.embedding`` package.
|
|
|
|
These pin the embedding module's contract that mirrors ``memu.llm``/``memu.vlm``:
|
|
|
|
- per-provider backends (openai/jina/voyage/openrouter/doubao) build the right
|
|
payload/endpoint and parse the ``data[].embedding`` response shape.
|
|
- the HTTP client falls back to an OpenAI-compatible backend for unknown
|
|
providers and returns ``(vectors, raw_response)``.
|
|
- the gateway dispatches on ``client_backend`` and raises clearly for anthropic.
|
|
- ``EmbeddingConfig`` resolves per-provider base_url/api_key/model defaults, and
|
|
``embedding_config_from_llm`` derives a config from an LLM profile.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
src_path = Path(__file__).parent.parent / "src"
|
|
if str(src_path) not in sys.path:
|
|
sys.path.insert(0, str(src_path))
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from memu.app.settings import EmbeddingConfig, LLMConfig, embedding_config_from_llm # noqa: E402
|
|
from memu.embedding.backends import ( # noqa: E402
|
|
JinaEmbeddingBackend,
|
|
OpenAIEmbeddingBackend,
|
|
OpenRouterEmbeddingBackend,
|
|
VoyageEmbeddingBackend,
|
|
)
|
|
from memu.embedding.gateway import build_embedding_client # noqa: E402
|
|
from memu.embedding.http_client import HTTPEmbeddingClient # noqa: E402
|
|
from memu.embedding.openai_sdk import OpenAIEmbeddingSDKClient # noqa: E402
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("backend", "endpoint"),
|
|
[
|
|
(OpenAIEmbeddingBackend(), "/embeddings"),
|
|
(JinaEmbeddingBackend(), "/embeddings"),
|
|
(VoyageEmbeddingBackend(), "/embeddings"),
|
|
(OpenRouterEmbeddingBackend(), "/api/v1/embeddings"),
|
|
],
|
|
)
|
|
def test_backend_payload_and_parse(backend, endpoint):
|
|
assert backend.embedding_endpoint == endpoint
|
|
assert backend.default_headers("k") == {"Authorization": "Bearer k"}
|
|
|
|
payload = backend.build_embedding_payload(inputs=["a", "b"], embed_model="m")
|
|
assert payload["model"] == "m"
|
|
assert payload["input"] == ["a", "b"]
|
|
|
|
parsed = backend.parse_embedding_response({"data": [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]})
|
|
assert parsed == [[0.1, 0.2], [0.3, 0.4]]
|
|
|
|
|
|
def test_http_client_unknown_provider_falls_back_to_openai():
|
|
client = HTTPEmbeddingClient(base_url="https://x/v1", api_key="k", embed_model="m", provider="grok")
|
|
assert isinstance(client.backend, OpenAIEmbeddingBackend)
|
|
|
|
|
|
def test_http_client_selects_registered_backend():
|
|
client = HTTPEmbeddingClient(base_url="https://api.jina.ai/v1", api_key="k", embed_model="m", provider="jina")
|
|
assert isinstance(client.backend, JinaEmbeddingBackend)
|
|
|
|
|
|
async def test_http_client_embed_returns_vectors_and_raw(monkeypatch):
|
|
captured: dict = {}
|
|
|
|
class _FakeResponse:
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return {"data": [{"embedding": [1.0, 2.0]}], "usage": {"total_tokens": 3}}
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return False
|
|
|
|
async def post(self, endpoint, json, headers):
|
|
captured["endpoint"] = endpoint
|
|
captured["json"] = json
|
|
captured["headers"] = headers
|
|
return _FakeResponse()
|
|
|
|
import memu.embedding.http_client as http_mod
|
|
|
|
monkeypatch.setattr(http_mod.httpx, "AsyncClient", _FakeAsyncClient)
|
|
|
|
client = HTTPEmbeddingClient(
|
|
base_url="https://api.voyageai.com/v1", api_key="key", embed_model="voyage-3.5", provider="voyage"
|
|
)
|
|
vectors, raw = await client.embed(["hello"])
|
|
|
|
assert vectors == [[1.0, 2.0]]
|
|
assert raw["usage"]["total_tokens"] == 3
|
|
assert captured["endpoint"] == "embeddings" # leading slash stripped
|
|
assert captured["headers"] == {"Authorization": "Bearer key"}
|
|
assert captured["json"] == {"model": "voyage-3.5", "input": ["hello"]}
|
|
|
|
|
|
def test_gateway_builds_sdk_and_httpx_clients():
|
|
sdk = build_embedding_client(EmbeddingConfig(provider="openai", api_key="k", client_backend="sdk"))
|
|
assert isinstance(sdk, OpenAIEmbeddingSDKClient)
|
|
|
|
httpx_client = build_embedding_client(EmbeddingConfig(provider="jina", api_key="k", client_backend="httpx"))
|
|
assert isinstance(httpx_client, HTTPEmbeddingClient)
|
|
assert isinstance(httpx_client.backend, JinaEmbeddingBackend)
|
|
|
|
|
|
def test_gateway_rejects_anthropic_and_unknown_backends():
|
|
with pytest.raises(ValueError, match="Anthropic does not provide"):
|
|
build_embedding_client(EmbeddingConfig(client_backend="anthropic"))
|
|
|
|
with pytest.raises(ValueError, match="Unknown embedding client_backend"):
|
|
build_embedding_client(EmbeddingConfig(client_backend="nope"))
|
|
|
|
|
|
def test_embedding_config_provider_defaults():
|
|
jina = EmbeddingConfig(provider="jina")
|
|
assert jina.base_url == "https://api.jina.ai/v1"
|
|
assert jina.api_key == "JINA_API_KEY"
|
|
assert jina.embed_model == "jina-embeddings-v3"
|
|
|
|
voyage = EmbeddingConfig(provider="voyage")
|
|
assert voyage.base_url == "https://api.voyageai.com/v1"
|
|
assert voyage.api_key == "VOYAGE_API_KEY"
|
|
assert voyage.embed_model == "voyage-3.5"
|
|
|
|
# Explicit values always survive the provider-default merge.
|
|
explicit = EmbeddingConfig(provider="jina", base_url="https://proxy/v1", api_key="real", embed_model="custom")
|
|
assert explicit.base_url == "https://proxy/v1"
|
|
assert explicit.api_key == "real"
|
|
assert explicit.embed_model == "custom"
|
|
|
|
|
|
def test_chat_clients_no_longer_expose_embed():
|
|
"""Embedding is fully decoupled: text/chat clients must not carry embed()."""
|
|
from memu.llm.anthropic_client import AnthropicClient
|
|
from memu.llm.http_client import HTTPLLMClient
|
|
from memu.llm.openai_client import OpenAIClient
|
|
|
|
assert not hasattr(OpenAIClient, "embed")
|
|
assert not hasattr(HTTPLLMClient, "embed")
|
|
assert not hasattr(AnthropicClient, "embed")
|
|
# The HTTP chat client no longer wires an embedding backend either.
|
|
assert not hasattr(HTTPLLMClient, "_load_embedding_backend")
|
|
|
|
|
|
def test_embedding_config_from_llm_carries_transport_and_model():
|
|
llm = LLMConfig(
|
|
provider="openrouter",
|
|
client_backend="httpx",
|
|
api_key="rk",
|
|
embed_model="openai/text-embedding-3-large",
|
|
)
|
|
emb = embedding_config_from_llm(llm)
|
|
assert emb.provider == "openrouter"
|
|
assert emb.client_backend == "httpx"
|
|
assert emb.api_key == "rk"
|
|
assert emb.embed_model == "openai/text-embedding-3-large"
|
|
assert emb.base_url == llm.base_url
|