Skip to content
Merged
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
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ policy.

## [Unreleased]

## [0.2.0b25] - 2026-05-30

### Fixed — explicit `wallet_password=""` dropped for auto-login wallets

Every Oracle pool builder converts `wallet_password` to
`pydantic.SecretStr` and gated forwarding it to
`oracledb.create_pool_async` with a truthy check
(`if cfg.wallet_password:`). In Pydantic v2 `SecretStr("")` is **falsy**,
so an explicit `wallet_password=""` — the documented python-oracledb
idiom for an auto-login (`cwallet.sso`) wallet — was silently dropped.
The thin driver then fell through to the encrypted `ewallet.pem` path
and failed at connection time with `DPY-6005` / `OSError: [Errno 22]`
(prompting for a PEM passphrase on a TTY); deployments using a real
vault-supplied wallet password were unaffected, which is why this only
bit auto-login setups (closes #289).

The guard is now `is not None` across all seven pool builders
(`memory/backends/oracle.py`, `memory/backends/oracle_versioned.py`,
`memory/store_backends/oracle.py`, `rag/embeddings/oracle_indb.py`,
`rag/chunkers/oracle_indb.py`, `rag/stores/oracle.py`,
`rag/loaders/oracle.py`): an omitted password (`None`) is still skipped,
while an explicit `""` is forwarded.

## [0.2.0b24] - 2026-05-28

### Fixed — OCI instance/resource-principal auth rots in long-lived processes
Expand Down Expand Up @@ -1594,7 +1617,8 @@ First internal-review version. Core shape established:
- Observability: OpenTelemetry spans and metrics, structured logging.
- Streaming: `AsyncIterator[LocusEvent]`, SSE, console handler.

[Unreleased]: https://github.com/oracle-samples/locus/compare/v0.2.0b10...main
[Unreleased]: https://github.com/oracle-samples/locus/compare/v0.2.0b25...main
[0.2.0b25]: https://github.com/oracle-samples/locus/compare/v0.2.0b24...v0.2.0b25
[0.2.0b10]: https://github.com/oracle-samples/locus/compare/v0.2.0b9...v0.2.0b10
[0.2.0b9]: https://github.com/oracle-samples/locus/compare/v0.2.0b7...v0.2.0b9
[0.2.0b8]: https://github.com/oracle-samples/locus/compare/v0.2.0b7...v0.2.0b9
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "locus-sdk"
version = "0.2.0b24"
version = "0.2.0b25"
description = "Multi-agent workflows for Python — stream them, branch them, pause for a human, resume next week. Built on Oracle Generative AI."
readme = "README.md"
license = "UPL-1.0"
Expand Down
101 changes: 58 additions & 43 deletions src/locus/rag/embeddings/oci.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,33 @@ class OCIEmbeddingConfig(BaseModel):
)
input_type: str = Field(
default="SEARCH_DOCUMENT",
description="Input type: SEARCH_DOCUMENT, SEARCH_QUERY, CLASSIFICATION, CLUSTERING",
description=(
"Input type for embed / embed_batch / embed_documents: "
"SEARCH_DOCUMENT, SEARCH_QUERY, CLASSIFICATION, CLUSTERING"
),
)
query_input_type: str = Field(
default="SEARCH_QUERY",
description=(
"Input type used by embed_query. Defaults to SEARCH_QUERY for "
"Cohere asymmetric retrieval (query and document embeddings live "
"in the same space but are optimised differently). Pin it to the "
"same value as input_type if a deployment ever ties output "
"dimension or handling to input_type. See issue #292."
),
)
output_dimensions: int | None = Field(
default=None,
description=(
"Explicit output embedding dimension (Cohere Matryoshka). "
"cohere.embed-v4.0 supports 256/512/1024/1536 and returns the "
"requested size; left unset, OCI's server-side default applies "
"(1536 for v4 today). Pin this to keep every embed / embed_query "
"/ embed_documents call — and the stored vector column — on the "
"same dimension regardless of the server default. Models without "
"Matryoshka support (e.g. embed-v3.0, fixed 1024) ignore it. "
"See issue #292."
),
)


Expand Down Expand Up @@ -151,7 +177,8 @@ def config(self) -> EmbeddingConfig:
default. The OCI Cohere family covers 384/1024/1536-dim variants.
"""
dimension = (
self._detected_dimension
self.oci_config.output_dimensions
or self._detected_dimension
or MODEL_DIMENSION_HINTS.get(self.oci_config.model_id)
or DEFAULT_DIMENSION
)
Expand Down Expand Up @@ -308,27 +335,38 @@ def _record_dimension(self, embeddings: list[list[float]]) -> None:
if first:
self._detected_dimension = len(first)

def _build_embed_details(self, inputs: list[str], input_type: str) -> Any:
"""Build an ``EmbedTextDetails`` shared by every embed path.

``output_dimensions`` is only attached when the caller set it, so
models without Matryoshka support never see the field. See #292.
"""
from oci.generative_ai_inference.models import (
EmbedTextDetails,
OnDemandServingMode,
)

kwargs: dict[str, Any] = {
"inputs": inputs,
"serving_mode": OnDemandServingMode(model_id=self.oci_config.model_id),
"compartment_id": self._get_compartment_id(),
"truncate": self.oci_config.truncate,
"input_type": input_type,
}
if self.oci_config.output_dimensions is not None:
kwargs["output_dimensions"] = self.oci_config.output_dimensions
return EmbedTextDetails(**kwargs)

async def embed(self, text: str) -> EmbeddingResult:
"""Embed a single text."""
results = await self.embed_batch([text])
return results[0]

async def embed_batch(self, texts: list[str]) -> list[EmbeddingResult]:
"""Embed multiple texts."""
from oci.generative_ai_inference.models import (
EmbedTextDetails,
OnDemandServingMode,
)

client = await self._get_client()

embed_details = EmbedTextDetails(
inputs=texts,
serving_mode=OnDemandServingMode(model_id=self.oci_config.model_id),
compartment_id=self._get_compartment_id(),
truncate=self.oci_config.truncate,
input_type=self.oci_config.input_type,
)
embed_details = self._build_embed_details(texts, self.oci_config.input_type)

response = client.embed_text(embed_details)
embeddings = response.data.embeddings
Expand All @@ -350,25 +388,13 @@ async def embed_batch(self, texts: list[str]) -> list[EmbeddingResult]:
async def embed_query(self, query: str) -> EmbeddingResult:
"""Embed a query for retrieval.

Uses SEARCH_QUERY input type for Cohere models.
Uses ``query_input_type`` (default ``SEARCH_QUERY``) instead of
hardcoding it, so callers can pin query and document embeddings to
the same input_type when needed. See issue #292.
"""
# Temporarily set input type for query
original_type = self.oci_config.input_type
# Note: Can't modify frozen config, so we handle this differently
from oci.generative_ai_inference.models import (
EmbedTextDetails,
OnDemandServingMode,
)

client = await self._get_client()

embed_details = EmbedTextDetails(
inputs=[query],
serving_mode=OnDemandServingMode(model_id=self.oci_config.model_id),
compartment_id=self._get_compartment_id(),
truncate=self.oci_config.truncate,
input_type="SEARCH_QUERY", # Query-specific
)
embed_details = self._build_embed_details([query], self.oci_config.query_input_type)

response = client.embed_text(embed_details)
self._record_dimension(response.data.embeddings)
Expand All @@ -383,13 +409,8 @@ async def embed_query(self, query: str) -> EmbeddingResult:
async def embed_documents(self, documents: list[str]) -> list[EmbeddingResult]:
"""Embed documents for storage.

Uses SEARCH_DOCUMENT input type for Cohere models.
Uses ``input_type`` (default ``SEARCH_DOCUMENT``) from config.
"""
from oci.generative_ai_inference.models import (
EmbedTextDetails,
OnDemandServingMode,
)

client = await self._get_client()

# Process in batches
Expand All @@ -399,13 +420,7 @@ async def embed_documents(self, documents: list[str]) -> list[EmbeddingResult]:
for i in range(0, len(documents), batch_size):
batch = documents[i : i + batch_size]

embed_details = EmbedTextDetails(
inputs=batch,
serving_mode=OnDemandServingMode(model_id=self.oci_config.model_id),
compartment_id=self._get_compartment_id(),
truncate=self.oci_config.truncate,
input_type="SEARCH_DOCUMENT", # Document-specific
)
embed_details = self._build_embed_details(batch, self.oci_config.input_type)

response = client.embed_text(embed_details)
self._record_dimension(response.data.embeddings)
Expand Down
54 changes: 53 additions & 1 deletion tests/unit/test_rag_embeddings_oci.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ def test_defaults(self) -> None:
assert cfg.model_id == OCIEmbeddingModel.COHERE_EMBED_ENGLISH_V3.value
assert cfg.profile_name == "DEFAULT"
assert cfg.input_type == "SEARCH_DOCUMENT"
assert cfg.query_input_type == "SEARCH_QUERY"
assert cfg.output_dimensions is None
assert cfg.truncate == "END"
assert cfg.compartment_id == ""

Expand Down Expand Up @@ -319,9 +321,19 @@ async def test_uses_search_query_input_type(self, monkeypatch: pytest.MonkeyPatc
probes = _stub_oci_modules(monkeypatch, embeddings=[[0.0] * 1024])
m = OCIEmbeddings()
await m.embed_query("what is locus")
# Last embed_text call uses ``SEARCH_QUERY``.
# Default ``query_input_type`` is ``SEARCH_QUERY``.
assert probes["embed_text_calls"][-1].input_type == "SEARCH_QUERY"

@pytest.mark.asyncio
async def test_query_input_type_is_configurable(self, monkeypatch: pytest.MonkeyPatch) -> None:
# issue #292: embed_query must honor query_input_type, not hardcode
# SEARCH_QUERY. Pinning it to SEARCH_DOCUMENT lets cohere.embed-v4.0
# queries match a SEARCH_DOCUMENT-indexed (1536-dim) table.
probes = _stub_oci_modules(monkeypatch, embeddings=[[0.0] * 1536])
m = OCIEmbeddings(query_input_type="SEARCH_DOCUMENT")
await m.embed_query("luxury hotels in Greece")
assert probes["embed_text_calls"][-1].input_type == "SEARCH_DOCUMENT"


class TestEmbedDocuments:
@pytest.mark.asyncio
Expand All @@ -331,6 +343,46 @@ async def test_uses_search_document_input_type(self, monkeypatch: pytest.MonkeyP
await m.embed_documents(["doc1"])
assert probes["embed_text_calls"][-1].input_type == "SEARCH_DOCUMENT"

@pytest.mark.asyncio
async def test_input_type_is_configurable(self, monkeypatch: pytest.MonkeyPatch) -> None:
# issue #292: embed_documents reads input_type from config rather than
# hardcoding SEARCH_DOCUMENT.
probes = _stub_oci_modules(monkeypatch, embeddings=[[0.0] * 1024])
m = OCIEmbeddings(input_type="CLASSIFICATION")
await m.embed_documents(["doc1"])
assert probes["embed_text_calls"][-1].input_type == "CLASSIFICATION"


class TestOutputDimensions:
"""issue #292: Cohere v4 is Matryoshka — output_dimensions controls the
returned dimension. Locus must let callers pin it so every embed path
(and the stored vector column) stays on one dimension regardless of OCI's
server-side default."""

@pytest.mark.asyncio
async def test_omitted_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Unset → field never attached, so non-Matryoshka models never see it.
probes = _stub_oci_modules(monkeypatch, embeddings=[[0.0] * 1536])
m = OCIEmbeddings(model_id="cohere.embed-v4.0")
await m.embed("hello")
assert not hasattr(probes["embed_text_calls"][-1], "output_dimensions")

@pytest.mark.asyncio
async def test_forwarded_on_every_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
probes = _stub_oci_modules(monkeypatch, embeddings=[[0.0] * 1024])
m = OCIEmbeddings(model_id="cohere.embed-v4.0", output_dimensions=1024)
await m.embed("a")
await m.embed_query("q")
await m.embed_documents(["d"])
# embed (via embed_batch), embed_query, embed_documents all pin 1024.
assert [c.output_dimensions for c in probes["embed_text_calls"]] == [1024, 1024, 1024]

def test_config_dimension_reflects_output_dimensions(self) -> None:
# The vector store sizes its column from config.dimension; an explicit
# output_dimensions must win over the model hint (v4 hint is 1536).
m = OCIEmbeddings(model_id="cohere.embed-v4.0", output_dimensions=1024)
assert m.config.dimension == 1024

@pytest.mark.asyncio
async def test_batches_when_inputs_exceed_batch_size(
self, monkeypatch: pytest.MonkeyPatch
Expand Down