mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
ac41cee604
* feat(extensions): add hindsight-extensions registry and unbundle Supabase
Extensions were only ever bundle-able or nothing: shipping one meant putting
it in `hindsight_api.extensions.builtin`, where it becomes maintainer-owned
forever, lands in every image, and — because `extensions/__init__.py` eagerly
re-exported every implementation — drags its dependencies into core's import
graph. That pipe is why a third-party IdP's JWT client was a direct dependency
of every Hindsight install.
Add `hindsight-extensions/` as the registry for extensions distributed
separately from the server. Its README is the contract: slots and how config
env vars map onto them, how to write an extension, the package layout and
naming (`hindsight-extensions/<name>/` -> `hindsight-ext-<name>` ->
`hindsight_ext_<name>`), and Docker packaging.
Move `SupabaseTenantExtension` there as the first entry, published as
`hindsight-ext-supabase-tenant`. All 54 of its tests move with it, plus two new
ones asserting the documented `hindsight_ext_supabase_tenant:...` env value
actually resolves through `load_extension`.
Two decisions worth their comments:
- The extension does NOT declare `hindsight-api-slim` as a runtime dependency.
The server is the host process that imports it, not something it installs;
declaring it would let `pip install` of an extension silently move the server
version underneath a running deployment. It is a dev extra, resolved from the
local checkout via `tool.uv.sources` (dev-only metadata, verified absent from
the built wheel).
- The Docker example installs with `uv pip install --python
/app/api/.venv/bin/python`, matching docker-compose/custom-models: the image's
venv was created by `uv sync` and ships no `pip`, so a bare `pip install`
lands in user site-packages and is invisible to the server.
Core changes:
- `extensions/__init__.py` and `builtin/__init__.py` export interfaces only.
Nothing needed the concrete re-exports — the loader imports by path — and
dropping them is what lets an extension have optional dependencies at all.
- `builtin/supabase_tenant.py` stays for one minor release as a module whose
`__getattr__` raises the migration instructions. `load_extension` wraps a
missing *attribute*, not a failed import, so the ImportError propagates with
its message intact instead of surfacing as "class not found".
- Drop the direct `PyJWT[crypto]` dependency: no core module imports `jwt` any
more. Note this does not shrink the install — `mcp` pulls pyjwt transitively
and `cryptography` is already pinned directly — so the win here is ownership
and import graph, not bytes.
Locks are not checked in for extensions: `tool.uv.sources` pins the whole
api-slim tree, so every core dependency bump would leave them stale. CI runs
`uv sync --extra dev` and retriggers on `core` changes, since these tests run
against the server's interfaces.
Docs point at the registry rather than restating it, and the Deploying section's
Docker recipe was replaced — it named an image (`vectorize/hindsight-api`) and a
PYTHONPATH volume-mount pattern that no longer exist.
Also includes two one-line generated-file syncs in skills/hindsight-docs
(quickstart, installation) that were already stale on main; regenerating the
docs skill picks them up.
* refactor(extensions): ship extensions by image, drop the compat shim
Follow-up on review. Three changes to how an extension is distributed:
- Delete `builtin/supabase_tenant.py`. An install pinned to the old path now
fails at startup with ModuleNotFoundError rather than a guided message. The
docs carry the migration instead.
- Extensions are not published to PyPI. There is no wheel, no version and no
release step: the unit of distribution is an image built on top of Hindsight
that installs the extension's dependencies and copies the package onto
PYTHONPATH. That drops the whole "declare hindsight-api-slim only as a dev
extra" problem — nothing resolves dependencies against a running server any
more.
- The pyproject is now test-harness only (`package = false`, no build backend,
no distribution metadata), and says so in a comment so nobody re-adds
packaging to it.
Docs say 0.9.3, not 0.10.
Since the Dockerfile is now the distribution mechanism rather than an example,
CI builds it — its final `import` step is the only thing proving the extension
is reachable from the interpreter the server actually runs. It builds against
`:latest-slim` via a HINDSIGHT_IMAGE build arg to keep the pull cheap.
Verified against the real image, not just locally:
docker build -f hindsight-extensions/supabase-tenant/Dockerfile \
--build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim ...
-> load_extension('TENANT', TenantExtension) inside the container returns
SupabaseTenantExtension with its config resolved from the env vars.
Worth noting from that build: `uv pip install 'PyJWT[crypto]' httpx` reports
"Checked 2 packages" — both are already in the base image transitively. The
line stays because the extension should pin what it imports rather than rely on
the server's transitive tree, but it costs nothing today.
56 extension tests pass; the 3 remaining core tests (which assert no
implementation is re-exported and no core module imports jwt) pass.
* fix(tests): import ApiKeyTenantExtension from its module, not the package
Dropping the concrete re-exports from `hindsight_api.extensions` broke
`tests/test_extensions.py`, which imported `ApiKeyTenantExtension` from the
package inside a multi-line parenthesised import. A collection ImportError
fails the whole shard, which is why all three test-api shards and all six LLM
acceptance jobs went red at once on the previous push.
I'd checked for this with a single-line grep, which cannot see a name inside a
parenthesised import list. Re-checked with an AST scan over every package in
the repo (this was the only occurrence) and by collecting the full suite:
7780 tests collect clean.
367 lines
14 KiB
Markdown
367 lines
14 KiB
Markdown
# Extensions
|
|
|
|
Extensions allow you to customize and extend Hindsight behavior without modifying core code. They enable multi-tenancy, custom authentication, additional HTTP endpoints, and operation hooks.
|
|
|
|
---
|
|
|
|
## Available Extensions
|
|
|
|
### TenantExtension
|
|
|
|
Handles multi-tenancy and API key authentication. Validates incoming requests and determines which PostgreSQL schema to use for database operations, enabling tenant isolation at the database level.
|
|
|
|
**Built-in: ApiKeyTenantExtension**
|
|
|
|
A simple implementation that validates API keys against an environment variable and uses the `public` schema for all authenticated requests.
|
|
|
|
```bash
|
|
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
|
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
|
```
|
|
|
|
**No longer built in: SupabaseTenantExtension**
|
|
|
|
Validates [Supabase](https://supabase.com) JWTs and gives each authenticated user their own PostgreSQL schema. It now lives in the [extensions registry](https://github.com/vectorize-io/hindsight/tree/main/hindsight-extensions/supabase-tenant), which documents its configuration and ships a Dockerfile that builds an image with it.
|
|
|
|
:::warning Breaking change in 0.9.3
|
|
Up to 0.9.2 this extension was built in, at `hindsight_api.extensions.builtin.supabase_tenant`. That path no longer exists, so an install still pointing at it fails at startup with `ModuleNotFoundError`. Add the extension to your image and set `HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension`. All `HINDSIGHT_API_TENANT_*` settings and the schema naming are unchanged.
|
|
:::
|
|
|
|
For other multi-tenant setups with separate schemas per tenant (e.g., custom JWT-based auth), implement a custom `TenantExtension`.
|
|
|
|
---
|
|
|
|
### HttpExtension
|
|
|
|
Adds custom HTTP endpoints under the `/ext/` path prefix. Useful for adding domain-specific APIs that integrate with Hindsight's memory engine.
|
|
|
|
Provides two router methods:
|
|
- `get_router(memory)` — returns a FastAPI router mounted at `/ext/`
|
|
- `get_root_router(memory)` — returns a FastAPI router mounted at the application root (for well-known endpoints or other paths that must be at specific locations). Returns `None` by default.
|
|
|
|
**No built-in implementation** - implement your own to add custom endpoints.
|
|
|
|
```bash
|
|
HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension
|
|
```
|
|
|
|
---
|
|
|
|
### OperationValidatorExtension
|
|
|
|
Hooks into retain/recall/reflect operations for validation and monitoring. Use cases include:
|
|
- Rate limiting and quota enforcement
|
|
- Permission checks and content filtering
|
|
- Audit logging and usage tracking
|
|
- Custom metrics collection
|
|
|
|
**No built-in implementation** - implement your own based on your requirements.
|
|
|
|
```bash
|
|
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
|
|
```
|
|
|
|
---
|
|
|
|
### MCPExtension
|
|
|
|
Registers additional MCP (Model Context Protocol) tools on the Hindsight MCP server. Enables external packages to add custom tools without modifying core code.
|
|
|
|
**No built-in implementation** - implement your own to add custom MCP tools.
|
|
|
|
```bash
|
|
HINDSIGHT_API_MCP_EXTENSION=mypackage.mcp:MyMCPExtension
|
|
```
|
|
|
|
---
|
|
|
|
## Writing Custom Extensions
|
|
|
|
### Extension Basics
|
|
|
|
Extensions are Python classes loaded via environment variables:
|
|
|
|
```bash
|
|
HINDSIGHT_API_<TYPE>_EXTENSION=mypackage.module:MyExtensionClass
|
|
```
|
|
|
|
Configuration is passed via prefixed environment variables:
|
|
|
|
```bash
|
|
HINDSIGHT_API_<TYPE>_SOME_CONFIG=value
|
|
# Extension receives: {"some_config": "value"}
|
|
```
|
|
|
|
All extensions support lifecycle hooks:
|
|
- `on_startup()` - Called when the application starts
|
|
- `on_shutdown()` - Called when the application shuts down
|
|
|
|
Extensions have access to an `ExtensionContext` that provides:
|
|
- `run_migration(schema)` - Run database migrations for a schema
|
|
- `get_memory_engine()` - Get the MemoryEngine interface
|
|
|
|
### Example: Custom TenantExtension with JWT
|
|
|
|
```python
|
|
import jwt
|
|
from hindsight_api.extensions import TenantExtension, TenantContext, AuthenticationError
|
|
|
|
class JwtTenantExtension(TenantExtension):
|
|
def __init__(self, config: dict[str, str]):
|
|
super().__init__(config)
|
|
self.jwt_secret = config.get("jwt_secret")
|
|
if not self.jwt_secret:
|
|
raise ValueError("HINDSIGHT_API_TENANT_JWT_SECRET is required")
|
|
|
|
async def authenticate(self, context: RequestContext) -> TenantContext:
|
|
token = context.api_key
|
|
if not token:
|
|
# Optional headers dict is forwarded in HTTP/MCP error responses
|
|
raise AuthenticationError("Bearer token required")
|
|
|
|
try:
|
|
payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
|
|
tenant_id = payload.get("tenant_id")
|
|
if not tenant_id:
|
|
raise AuthenticationError("Missing tenant_id in token")
|
|
return TenantContext(schema_name=f"tenant_{tenant_id}")
|
|
except jwt.InvalidTokenError as e:
|
|
raise AuthenticationError(str(e))
|
|
```
|
|
|
|
`AuthenticationError` accepts an optional `headers` dict that is forwarded in both HTTP and MCP error responses. This is useful for returning custom headers like `WWW-Authenticate`:
|
|
|
|
```python
|
|
raise AuthenticationError(
|
|
"Authorization required",
|
|
headers={"WWW-Authenticate": 'Bearer realm="example"'},
|
|
)
|
|
```
|
|
|
|
### Reading additional request headers
|
|
|
|
`RequestContext` carries the `Authorization` header as `api_key`. To authenticate on a *different* header — for instance when a gateway terminates auth with one shared identity and forwards the per-caller identity separately — name the headers you want forwarded:
|
|
|
|
```bash
|
|
HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS=x-user-assertion
|
|
```
|
|
|
|
They are available as `context.extra_headers`, keyed by lower-cased name:
|
|
|
|
```python
|
|
async def authenticate(self, context: RequestContext) -> TenantContext:
|
|
assertion = context.extra_headers.get("x-user-assertion")
|
|
if not assertion:
|
|
raise AuthenticationError("x-user-assertion header required")
|
|
|
|
user_id = verify_assertion(assertion) # your verification
|
|
return TenantContext(schema_name=f"tenant_{user_id}")
|
|
```
|
|
|
|
This works on both the HTTP and MCP transports, and the same `RequestContext` is passed to `OperationValidatorExtension` hooks, so a validator can enforce rules against the identity resolved here.
|
|
|
|
Only headers you list are forwarded, and only when present on the request. The variable is unset by default, so extensions see no header data unless you opt in.
|
|
|
|
A header sent **more than once** is not forwarded at all, and a warning is logged. There is no safe way to choose between the copies — a proxy may append its trusted value either before or after a client-supplied one — so an extension reading it sees nothing and fails the request, rather than silently accepting a value that may be spoofed. Make sure your proxy *replaces* the identity header it injects instead of appending to it.
|
|
|
|
:::caution Deferred operations
|
|
`extra_headers` describes the request being served. Operations that run later — a queued retain, a scheduled consolidation, a mental-model refresh — are executed by a background worker with no request behind them, so their `RequestContext` carries no headers. Authorize on the header at request time; do not rely on it inside work that continues after the response.
|
|
:::
|
|
|
|
### Example: Custom HttpExtension
|
|
|
|
```python
|
|
from fastapi import APIRouter
|
|
from hindsight_api.extensions import HttpExtension
|
|
|
|
class MyHttpExtension(HttpExtension):
|
|
def get_router(self, memory: MemoryEngine) -> APIRouter:
|
|
router = APIRouter()
|
|
|
|
@router.get("/hello")
|
|
async def hello():
|
|
return {"message": "Hello from extension!"}
|
|
|
|
@router.post("/custom/{bank_id}/action")
|
|
async def custom_action(bank_id: str):
|
|
# Access memory engine for database operations
|
|
pool = await memory._get_pool()
|
|
# ... custom logic
|
|
return {"status": "ok"}
|
|
|
|
return router
|
|
|
|
def get_root_router(self, memory: MemoryEngine) -> APIRouter | None:
|
|
"""Optional: mount routes at the application root (not under /ext/)."""
|
|
router = APIRouter()
|
|
|
|
@router.get("/.well-known/my-metadata")
|
|
async def metadata():
|
|
return {"version": "1.0"}
|
|
|
|
return router
|
|
```
|
|
|
|
Routes from `get_router` are available at `/ext/hello`, `/ext/custom/{bank_id}/action`, etc.
|
|
Routes from `get_root_router` are mounted at the app root (e.g., `/.well-known/my-metadata`).
|
|
|
|
### Example: Custom OperationValidatorExtension
|
|
|
|
```python
|
|
from hindsight_api.extensions import (
|
|
OperationValidatorExtension,
|
|
ValidationResult,
|
|
PrecheckContext,
|
|
RetainContext,
|
|
RecallContext,
|
|
ReflectContext,
|
|
RetainResult,
|
|
)
|
|
|
|
class MyValidator(OperationValidatorExtension):
|
|
# Pre-body validation (optional)
|
|
async def precheck(self, ctx: PrecheckContext) -> ValidationResult:
|
|
if ctx.content_length is not None and ctx.content_length > 10_000_000:
|
|
return ValidationResult.reject("Payload is too large")
|
|
return ValidationResult.accept()
|
|
|
|
# Pre-operation validation (required)
|
|
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
|
# Implement your validation logic
|
|
return ValidationResult.accept()
|
|
# Or reject: return ValidationResult.reject("Reason")
|
|
|
|
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
|
return ValidationResult.accept()
|
|
|
|
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
|
return ValidationResult.accept()
|
|
|
|
# Post-operation hooks (optional)
|
|
async def on_retain_complete(self, result: RetainResult) -> None:
|
|
# Log usage, update metrics, send notifications, etc.
|
|
pass
|
|
```
|
|
|
|
`precheck` runs before the request body is read or deserialized. Its
|
|
`PrecheckContext.content_length` is the parsed `Content-Length` header as an
|
|
integer, or `None` when the header is missing or cannot be parsed (for example,
|
|
chunked transfer encoding). Use it for cheap size-aware quota or cost guards;
|
|
the full `validate_*` hooks still run after parsing and should enforce precise
|
|
per-operation limits.
|
|
|
|
#### Deferring an operation
|
|
|
|
In addition to `accept` and `reject`, a `validate_*` hook can ask the
|
|
worker to **requeue** the operation for a future time by raising
|
|
`DeferOperation`. Use this for backpressure (rate-limited upstream,
|
|
quota window not yet open, dependency warming up) — unlike a retry, it
|
|
does not increment `retry_count` or write `error_message`. The worker
|
|
sets `next_retry_at` to your `exec_date` and the task is invisible to
|
|
claim queries until that time.
|
|
|
|
```python
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from hindsight_api.extensions import (
|
|
DeferOperation,
|
|
OperationValidatorExtension,
|
|
RetainContext,
|
|
ValidationResult,
|
|
)
|
|
|
|
|
|
class QuotaAwareValidator(OperationValidatorExtension):
|
|
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
|
if not await self._quota_available(ctx.bank_id):
|
|
raise DeferOperation(
|
|
exec_date=datetime.now(timezone.utc) + timedelta(minutes=5),
|
|
reason="bank quota window exhausted",
|
|
)
|
|
return ValidationResult.accept()
|
|
```
|
|
|
|
`DeferOperation` is **worker-only**: do not raise it from
|
|
`validate_recall` or `validate_reflect` in synchronous HTTP request
|
|
paths — there is no queue to defer to and it will surface as a 500.
|
|
|
|
### Example: Custom MCPExtension
|
|
|
|
```python
|
|
from mcp.server.fastmcp import FastMCP
|
|
from hindsight_api.extensions import MCPExtension
|
|
from hindsight_api.engine import MemoryEngine
|
|
|
|
class MyMCPExtension(MCPExtension):
|
|
async def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
|
|
@mcp.tool()
|
|
async def custom_search(query: str) -> str:
|
|
"""Custom MCP tool for specialized search."""
|
|
# Access memory engine for operations
|
|
pool = await memory._get_pool()
|
|
# ... custom logic
|
|
return f"Results for: {query}"
|
|
```
|
|
|
|
---
|
|
|
|
## Deploying Custom Extensions
|
|
|
|
### With Docker
|
|
|
|
Extensions are not bundled in the image. Build one on top of Hindsight that installs your extension's dependencies and copies it in. Install into the image's virtualenv explicitly — it was created by `uv sync` and ships no `pip` of its own, so a bare `pip install` lands where the server can't see it:
|
|
|
|
```dockerfile
|
|
FROM ghcr.io/vectorize-io/hindsight:latest
|
|
|
|
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
|
|
'PyJWT[crypto]>=2.12.0' 'httpx>=0.27.0'
|
|
|
|
COPY my_extension /app/extensions/my_extension
|
|
ENV PYTHONPATH=/app/extensions
|
|
|
|
# Fail the build, not the first request, if it isn't importable.
|
|
RUN /app/api/.venv/bin/python -c "import my_extension"
|
|
```
|
|
|
|
Then point the service at that image and pass the extension's variables as environment. Give the API and worker containers the same extension configuration — the worker uses the tenant extension to enumerate schemas for background consolidation.
|
|
|
|
See the [extensions registry README](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md#docker-packaging) for the full recipe.
|
|
|
|
### Bare Metal
|
|
|
|
Install your extension package in the same Python environment as Hindsight:
|
|
|
|
```bash
|
|
# Install Hindsight
|
|
pip install hindsight-api
|
|
|
|
# Install your extension package
|
|
pip install ./my-extensions
|
|
# or
|
|
pip install my-extensions-package
|
|
|
|
# Configure
|
|
export HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
|
|
export HINDSIGHT_API_TENANT_JWT_SECRET=your-secret
|
|
|
|
# Run
|
|
hindsight-api
|
|
```
|
|
|
|
---
|
|
|
|
## Contributing Extensions
|
|
|
|
Custom extensions that solve common use cases are welcome contributions to the Hindsight project. If you've built an extension for:
|
|
|
|
- Authentication providers (OAuth, SAML, API gateways)
|
|
- Rate limiting or quota management
|
|
- Audit logging integrations
|
|
- Metrics exporters (Datadog, New Relic, etc.)
|
|
- Custom HTTP endpoints for specific platforms
|
|
|
|
Add it to the [extensions registry](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md) — either as a directory under `hindsight-extensions/`, or as a registry entry linking to your own repository. That README covers the layout, the development workflow, and Docker packaging.
|
|
|
|
Extensions live outside the server so that installing Hindsight does not pull in a vendor's client library, and so changing an extension does not require a Hindsight release. Only extensions that add no dependencies and are useful to any deployment (`ApiKeyTenantExtension`, `MemoryDefenseRegexExtension`) stay in `hindsight_api.extensions.builtin`.
|