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 36c22e162..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 ( @@ -327,6 +328,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 +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", "sqlite-vec probe failed") 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") @@ -525,6 +530,36 @@ async def hydrate( return tuple(hydrated) +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) + 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 " + 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/composition.py b/src/powercontext/builtin/runtime/composition.py index 7c623b068..b74668587 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -562,7 +562,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, 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 new file mode 100644 index 000000000..73dfdb150 --- /dev/null +++ b/tests/builtin/persistence/test_sqlite_memory_vector_index.py @@ -0,0 +1,130 @@ +# 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 ( + _INSERT_VECTOR_SQL, + SQLITE_MEMORY_VECTOR_TABLES, + SQLiteMemoryVectorIndex, + _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, + 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, + 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, + 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()) + + +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_composition_embedding.py b/tests/builtin/runtime/test_composition_embedding.py new file mode 100644 index 000000000..f4ce4f916 --- /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 +from typing import ClassVar + +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: 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) + 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()) 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)", }, } )