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
8 changes: 6 additions & 2 deletions src/powercontext/builtin/inference/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)):
Expand Down
37 changes: 36 additions & 1 deletion src/powercontext/builtin/persistence/sqlite/memory_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import json
import struct
from collections.abc import Mapping
from re import search
from typing import Any

from sqlalchemy import (
Expand Down Expand Up @@ -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},
Expand All @@ -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")

Expand Down Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions src/powercontext/builtin/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -162,6 +164,7 @@
"BuiltinConfig",
"BuiltinConfigurationError",
"BuiltinRuntime",
"CachedReadinessProbe",
"CandidateFamilyCount",
"CandidateInventoryStatistics",
"CaptureSource",
Expand Down Expand Up @@ -278,6 +281,7 @@
"StatisticsPeriod",
"UsageStatistics",
"WorkApplication",
"dependency_readiness_probe",
"open_builtin_contexts",
"open_builtin_runtime",
]
6 changes: 5 additions & 1 deletion src/powercontext/builtin/runtime/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Comment thread
happy-v587 marked this conversation as resolved.
instrument=instrument,
),
batch_size=settings.embedding_batch_size,
profile=profile,
limits=limits,
Expand Down
30 changes: 21 additions & 9 deletions src/powercontext/builtin/runtime/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class ReadinessCheckStatus(StrEnum):
MISCONFIGURED = "misconfigured"


ReadinessProbe = Callable[[], Awaitable[ReadinessCheckStatus]]
ReadinessProbe = Callable[[], Awaitable[str]]


class RuntimeReadinessStatus(StrEnum):
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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


Expand All @@ -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:
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/powercontext/server/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions tests/builtin/inference/test_pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
130 changes: 130 additions & 0 deletions tests/builtin/persistence/test_sqlite_memory_vector_index.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading