Skip to content
Open
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
69 changes: 66 additions & 3 deletions hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2885,6 +2885,57 @@ def get_request_context(authorization: str | None = Header(default=None)) -> Req
api_key = authorization.strip()
return RequestContext(api_key=api_key)

def precheck_for(operation: str):
"""
Build a FastAPI dependency that runs ``OperationValidator.precheck``.

FastAPI resolves dependencies before deserialising the route's body
parameter. Wiring this dependency on the billable POST routes lets
an extension reject a request — e.g. with HTTP 402 when a tenant's
balance is exhausted — without the request body ever being read or
materialised in memory.

The dependency intentionally:
- authenticates the tenant (so ``request_context.tenant_id`` is
resolved before the precheck runs);
- falls through silently when no validator is configured or the
validator's default no-op precheck is in effect;
- converts a rejection ``ValidationResult`` into the corresponding
``HTTPException`` directly (the per-route ``OperationValidationError``
catch blocks don't see exceptions raised in dependencies, so we
translate here instead of relying on each handler's try/except).

Args:
operation: Short identifier for the route, e.g. ``"retain"``.

Returns:
A FastAPI dependency callable suitable for ``Depends(...)``.
"""

async def _precheck_dep(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
) -> None:
validator = getattr(app.state.memory, "_operation_validator", None)
if validator is None:
return
from hindsight_api.extensions import PrecheckContext

await app.state.memory._authenticate_tenant(request_context)
ctx = PrecheckContext(
operation=operation,
bank_id=bank_id,
request_context=request_context,
)
result = await validator.precheck(ctx)
if not result.allowed:
raise HTTPException(
status_code=result.status_code,
detail=result.reason or "Operation not allowed",
)

return _precheck_dep

# Global exception handler for authentication errors
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request, exc: AuthenticationError):
Expand Down Expand Up @@ -3142,7 +3193,10 @@ async def api_get_observation_history(
)
@audited("recall")
async def api_recall(
bank_id: str, request: RecallRequest, request_context: RequestContext = Depends(get_request_context)
bank_id: str,
request: RecallRequest,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("recall")),
):
"""Run a recall and return results with trace."""
import time
Expand Down Expand Up @@ -3330,7 +3384,10 @@ def _fact_to_result(fact: "MemoryFact") -> RecallResult:
)
@audited("reflect")
async def api_reflect(
bank_id: str, request: ReflectRequest, request_context: RequestContext = Depends(get_request_context)
bank_id: str,
request: ReflectRequest,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("reflect")),
):
metrics = get_metrics_collector()

Expand Down Expand Up @@ -3828,6 +3885,7 @@ async def api_create_mental_model(
bank_id: str,
body: CreateMentalModelRequest,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("mental_model_create")),
):
"""Create a mental model (async - returns operation_id)."""
try:
Expand Down Expand Up @@ -3876,6 +3934,7 @@ async def api_refresh_mental_model(
bank_id: str,
mental_model_id: str,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("mental_model_refresh")),
):
"""Refresh a mental model by re-running its source query (async)."""
try:
Expand Down Expand Up @@ -5722,7 +5781,10 @@ async def api_list_webhook_deliveries(
)
@audited("retain")
async def api_retain(
bank_id: str, request: RetainRequest, request_context: RequestContext = Depends(get_request_context)
bank_id: str,
request: RetainRequest,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("retain")),
):
"""Retain memories with optional async processing."""
metrics = get_metrics_collector()
Expand Down Expand Up @@ -5892,6 +5954,7 @@ async def api_file_retain(
files: list[UploadFile] = File(..., description="Files to upload and convert"),
request: str = Form(..., description="JSON string with FileRetainRequest model"),
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("files_retain")),
):
"""Upload and convert files to memories."""
from hindsight_api.config import get_config
Expand Down
2 changes: 2 additions & 0 deletions hindsight-api-slim/hindsight_api/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
# Core operations
OperationValidationError,
OperationValidatorExtension,
PrecheckContext,
RecallContext,
RecallResult,
ReflectContext,
Expand Down Expand Up @@ -72,6 +73,7 @@
"DeferOperation",
"OperationValidationError",
"OperationValidatorExtension",
"PrecheckContext",
"RecallContext",
"RecallResult",
"ReflectContext",
Expand Down
63 changes: 63 additions & 0 deletions hindsight-api-slim/hindsight_api/extensions/operation_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,33 @@ def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
# =============================================================================


@dataclass
class PrecheckContext:
"""Context for a pre-body-parse precheck on an operation.

Unlike :class:`RetainContext` / :class:`RecallContext` / etc., this
context is constructed *before* the request body is deserialised. It
therefore intentionally carries only the cheap, already-resolved
pieces of request state:

- ``operation``: a short string identifying the route, e.g. ``"retain"``,
``"recall"``, ``"reflect"``, ``"files_retain"``, ``"mental_model_create"``,
``"mental_model_refresh"``.
- ``bank_id``: parsed from the URL path.
- ``request_context``: the authenticated :class:`RequestContext` (tenant
already resolved by the tenant extension).

Implementations should keep precheck cheap and side-effect-free. The
full per-request validators (``validate_retain`` / ``validate_recall``
/ ``validate_reflect``) still run after the body is parsed and remain
the source of truth for the precise per-call cost / quota arithmetic.
"""

operation: str
bank_id: str
request_context: "RequestContext"


@dataclass
class RetainContext:
"""Context for a retain operation validation (pre-operation).
Expand Down Expand Up @@ -407,6 +434,42 @@ class OperationValidatorExtension(Extension, ABC):
- consolidate (mental models consolidation)
"""

# =========================================================================
# Pre-body-parse hook (optional - default no-op)
# =========================================================================

async def precheck(self, ctx: PrecheckContext) -> ValidationResult:
"""
Cheap pre-body-parse check, called before the request body is read.

FastAPI resolves ``Depends`` callables before deserialising the route
body; routes that wire ``precheck`` as a dependency therefore short
-circuit here without ever materialising the JSON payload in memory.
That makes this the right hook for "should this caller be allowed to
spend resources on this request at all" decisions — e.g. a balance
is exhausted, a key is revoked, or a tenant is rate-limited.

Implementations should:
- Be cheap: prefer cached lookups, avoid heavy DB queries.
- Use only data on ``ctx`` (operation name + bank_id + request_context);
the body is not yet available.
- Be conservative on errors: prefer ``ValidationResult.accept()`` so
a transient lookup failure doesn't turn into a request rejection.
The post-body ``validate_*`` hooks still run and remain the source
of truth for the precise per-call cost check.

Default implementation accepts everything. Override to opt in.

Args:
ctx: Pre-body context with operation name, bank_id, and
request_context (tenant already resolved).

Returns:
ValidationResult indicating whether the request may proceed to
body parsing and the post-parse validators.
"""
return ValidationResult.accept()

# =========================================================================
# Pre-operation validation hooks (abstract - must be implemented)
# =========================================================================
Expand Down
Loading
Loading