From 02cfd59141aecabae75a6e00884e0685b365b75e Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Fri, 28 Aug 2026 14:02:25 +0800 Subject: [PATCH 1/3] fix: pass embedding dimension to provider and surface sqlite-vec probe failures - send the configured embedding profile dimension via Embedder settings instead of letting providers return their default dimension - clear a leftover probe row before the sqlite-vec vector index probe so an interrupted run cannot poison subsequent startups - include the underlying error, with a dimension-mismatch hint, in the CapabilityNotSupportedError raised by the probe - log embedding readiness probe failures before marking the capability unavailable Fixes #1385 --- .../persistence/sqlite/memory_index.py | 18 ++- .../builtin/runtime/composition.py | 15 ++- .../test_sqlite_memory_vector_index.py | 109 ++++++++++++++++++ .../runtime/test_composition_embedding.py | 62 ++++++++++ 4 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 tests/builtin/persistence/test_sqlite_memory_vector_index.py create mode 100644 tests/builtin/runtime/test_composition_embedding.py diff --git a/src/powercontext/builtin/persistence/sqlite/memory_index.py b/src/powercontext/builtin/persistence/sqlite/memory_index.py index 36c22e162..10a99d7e8 100644 --- a/src/powercontext/builtin/persistence/sqlite/memory_index.py +++ b/src/powercontext/builtin/persistence/sqlite/memory_index.py @@ -327,6 +327,9 @@ async def initialize(self, connection: AsyncConnection, /) -> None: f"USING vec0(embedding float[{self.profile.dimension}])" ) probe = _pack_vector((0.0,) * self.profile.dimension) + # A previous run may have been interrupted between inserting and deleting + # the probe row; clear any leftover before probing again. + await connection.execute(_DELETE_VECTOR_SQL, {"vector_id": -1}) await connection.execute( _INSERT_VECTOR_SQL, {"vector_id": -1, "embedding": probe}, @@ -339,7 +342,7 @@ async def initialize(self, connection: AsyncConnection, /) -> None: ).one_or_none() await connection.execute(_DELETE_VECTOR_SQL, {"vector_id": -1}) except SQLAlchemyError as error: - raise CapabilityNotSupportedError("vector", "sqlite-vec probe failed") from error + raise CapabilityNotSupportedError("vector", _probe_failure_detail(error, self.profile.dimension)) from error if row is None or int(row[0]) != -1: raise CapabilityNotSupportedError("vector", "sqlite-vec probe returned an invalid row") @@ -525,6 +528,19 @@ async def hydrate( return tuple(hydrated) +def _probe_failure_detail(error: SQLAlchemyError, dimension: int) -> str: + orig = getattr(error, "orig", None) + detail = str(orig) if orig is not None else str(error) + normalized = detail.casefold() + if "dimension" in normalized: + return ( + "sqlite-vec probe failed: the existing pc_memory_entry_vec table dimension does not match " + f"the configured embedding profile dimension {dimension}; migrate the table or align the " + f"embedding dimension configuration ({detail})" + ) + return f"sqlite-vec probe failed: {detail}" + + def _pack_vector(vector: tuple[float, ...]) -> bytes: return struct.pack(f"={len(vector)}f", *vector) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 7c623b068..adf0eba3b 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -16,6 +16,7 @@ from __future__ import annotations +import logging from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path @@ -80,6 +81,8 @@ ValueT = TypeVar("ValueT") +logger = logging.getLogger(__name__) + class BuiltinConfigurationError(RuntimeError): """Report a configuration that cannot assemble the built-in runtime.""" @@ -562,7 +565,11 @@ def provider_factory(provider_name: str) -> Provider[object]: def adapter(instrument: InstrumentationSettings | bool | None) -> EmbeddingModel: return PydanticAIEmbeddingModel( - embedder=Embedder(model, instrument=instrument), + embedder=Embedder( + model, + settings={"dimensions": profile.dimension}, + instrument=instrument, + ), batch_size=settings.embedding_batch_size, profile=profile, limits=limits, @@ -575,7 +582,11 @@ def adapter(instrument: InstrumentationSettings | bool | None) -> EmbeddingModel def _embedding_readiness_probe(model: EmbeddingModel) -> ReadinessProbe: async def probe_embedding() -> None: - await model.embed(("PowerContext readiness probe",)) + try: + await model.embed(("PowerContext readiness probe",)) + except Exception: + logger.exception("embedding readiness probe failed; marking inference.embedding unavailable") + raise return CachedReadinessProbe(dependency_readiness_probe(probe_embedding)) diff --git a/tests/builtin/persistence/test_sqlite_memory_vector_index.py b/tests/builtin/persistence/test_sqlite_memory_vector_index.py new file mode 100644 index 000000000..cff90aada --- /dev/null +++ b/tests/builtin/persistence/test_sqlite_memory_vector_index.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio + +import pytest + +from powercontext.builtin.artifacts.memory import CapabilityNotSupportedError, EmbeddingProfile +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.sqlite.memory_index import ( + SQLITE_MEMORY_VECTOR_TABLES, + SQLiteMemoryVectorIndex, + _INSERT_VECTOR_SQL, + _pack_vector, +) + + +def _profile(dimension: int) -> EmbeddingProfile: + return EmbeddingProfile( + profile_id="test-v1", + model="test", + dimension=dimension, + distance="l2", + normalization="unit", + ) + + +def test_vector_index_probe_clears_a_leftover_probe_row(tmp_path) -> None: + async def scenario() -> None: + async with SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), + tables=SQLITE_MEMORY_VECTOR_TABLES, + load_vector_extension=True, + ) as profile: + async with profile.database.transaction() as connection: + await connection.exec_driver_sql( + "CREATE VIRTUAL TABLE pc_memory_entry_vec USING vec0(embedding float[3])" + ) + await connection.execute( + _INSERT_VECTOR_SQL, + {"vector_id": -1, "embedding": _pack_vector((0.0, 0.0, 0.0))}, + ) + await SQLiteMemoryVectorIndex(_profile(3)).initialize(connection) + leftover = ( + await connection.exec_driver_sql( + "SELECT count(*) FROM pc_memory_entry_vec WHERE rowid = -1" + ) + ).scalar() + assert int(leftover) == 0 + + asyncio.run(scenario()) + + +def test_vector_index_probe_reports_a_table_dimension_mismatch(tmp_path) -> None: + async def scenario() -> None: + async with SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), + tables=SQLITE_MEMORY_VECTOR_TABLES, + load_vector_extension=True, + ) as profile: + async with profile.database.transaction() as connection: + await connection.exec_driver_sql( + "CREATE VIRTUAL TABLE pc_memory_entry_vec USING vec0(embedding float[4])" + ) + index = SQLiteMemoryVectorIndex(_profile(3)) + with pytest.raises(CapabilityNotSupportedError, match=r"dimension") as exc_info: + await index.initialize(connection) + message = str(exc_info.value) + assert "4" in message + assert "3" in message + assert "capability is not supported: vector" in message + assert isinstance(exc_info.value.__cause__, Exception) + + asyncio.run(scenario()) + + +def test_vector_index_probe_surfaces_the_underlying_cause(tmp_path) -> None: + async def scenario() -> None: + async with SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), + tables=SQLITE_MEMORY_VECTOR_TABLES, + load_vector_extension=True, + ) as profile: + async with profile.database.transaction() as connection: + await connection.exec_driver_sql( + "CREATE TABLE pc_memory_entry_vec (rowid INTEGER PRIMARY KEY, embedding BLOB)" + ) + index = SQLiteMemoryVectorIndex(_profile(3)) + with pytest.raises(CapabilityNotSupportedError, match=r"sqlite-vec probe failed") as exc_info: + await index.initialize(connection) + cause = exc_info.value.__cause__ + assert cause is not None + assert str(cause) not in ("", "None") + assert "sqlite-vec probe failed:" in str(exc_info.value) + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_composition_embedding.py b/tests/builtin/runtime/test_composition_embedding.py new file mode 100644 index 000000000..4285d7972 --- /dev/null +++ b/tests/builtin/runtime/test_composition_embedding.py @@ -0,0 +1,62 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from contextlib import AsyncExitStack + +import pytest +from pydantic_ai import Embedder + +from powercontext.builtin.runtime.composition import _embedding_models +from powercontext.builtin.runtime.config import InferenceConfig + + +class _SpyEmbedder(Embedder): + settings_seen: list[dict[str, int] | None] = [] + + def __init__(self, model, *, settings=None, defer_model_check=True, instrument=None): + super().__init__(model, settings=settings, defer_model_check=defer_model_check, instrument=instrument) + type(self).settings_seen.append(settings) + + +def test_embedding_models_send_the_configured_dimension_to_the_provider(monkeypatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setattr("pydantic_ai.Embedder", _SpyEmbedder) + _SpyEmbedder.settings_seen = [] + + async def scenario() -> None: + config = InferenceConfig( + embedding_model="openai:text-embedding-3-small", + embedding_profile_id="bailian-1536-v1", + embedding_dimension=1536, + ) + async with AsyncExitStack() as resources: + operational, readiness = await _embedding_models(config, resources, None) + assert operational is not None + assert readiness is not None + assert _SpyEmbedder.settings_seen == [{"dimensions": 1536}, {"dimensions": 1536}] + + asyncio.run(scenario()) + + +def test_embedding_models_without_configuration_return_no_models() -> None: + async def scenario() -> None: + async with AsyncExitStack() as resources: + operational, readiness = await _embedding_models(InferenceConfig(), resources, None) + assert operational is None + assert readiness is None + + asyncio.run(scenario()) From 5b5c5d70f0911eb37ed23aac4968085b5dea1cb5 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Fri, 28 Aug 2026 14:10:01 +0800 Subject: [PATCH 2/3] fix: drop readiness probe logging to keep provider errors redacted The server test suite intentionally asserts that provider error messages never reach the logs (see the redaction tests in tests/test_server.py), so the readiness probe stays silent; root causes are surfaced through the hard-failure CapabilityNotSupportedError detail instead. Also annotate the spy state as ClassVar and apply ruff formatting. --- .../builtin/runtime/composition.py | 9 +- .../test_sqlite_memory_vector_index.py | 108 +++++++++--------- .../runtime/test_composition_embedding.py | 4 +- 3 files changed, 57 insertions(+), 64 deletions(-) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index adf0eba3b..b74668587 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -16,7 +16,6 @@ from __future__ import annotations -import logging from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path @@ -81,8 +80,6 @@ ValueT = TypeVar("ValueT") -logger = logging.getLogger(__name__) - class BuiltinConfigurationError(RuntimeError): """Report a configuration that cannot assemble the built-in runtime.""" @@ -582,11 +579,7 @@ def adapter(instrument: InstrumentationSettings | bool | None) -> EmbeddingModel def _embedding_readiness_probe(model: EmbeddingModel) -> ReadinessProbe: async def probe_embedding() -> None: - try: - await model.embed(("PowerContext readiness probe",)) - except Exception: - logger.exception("embedding readiness probe failed; marking inference.embedding unavailable") - raise + await model.embed(("PowerContext readiness probe",)) return CachedReadinessProbe(dependency_readiness_probe(probe_embedding)) diff --git a/tests/builtin/persistence/test_sqlite_memory_vector_index.py b/tests/builtin/persistence/test_sqlite_memory_vector_index.py index cff90aada..16ba78905 100644 --- a/tests/builtin/persistence/test_sqlite_memory_vector_index.py +++ b/tests/builtin/persistence/test_sqlite_memory_vector_index.py @@ -21,9 +21,9 @@ from powercontext.builtin.artifacts.memory import CapabilityNotSupportedError, EmbeddingProfile from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.sqlite.memory_index import ( + _INSERT_VECTOR_SQL, SQLITE_MEMORY_VECTOR_TABLES, SQLiteMemoryVectorIndex, - _INSERT_VECTOR_SQL, _pack_vector, ) @@ -40,70 +40,70 @@ def _profile(dimension: int) -> EmbeddingProfile: def test_vector_index_probe_clears_a_leftover_probe_row(tmp_path) -> None: async def scenario() -> None: - async with SQLiteProfile.open( - SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), - tables=SQLITE_MEMORY_VECTOR_TABLES, - load_vector_extension=True, - ) as profile: - async with profile.database.transaction() as connection: - await connection.exec_driver_sql( - "CREATE VIRTUAL TABLE pc_memory_entry_vec USING vec0(embedding float[3])" - ) - await connection.execute( - _INSERT_VECTOR_SQL, - {"vector_id": -1, "embedding": _pack_vector((0.0, 0.0, 0.0))}, - ) - await SQLiteMemoryVectorIndex(_profile(3)).initialize(connection) - leftover = ( - await connection.exec_driver_sql( - "SELECT count(*) FROM pc_memory_entry_vec WHERE rowid = -1" - ) - ).scalar() - assert int(leftover) == 0 + async with ( + SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), + tables=SQLITE_MEMORY_VECTOR_TABLES, + load_vector_extension=True, + ) as profile, + profile.database.transaction() as connection, + ): + await connection.exec_driver_sql("CREATE VIRTUAL TABLE pc_memory_entry_vec USING vec0(embedding float[3])") + await connection.execute( + _INSERT_VECTOR_SQL, + {"vector_id": -1, "embedding": _pack_vector((0.0, 0.0, 0.0))}, + ) + await SQLiteMemoryVectorIndex(_profile(3)).initialize(connection) + leftover = ( + await connection.exec_driver_sql("SELECT count(*) FROM pc_memory_entry_vec WHERE rowid = -1") + ).scalar() + assert int(leftover) == 0 asyncio.run(scenario()) def test_vector_index_probe_reports_a_table_dimension_mismatch(tmp_path) -> None: async def scenario() -> None: - async with SQLiteProfile.open( - SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), - tables=SQLITE_MEMORY_VECTOR_TABLES, - load_vector_extension=True, - ) as profile: - async with profile.database.transaction() as connection: - await connection.exec_driver_sql( - "CREATE VIRTUAL TABLE pc_memory_entry_vec USING vec0(embedding float[4])" - ) - index = SQLiteMemoryVectorIndex(_profile(3)) - with pytest.raises(CapabilityNotSupportedError, match=r"dimension") as exc_info: - await index.initialize(connection) - message = str(exc_info.value) - assert "4" in message - assert "3" in message - assert "capability is not supported: vector" in message - assert isinstance(exc_info.value.__cause__, Exception) + async with ( + SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), + tables=SQLITE_MEMORY_VECTOR_TABLES, + load_vector_extension=True, + ) as profile, + profile.database.transaction() as connection, + ): + await connection.exec_driver_sql("CREATE VIRTUAL TABLE pc_memory_entry_vec USING vec0(embedding float[4])") + index = SQLiteMemoryVectorIndex(_profile(3)) + with pytest.raises(CapabilityNotSupportedError, match=r"dimension") as exc_info: + await index.initialize(connection) + message = str(exc_info.value) + assert "4" in message + assert "3" in message + assert "capability is not supported: vector" in message + assert isinstance(exc_info.value.__cause__, Exception) asyncio.run(scenario()) def test_vector_index_probe_surfaces_the_underlying_cause(tmp_path) -> None: async def scenario() -> None: - async with SQLiteProfile.open( - SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), - tables=SQLITE_MEMORY_VECTOR_TABLES, - load_vector_extension=True, - ) as profile: - async with profile.database.transaction() as connection: - await connection.exec_driver_sql( - "CREATE TABLE pc_memory_entry_vec (rowid INTEGER PRIMARY KEY, embedding BLOB)" - ) - index = SQLiteMemoryVectorIndex(_profile(3)) - with pytest.raises(CapabilityNotSupportedError, match=r"sqlite-vec probe failed") as exc_info: - await index.initialize(connection) - cause = exc_info.value.__cause__ - assert cause is not None - assert str(cause) not in ("", "None") - assert "sqlite-vec probe failed:" in str(exc_info.value) + async with ( + SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), + tables=SQLITE_MEMORY_VECTOR_TABLES, + load_vector_extension=True, + ) as profile, + profile.database.transaction() as connection, + ): + await connection.exec_driver_sql( + "CREATE TABLE pc_memory_entry_vec (rowid INTEGER PRIMARY KEY, embedding BLOB)" + ) + index = SQLiteMemoryVectorIndex(_profile(3)) + with pytest.raises(CapabilityNotSupportedError, match=r"sqlite-vec probe failed") as exc_info: + await index.initialize(connection) + cause = exc_info.value.__cause__ + assert cause is not None + assert str(cause) not in ("", "None") + assert "sqlite-vec probe failed:" in str(exc_info.value) asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_composition_embedding.py b/tests/builtin/runtime/test_composition_embedding.py index 4285d7972..f4ce4f916 100644 --- a/tests/builtin/runtime/test_composition_embedding.py +++ b/tests/builtin/runtime/test_composition_embedding.py @@ -16,8 +16,8 @@ import asyncio from contextlib import AsyncExitStack +from typing import ClassVar -import pytest from pydantic_ai import Embedder from powercontext.builtin.runtime.composition import _embedding_models @@ -25,7 +25,7 @@ class _SpyEmbedder(Embedder): - settings_seen: list[dict[str, int] | None] = [] + settings_seen: ClassVar[list[dict[str, int] | None]] = [] def __init__(self, model, *, settings=None, defer_model_check=True, instrument=None): super().__init__(model, settings=settings, defer_model_check=defer_model_check, instrument=instrument) From 6229cb9732952142ea2368e5b520bde21a027338 Mon Sep 17 00:00:00 2001 From: happy-v587 Date: Fri, 28 Aug 2026 15:49:23 +0800 Subject: [PATCH 3/3] fix: surface stable redacted readiness reasons from probe failures Address review feedback on PR #1386: - dependency_readiness_probe now exposes the stable code/detail carried by InferenceConfigurationError (e.g. 'misconfigured: provider-rejected (HTTP 400)') so the Server and doctor explain what was rejected, while plain configuration errors stay redacted as 'misconfigured'. - _map_error attaches the structured HTTP status to provider-rejected failures; the raw provider response body is never included. - _probe_failure_detail only emits the existing-table migration hint after confirming a pre-existing vec0 table with a different dimension; a fresh oversized dimension now preserves the actual probe error (e.g. the sqlite-vec 8192 maximum). --- .../builtin/inference/pydantic_ai.py | 8 +++-- .../persistence/sqlite/memory_index.py | 33 +++++++++++++++---- src/powercontext/builtin/runtime/__init__.py | 4 +++ src/powercontext/builtin/runtime/readiness.py | 30 ++++++++++++----- src/powercontext/server/factory.py | 2 +- tests/builtin/inference/test_pydantic_ai.py | 18 ++++++++++ .../test_sqlite_memory_vector_index.py | 21 ++++++++++++ tests/builtin/runtime/test_readiness.py | 27 +++++++++++++++ tests/test_server.py | 27 ++++++++++++++- 9 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/powercontext/builtin/inference/pydantic_ai.py b/src/powercontext/builtin/inference/pydantic_ai.py index 9e792185e..d12f41979 100644 --- a/src/powercontext/builtin/inference/pydantic_ai.py +++ b/src/powercontext/builtin/inference/pydantic_ai.py @@ -60,7 +60,11 @@ def __init__(self, code: str, detail: object | None = None) -> None: "provider-rejected": "provider rejected the configured Pydantic AI request", "pydantic-rejected": "Pydantic AI rejected the configured request", } - super().__init__(messages.get(code, f"Pydantic AI adapter is not configured correctly: {code}")) + message = messages.get(code, f"Pydantic AI adapter is not configured correctly: {code}") + if code == "provider-rejected" and detail is not None: + # The detail is structured (e.g. "HTTP 400"), never the raw provider response body. + message = f"{message} ({detail})" + super().__init__(message) try: @@ -316,7 +320,7 @@ def _map_error( return InferenceTimeoutError(operation, timeout_seconds) if error.status_code in {409, 425, 429} or error.status_code >= 500: return InferenceUnavailableError(operation) - return PydanticAIConfigurationError("provider-rejected") + return PydanticAIConfigurationError("provider-rejected", detail=f"HTTP {error.status_code}") if isinstance(error, (ModelAPIError, ConcurrencyLimitExceeded, OSError)): return InferenceUnavailableError(operation) if isinstance(error, (UnexpectedModelBehavior, UsageLimitExceeded, ValidationError)): diff --git a/src/powercontext/builtin/persistence/sqlite/memory_index.py b/src/powercontext/builtin/persistence/sqlite/memory_index.py index 10a99d7e8..4577a57a4 100644 --- a/src/powercontext/builtin/persistence/sqlite/memory_index.py +++ b/src/powercontext/builtin/persistence/sqlite/memory_index.py @@ -19,6 +19,7 @@ import json import struct from collections.abc import Mapping +from re import search from typing import Any from sqlalchemy import ( @@ -342,7 +343,8 @@ async def initialize(self, connection: AsyncConnection, /) -> None: ).one_or_none() await connection.execute(_DELETE_VECTOR_SQL, {"vector_id": -1}) except SQLAlchemyError as error: - raise CapabilityNotSupportedError("vector", _probe_failure_detail(error, self.profile.dimension)) from error + detail = await _probe_failure_detail(connection, error, self.profile.dimension) + raise CapabilityNotSupportedError("vector", detail) from error if row is None or int(row[0]) != -1: raise CapabilityNotSupportedError("vector", "sqlite-vec probe returned an invalid row") @@ -528,19 +530,36 @@ async def hydrate( return tuple(hydrated) -def _probe_failure_detail(error: SQLAlchemyError, dimension: int) -> str: +async def _probe_failure_detail(connection: AsyncConnection, error: SQLAlchemyError, dimension: int) -> str: orig = getattr(error, "orig", None) detail = str(orig) if orig is not None else str(error) - normalized = detail.casefold() - if "dimension" in normalized: + existing = await _existing_vec_dimension(connection) + if existing is not None and existing != dimension: return ( - "sqlite-vec probe failed: the existing pc_memory_entry_vec table dimension does not match " - f"the configured embedding profile dimension {dimension}; migrate the table or align the " - f"embedding dimension configuration ({detail})" + "sqlite-vec probe failed: the existing pc_memory_entry_vec table dimension " + f"{existing} does not match the configured embedding profile dimension {dimension}; " + f"migrate the table or align the embedding dimension configuration ({detail})" ) return f"sqlite-vec probe failed: {detail}" +async def _existing_vec_dimension(connection: AsyncConnection) -> int | None: + """Return the dimension of a pre-existing vec0 table, or None when it cannot be confirmed.""" + + try: + row = ( + await connection.exec_driver_sql( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pc_memory_entry_vec'" + ) + ).one_or_none() + except SQLAlchemyError: + return None + if row is None: + return None + match = search(r"float\[(\d+)\]", str(row[0])) + return int(match.group(1)) if match is not None else None + + def _pack_vector(vector: tuple[float, ...]) -> bytes: return struct.pack(f"={len(vector)}f", *vector) diff --git a/src/powercontext/builtin/runtime/__init__.py b/src/powercontext/builtin/runtime/__init__.py index c13587b1b..ef7eba4e3 100644 --- a/src/powercontext/builtin/runtime/__init__.py +++ b/src/powercontext/builtin/runtime/__init__.py @@ -123,11 +123,13 @@ ) from powercontext.builtin.runtime.protocols import PowerContextProvider from powercontext.builtin.runtime.readiness import ( + CachedReadinessProbe, ReadinessCheckStatus, ReadinessProbeDefinition, RuntimeReadiness, RuntimeReadinessChecks, RuntimeReadinessStatus, + dependency_readiness_probe, ) from powercontext.builtin.statistics import ( ArtifactInventoryStatistics, @@ -162,6 +164,7 @@ "BuiltinConfig", "BuiltinConfigurationError", "BuiltinRuntime", + "CachedReadinessProbe", "CandidateFamilyCount", "CandidateInventoryStatistics", "CaptureSource", @@ -278,6 +281,7 @@ "StatisticsPeriod", "UsageStatistics", "WorkApplication", + "dependency_readiness_probe", "open_builtin_contexts", "open_builtin_runtime", ] diff --git a/src/powercontext/builtin/runtime/readiness.py b/src/powercontext/builtin/runtime/readiness.py index dbbfb3d49..fc9b8c15d 100644 --- a/src/powercontext/builtin/runtime/readiness.py +++ b/src/powercontext/builtin/runtime/readiness.py @@ -41,7 +41,7 @@ class ReadinessCheckStatus(StrEnum): MISCONFIGURED = "misconfigured" -ReadinessProbe = Callable[[], Awaitable[ReadinessCheckStatus]] +ReadinessProbe = Callable[[], Awaitable[str]] class RuntimeReadinessStatus(StrEnum): @@ -65,7 +65,7 @@ class RuntimeReadiness: """Aggregate the safe readiness outcomes for one Runtime.""" status: RuntimeReadinessStatus - checks: Mapping[str, ReadinessCheckStatus] + checks: Mapping[str, str] @property def ready(self) -> bool: @@ -98,7 +98,7 @@ async def run(self) -> RuntimeReadiness: return RuntimeReadiness(status=status, checks=checks) @staticmethod - async def _run(probe: ReadinessProbe) -> ReadinessCheckStatus: + async def _run(probe: ReadinessProbe) -> str: try: return await probe() except asyncio.CancelledError: @@ -123,10 +123,10 @@ def __init__( self._transient_ttl_seconds = transient_ttl_seconds self._clock = monotonic if clock is None else clock self._lock = asyncio.Lock() - self._result: ReadinessCheckStatus | None = None + self._result: str | None = None self._expires_at = 0.0 - async def __call__(self) -> ReadinessCheckStatus: + async def __call__(self) -> str: """Return a fresh cached result, refreshing it at most once.""" result = self._fresh_result() @@ -146,7 +146,7 @@ async def __call__(self) -> ReadinessCheckStatus: self._expires_at = self._clock() + ttl_seconds return result - def _fresh_result(self) -> ReadinessCheckStatus | None: + def _fresh_result(self) -> str | None: return self._result if self._result is not None and self._clock() < self._expires_at else None @@ -157,13 +157,13 @@ def dependency_readiness_probe( ) -> ReadinessProbe: """Convert one dependency operation into a bounded, redacted probe.""" - async def probe() -> ReadinessCheckStatus: + async def probe() -> str: try: await asyncio.wait_for(operation(), timeout=timeout_seconds) except asyncio.CancelledError: raise - except InferenceConfigurationError: - return ReadinessCheckStatus.MISCONFIGURED + except InferenceConfigurationError as error: + return _misconfigured_check(error) except TimeoutError: return ReadinessCheckStatus.TIMEOUT except Exception: @@ -173,6 +173,18 @@ async def probe() -> ReadinessCheckStatus: return probe +def _misconfigured_check(error: InferenceConfigurationError) -> str: + """Expose the stable redacted reason carried by the error, never its message.""" + + code = getattr(error, "code", None) + if not isinstance(code, str) or not code: + return ReadinessCheckStatus.MISCONFIGURED.value + detail = getattr(error, "detail", None) + if isinstance(detail, str) and detail: + return f"{ReadinessCheckStatus.MISCONFIGURED.value}: {code} ({detail})" + return f"{ReadinessCheckStatus.MISCONFIGURED.value}: {code}" + + __all__ = [ "READINESS_PROBE_CACHE_SECONDS", "READINESS_PROBE_TIMEOUT_SECONDS", diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index c3b77933b..563b0ab4c 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -244,7 +244,7 @@ async def _check(self, runtime: BuiltinRuntime) -> ReadinessResponse: ) return ReadinessResponse( status=ReadinessStatus(readiness.status.value), - checks={name: status.value for name, status in readiness.checks.items()}, + checks={name: str(status) for name, status in readiness.checks.items()}, ) def _observe(self, status: ReadinessStatus) -> None: diff --git a/tests/builtin/inference/test_pydantic_ai.py b/tests/builtin/inference/test_pydantic_ai.py index eb6d8b0af..b2c205a86 100644 --- a/tests/builtin/inference/test_pydantic_ai.py +++ b/tests/builtin/inference/test_pydantic_ai.py @@ -409,6 +409,24 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_embedding_adapter_maps_a_rejected_request_to_a_stable_reason_without_leaking_body() -> None: + async def scenario() -> None: + provider_error = ModelHTTPError(400, "result-model", {"error": {"message": "secret provider body"}}) + adapter = PydanticAIEmbeddingModel( + embedder=Embedder(ResultEmbeddingModel((), error=provider_error)), + profile=TEST_PROFILE, + ) + + with pytest.raises(PydanticAIConfigurationError) as error: + await adapter.embed(("bounded text",)) + assert error.value.code == "provider-rejected" + assert error.value.detail == "HTTP 400" + assert "HTTP 400" in str(error.value) + assert "secret provider body" not in str(error.value) + + asyncio.run(scenario()) + + def test_instrumented_embedding_spans_nest_under_the_active_span_without_recording_text() -> None: exporter = InMemorySpanExporter() provider = TracerProvider(shutdown_on_exit=False) diff --git a/tests/builtin/persistence/test_sqlite_memory_vector_index.py b/tests/builtin/persistence/test_sqlite_memory_vector_index.py index 16ba78905..73dfdb150 100644 --- a/tests/builtin/persistence/test_sqlite_memory_vector_index.py +++ b/tests/builtin/persistence/test_sqlite_memory_vector_index.py @@ -107,3 +107,24 @@ async def scenario() -> None: assert "sqlite-vec probe failed:" in str(exc_info.value) asyncio.run(scenario()) + + +def test_vector_index_probe_reports_the_provider_limit_for_a_fresh_oversized_dimension(tmp_path) -> None: + async def scenario() -> None: + async with ( + SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'memory.db'}"), + tables=SQLITE_MEMORY_VECTOR_TABLES, + load_vector_extension=True, + ) as profile, + profile.database.transaction() as connection, + ): + index = SQLiteMemoryVectorIndex(_profile(65536)) + with pytest.raises(CapabilityNotSupportedError, match=r"sqlite-vec probe failed") as exc_info: + await index.initialize(connection) + message = str(exc_info.value) + assert "migrate" not in message + assert "8192" in message + assert "65536" in message + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_readiness.py b/tests/builtin/runtime/test_readiness.py index f374367ce..69585bb0e 100644 --- a/tests/builtin/runtime/test_readiness.py +++ b/tests/builtin/runtime/test_readiness.py @@ -17,11 +17,14 @@ import asyncio from pathlib import Path +from powercontext.builtin.inference import InferenceConfigurationError +from powercontext.builtin.inference.pydantic_ai import PydanticAIConfigurationError from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import ( BuiltinConfig, ReadinessCheckStatus, RuntimeReadinessStatus, + dependency_readiness_probe, open_builtin_runtime, ) @@ -41,3 +44,27 @@ async def scenario() -> None: } asyncio.run(scenario()) + + +def test_dependency_readiness_probe_surfaces_a_stable_redacted_configuration_reason() -> None: + async def reject() -> None: + raise PydanticAIConfigurationError("provider-rejected", detail="HTTP 400") + + async def scenario() -> None: + probe = dependency_readiness_probe(reject) + + assert await probe() == "misconfigured: provider-rejected (HTTP 400)" + + asyncio.run(scenario()) + + +def test_dependency_readiness_probe_redacts_plain_configuration_errors() -> None: + async def reject() -> None: + raise InferenceConfigurationError("secret provider response") # noqa: TRY003 - verifies redaction + + async def scenario() -> None: + probe = dependency_readiness_probe(reject) + + assert await probe() == "misconfigured" + + asyncio.run(scenario()) diff --git a/tests/test_server.py b/tests/test_server.py index d22b7b3e6..9c541f11b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -32,6 +32,7 @@ from powercontext.builtin.artifacts.experience import ExperienceCandidateInput from powercontext.builtin.artifacts.memory import EmbeddingProfile from powercontext.builtin.inference import EmbeddingResult, InferenceConfigurationError +from powercontext.builtin.inference.pydantic_ai import PydanticAIConfigurationError from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.oceanbase import OceanBaseConfig from powercontext.builtin.persistence.seekdb import SeekDBConfig @@ -453,6 +454,30 @@ def test_server_factory_caches_and_redacts_degraded_embedding_readiness(caplog, assert "secret provider response" not in caplog.text +def test_server_factory_reports_a_rejected_embedding_request_with_a_redacted_reason(tmp_path) -> None: + embedding = _FailingEmbeddingModel(PydanticAIConfigurationError("provider-rejected", detail="HTTP 400")) + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}"), + mcp=McpConfig(enabled=False), + ), + embedding_model=embedding, + ) + + with TestClient(app) as client: + response = client.get("/health/ready") + + assert response.status_code == 200 + assert response.json() == { + "status": "degraded", + "checks": { + "runtime": "ready", + "database": "ready", + "inference.embedding": "misconfigured: provider-rejected (HTTP 400)", + }, + } + + @pytest.mark.parametrize( ("error", "expected_status"), [ @@ -586,7 +611,7 @@ def reject(request: httpx.Request) -> httpx.Response: "checks": { "runtime": "ready", "database": "ready", - "inference.embedding": "misconfigured", + "inference.embedding": "misconfigured: provider-rejected (HTTP 404)", }, } )