From 91e1f70022eeaf46ddb819d614e89a6d9c9068c8 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 30 May 2026 08:02:47 -0400 Subject: [PATCH 1/2] =?UTF-8?q?chore(release):=20v0.2.0b25=20=E2=80=94=20O?= =?UTF-8?q?racle=20auto-login=20wallet=5Fpassword=3D""=20fix=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps locus-sdk to 0.2.0b25. Fixes a Pydantic v2 regression where an explicit wallet_password="" (the python-oracledb auto-login / cwallet.sso idiom) was silently dropped by a truthy SecretStr guard, forcing the encrypted ewallet.pem path and failing at connect with DPY-6005 / OSError: [Errno 22]. The guard is now `is not None` across all seven Oracle pool builders (closes #289). Signed-off-by: Federico Kamelhar --- CHANGELOG.md | 26 +++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 840d1a7..5bbda68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 652f8a9..f0af2fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From 879f814afe943da5a348e332138e09ad62314e81 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 30 May 2026 08:42:00 -0400 Subject: [PATCH 2/2] fix(rag): make OCI embedding dimension + input_type deterministic (closes #292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCIEmbeddings.embed_query() hardcoded input_type="SEARCH_QUERY" (ignoring config) and embed_documents() hardcoded "SEARCH_DOCUMENT"; embed_query also carried a dead `original_type` local and a misleading "frozen config" comment. More importantly, the real dimension-mismatch trigger behind the report is Cohere v4's Matryoshka output_dimensions, which Locus never set. cohere.embed- v4.0 returns exactly the requested size (256/512/1024/1536) and OCI's server-side default (1536 today) applies otherwise — so a table indexed at one output_dimensions and queried at another raises ORA-51803. input_type does NOT change the output dimension (verified live: v4 returns 1536 for both SEARCH_QUERY and SEARCH_DOCUMENT; v3 returns 1024 for both). Changes: - Add OCIEmbeddingConfig.output_dimensions (default None). Forwarded to EmbedTextDetails on every embed path via a shared _build_embed_details helper, and only when set, so non-Matryoshka models never see the field. - config.dimension now prefers output_dimensions, so the vector column is sized to match the pinned embedding dimension. - Add OCIEmbeddingConfig.query_input_type (default SEARCH_QUERY) and read input_type from config in embed_documents; removes the hardcodes and the dead/misleading code in embed_query. Tests: query_input_type / input_type configurability, output_dimensions omitted-by-default + forwarded on all three paths, and config.dimension reflecting output_dimensions. Signed-off-by: Federico Kamelhar --- src/locus/rag/embeddings/oci.py | 101 +++++++++++++++----------- tests/unit/test_rag_embeddings_oci.py | 54 +++++++++++++- 2 files changed, 111 insertions(+), 44 deletions(-) diff --git a/src/locus/rag/embeddings/oci.py b/src/locus/rag/embeddings/oci.py index 77111da..75630fe 100644 --- a/src/locus/rag/embeddings/oci.py +++ b/src/locus/rag/embeddings/oci.py @@ -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." + ), ) @@ -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 ) @@ -308,6 +335,28 @@ 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]) @@ -315,20 +364,9 @@ async def embed(self, text: str) -> EmbeddingResult: 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 @@ -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) @@ -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 @@ -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) diff --git a/tests/unit/test_rag_embeddings_oci.py b/tests/unit/test_rag_embeddings_oci.py index 154569b..dfdd4ef 100644 --- a/tests/unit/test_rag_embeddings_oci.py +++ b/tests/unit/test_rag_embeddings_oci.py @@ -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 == "" @@ -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 @@ -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