Files
lancedb/python/python/tests/test_embeddings_oci_genai.py
T
Fede Kamelhar 8be1f8e00f feat(python): add OCI Generative AI text embedding function
Adds `OCIGenAIEmbeddings`, registered as `"oci-genai"`, embedding text with the Cohere models hosted on Oracle Cloud Infrastructure Generative AI (default `cohere.embed-v4.0`) via the `oci` SDK, alongside the existing Bedrock, Gemini and watsonx functions.

- Auth delegated to the OCI SDK: `API_KEY` / `SECURITY_TOKEN` profiles from the OCI config file, or `INSTANCE_PRINCIPAL` / `RESOURCE_PRINCIPAL`. No credentials are stored on the function or in table metadata; `compartment_id` can come from `OCI_COMPARTMENT_ID` so the OCID need not be baked into metadata either.
- Endpoint derived from `region` (default `us-chicago-1`) or overridden with `service_endpoint`; dedicated AI cluster endpoint OCIDs use `DedicatedServingMode`.
- Asymmetric Cohere input types (`SEARCH_DOCUMENT` for the source column, `SEARCH_QUERY` for queries); `truncate` and `output_dimensions` (embed-v4.0: 256/512/1024/1536) forwarded.
- Batched at the service limit of 96 inputs; empty inputs are not sent and come back as `None`.
- `ndims()` uses `output_dimensions` when set, otherwise a single cached probe request, so there is no model→dims table to keep in sync with the catalog.
- SDK retries disabled in favour of LanceDB's `max_retries` backoff; 401/403 service errors carry `status_code` so they surface without retrying, with an actionable message (expired session token, IAM policy, region, model).
- `oci>=2.165.1` added to the `embeddings` extra (the floor where `EmbedTextDetails.output_dimensions` exists).

Tests: 22 mocked unit tests (auth paths, batching, input types, config validation, retry behaviour, pickling, table-metadata round trip) and a live test skipped unless `OCI_COMPARTMENT_ID` is set, which passes against us-chicago-1 for embed-v4.0 at 1536 and 512 dims and embed-multilingual-v3.0 at 1024. Verified end to end with a real LanceDB table (`LanceModel` with `SourceField`/`VectorField`, 6 rows, two semantic queries ranked correctly). `ruff format --check`, `ruff check`, pyright and typos are clean.

Docs: a page under `docs/src/embeddings/available_embedding_models/text_embedding_functions/` in the shape of the remaining `voyageai_embedding.md`; the function is exported from `lancedb.embeddings` so the API reference picks it up. The hosted docs' available-models table lives outside this repo and would need a separate update.
2026-11-03 16:08:15 +08:00

352 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Unit tests for the OCI Generative AI embedding function.
The ``oci`` SDK is replaced with a small fake so these run without the package
or cloud credentials. Live coverage lives in ``test_embeddings_slow.py``.
"""
import os
import pickle
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import lancedb
import pytest
from lancedb.embeddings import OCIGenAIEmbeddings, get_registry
from lancedb.embeddings.oci_genai import EMBEDDING_BATCH_SIZE, OCIGenAIServiceError
from lancedb.pydantic import LanceModel, Vector
from pydantic import ValidationError
DIMS = 8
COMPARTMENT = "ocid1.compartment.oc1..test"
class FakeServiceError(Exception):
"""Mimics ``oci.exceptions.ServiceError``."""
def __init__(self, status, code, message):
super().__init__(message)
self.status = status
self.code = code
self.message = message
def _fake_embed_text(details):
# Deterministic embeddings: first component encodes len(text) so ordering
# and nearest-neighbour behaviour can be asserted.
embeddings = [[float(len(text))] + [0.0] * (DIMS - 1) for text in details.inputs]
return SimpleNamespace(data=SimpleNamespace(embeddings=embeddings))
@pytest.fixture
def fake_oci():
"""Stand-in for the ``oci`` package, injected via attempt_import_or_raise."""
oci = MagicMock(name="oci")
oci.exceptions.ServiceError = FakeServiceError
models = oci.generative_ai_inference.models
models.EmbedTextDetails.side_effect = lambda **kw: SimpleNamespace(**kw)
models.OnDemandServingMode.side_effect = lambda **kw: SimpleNamespace(
serving_type="ON_DEMAND", **kw
)
models.DedicatedServingMode.side_effect = lambda **kw: SimpleNamespace(
serving_type="DEDICATED", **kw
)
oci.config.from_file.return_value = {"key_file": "~/.oci/key.pem"}
client = oci.generative_ai_inference.GenerativeAiInferenceClient.return_value
client.embed_text.side_effect = _fake_embed_text
with patch(
"lancedb.embeddings.oci_genai.attempt_import_or_raise", return_value=oci
):
yield oci
def _create(**kwargs):
kwargs.setdefault("compartment_id", COMPARTMENT)
kwargs.setdefault("max_retries", 0)
return get_registry().get("oci-genai").create(**kwargs)
def _client(fake_oci):
return fake_oci.generative_ai_inference.GenerativeAiInferenceClient.return_value
def _client_kwargs(fake_oci):
return fake_oci.generative_ai_inference.GenerativeAiInferenceClient.call_args.kwargs
def _embed_requests(fake_oci):
return [call.args[0] for call in _client(fake_oci).embed_text.call_args_list]
def test_oci_genai_registered():
assert get_registry().get("oci-genai") is OCIGenAIEmbeddings
assert "cohere.embed-v4.0" in OCIGenAIEmbeddings.model_names()
func = _create()
assert func.name == "cohere.embed-v4.0"
assert func.auth_type == "API_KEY"
assert func.truncate == "END"
assert func.sensitive_keys() == []
def test_oci_genai_service_endpoint(fake_oci):
default = _create()
assert default.endpoints() == []
default._client
assert (
_client_kwargs(fake_oci)["service_endpoint"]
== "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com"
)
regional = _create(region="eu-frankfurt-1")
regional_endpoint = (
"https://inference.generativeai.eu-frankfurt-1.oci.oraclecloud.com"
)
assert regional.endpoints() == [regional_endpoint]
regional._client
assert _client_kwargs(fake_oci)["service_endpoint"] == regional_endpoint
custom = "https://inference.generativeai.us-langley-1.oci.oraclegovcloud.com"
explicit = _create(region="us-chicago-1", service_endpoint=custom)
assert explicit.endpoints() == [custom]
explicit._client
assert _client_kwargs(fake_oci)["service_endpoint"] == custom
def test_oci_genai_client_disables_sdk_retries(fake_oci):
_create()._client
kwargs = _client_kwargs(fake_oci)
assert kwargs["retry_strategy"] is fake_oci.retry.NoneRetryStrategy.return_value
def test_oci_genai_api_key_auth(fake_oci, tmp_path):
config_file = str(tmp_path / "config")
func = _create(auth_profile="MYPROFILE", auth_file_location=config_file)
func._client
fake_oci.config.from_file.assert_called_once_with(
file_location=config_file, profile_name="MYPROFILE"
)
kwargs = _client_kwargs(fake_oci)
assert kwargs["config"] is fake_oci.config.from_file.return_value
assert "signer" not in kwargs
def test_oci_genai_api_key_rejects_session_profile(fake_oci):
fake_oci.config.from_file.return_value = {
"key_file": "~/.oci/sessions/x/key.pem",
"security_token_file": "~/.oci/sessions/x/token",
}
with pytest.raises(ValueError, match="SECURITY_TOKEN"):
_create(auth_type="API_KEY")._client
def test_oci_genai_security_token_auth(fake_oci, tmp_path):
token_file = tmp_path / "token"
token_file.write_text("session-token\n")
fake_oci.config.from_file.return_value = {
"key_file": "~/.oci/sessions/x/key.pem",
"security_token_file": str(token_file),
}
_create(auth_type="SECURITY_TOKEN", auth_profile="SESSION")._client
fake_oci.signer.load_private_key_from_file.assert_called_once_with(
os.path.expanduser("~/.oci/sessions/x/key.pem"), None
)
fake_oci.auth.signers.SecurityTokenSigner.assert_called_once_with(
"session-token", fake_oci.signer.load_private_key_from_file.return_value
)
kwargs = _client_kwargs(fake_oci)
assert kwargs["signer"] is fake_oci.auth.signers.SecurityTokenSigner.return_value
assert kwargs["config"] is fake_oci.config.from_file.return_value
def test_oci_genai_security_token_requires_token_file(fake_oci):
with pytest.raises(ValueError, match="security_token_file"):
_create(auth_type="SECURITY_TOKEN")._client
@pytest.mark.parametrize(
"auth_type,factory",
[
("INSTANCE_PRINCIPAL", "InstancePrincipalsSecurityTokenSigner"),
("RESOURCE_PRINCIPAL", "get_resource_principals_signer"),
],
)
def test_oci_genai_principal_auth(fake_oci, auth_type, factory):
_create(auth_type=auth_type)._client
signer_factory = getattr(fake_oci.auth.signers, factory)
signer_factory.assert_called_once_with()
kwargs = _client_kwargs(fake_oci)
assert kwargs["config"] == {}
assert kwargs["signer"] is signer_factory.return_value
fake_oci.config.from_file.assert_not_called()
def test_oci_genai_rejects_invalid_config():
with pytest.raises(ValidationError, match="auth_type"):
_create(auth_type="PASSWORD")
with pytest.raises(ValidationError, match="truncate"):
_create(truncate="MIDDLE")
def test_oci_genai_compartment_id_required(fake_oci, monkeypatch):
monkeypatch.delenv("OCI_COMPARTMENT_ID", raising=False)
func = get_registry().get("oci-genai").create(max_retries=0)
with pytest.raises(ValueError, match="OCI_COMPARTMENT_ID"):
func.compute_source_embeddings(["hello"])
assert _embed_requests(fake_oci) == []
monkeypatch.setenv("OCI_COMPARTMENT_ID", "ocid1.compartment.oc1..from-env")
func.compute_source_embeddings(["hello"])
assert _embed_requests(fake_oci)[0].compartment_id == (
"ocid1.compartment.oc1..from-env"
)
def test_oci_genai_request_shape(fake_oci):
func = _create(name="cohere.embed-multilingual-v3.0", truncate="START")
func.compute_source_embeddings(["hello"])
(details,) = _embed_requests(fake_oci)
assert details.inputs == ["hello"]
assert details.compartment_id == COMPARTMENT
assert details.truncate == "START"
assert details.input_type == "SEARCH_DOCUMENT"
assert details.serving_mode.serving_type == "ON_DEMAND"
assert details.serving_mode.model_id == "cohere.embed-multilingual-v3.0"
# only sent when configured so older SDKs / models keep working
assert not hasattr(details, "output_dimensions")
def test_oci_genai_dedicated_endpoint(fake_oci):
endpoint = "ocid1.generativeaiendpoint.oc1.us-chicago-1.test"
_create(name=endpoint).compute_source_embeddings(["hello"])
(details,) = _embed_requests(fake_oci)
assert details.serving_mode.serving_type == "DEDICATED"
assert details.serving_mode.endpoint_id == endpoint
def test_oci_genai_query_and_source_input_types(fake_oci):
func = _create()
func.compute_source_embeddings(["a document"])
func.compute_query_embeddings("a query")
source, query = _embed_requests(fake_oci)
assert source.input_type == "SEARCH_DOCUMENT"
assert query.input_type == "SEARCH_QUERY"
custom = _create(source_input_type="CLUSTERING", query_input_type="CLASSIFICATION")
custom.compute_source_embeddings(["a document"])
custom.compute_query_embeddings("a query")
source, query = _embed_requests(fake_oci)[2:]
assert source.input_type == "CLUSTERING"
assert query.input_type == "CLASSIFICATION"
def test_oci_genai_batches_at_service_limit(fake_oci):
assert EMBEDDING_BATCH_SIZE == 96
texts = [f"text {i:03d}" for i in range(2 * EMBEDDING_BATCH_SIZE + 8)]
embeddings = _create().compute_source_embeddings(texts)
requests = _embed_requests(fake_oci)
assert [len(r.inputs) for r in requests] == [96, 96, 8]
assert [t for r in requests for t in r.inputs] == texts
assert all(r.input_type == "SEARCH_DOCUMENT" for r in requests)
assert len(embeddings) == len(texts)
assert [e[0] for e in embeddings] == [float(len(t)) for t in texts]
def test_oci_genai_empty_inputs_are_skipped(fake_oci):
func = _create()
assert func.compute_source_embeddings([]) == []
assert _embed_requests(fake_oci) == []
embeddings = func.compute_source_embeddings(["hello", "", None, "world"])
(details,) = _embed_requests(fake_oci)
assert details.inputs == ["hello", "world"]
assert embeddings[1] is None
assert embeddings[2] is None
assert embeddings[0][0] == 5.0
assert embeddings[3][0] == 5.0
def test_oci_genai_ndims_probe_is_cached(fake_oci):
func = _create()
assert func.ndims() == DIMS
assert func.ndims() == DIMS
assert len(_embed_requests(fake_oci)) == 1
def test_oci_genai_output_dimensions(fake_oci):
func = _create(output_dimensions=512)
assert func.ndims() == 512
assert _embed_requests(fake_oci) == [] # no probe request needed
func.compute_source_embeddings(["hello"])
(details,) = _embed_requests(fake_oci)
assert details.output_dimensions == 512
@patch("time.sleep")
def test_oci_genai_auth_errors_are_not_retried(mock_sleep, fake_oci):
_client(fake_oci).embed_text.side_effect = FakeServiceError(
401, "NotAuthenticated", "The required information was not provided."
)
func = _create(max_retries=3)
with pytest.raises(OCIGenAIServiceError, match="HTTP 401") as exc_info:
func.compute_source_embeddings_with_retry(["hello"])
assert exc_info.value.status_code == 401
assert "oci session authenticate" in str(exc_info.value)
assert isinstance(exc_info.value.__cause__, FakeServiceError)
assert _client(fake_oci).embed_text.call_count == 1
mock_sleep.assert_not_called()
@patch("time.sleep")
def test_oci_genai_server_errors_are_retried(mock_sleep, fake_oci):
_client(fake_oci).embed_text.side_effect = FakeServiceError(
500, "InternalError", "try again"
)
func = _create(max_retries=2)
with pytest.raises(Exception, match="Maximum number of retries"):
func.compute_source_embeddings_with_retry(["hello"])
assert _client(fake_oci).embed_text.call_count == 3
def test_oci_genai_pickle_after_client_created(fake_oci):
func = _create(auth_profile="MYPROFILE")
func.compute_source_embeddings(["hello"])
assert "_client" in func.__dict__
restored = pickle.loads(pickle.dumps(func))
assert "_client" not in restored.__dict__
assert restored.compartment_id == COMPARTMENT
assert restored.auth_profile == "MYPROFILE"
def test_oci_genai_table_round_trip(fake_oci):
func = _create(region="us-ashburn-1", auth_profile="MYPROFILE")
class Docs(LanceModel):
text: str = func.SourceField()
vector: Vector(func.ndims()) = func.VectorField()
db = lancedb.connect("memory://")
tbl = db.create_table("docs", schema=Docs, mode="overwrite")
tbl.add([{"text": "hello"}, {"text": "goodbye world"}])
assert len(tbl.to_pandas()["vector"][0]) == DIMS
# the fake embeds len(text), so an equally long query lands on that row
hits = tbl.search("goodbye world").limit(1).to_pandas()
assert hits["text"][0] == "goodbye world"
assert _embed_requests(fake_oci)[-1].input_type == "SEARCH_QUERY"
parsed = tbl.embedding_functions["vector"].function
assert isinstance(parsed, OCIGenAIEmbeddings)
assert parsed.safe_model_dump() == {
"compartment_id": COMPARTMENT,
"max_retries": 0,
"region": "us-ashburn-1",
"auth_profile": "MYPROFILE",
}