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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e "./sdk-python[dev,async]"
pip install -e "./sdk-python[dev,async,server]"
- name: Validate OpenAPI spec (Principle I)
run: omp-validate-spec
- name: Run conformance suite (overall ≥85%)
Expand Down
77 changes: 77 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,83 @@

All notable changes to this project will be documented in this file.

## [0.5.0] — Unreleased (M3.2 PR-B — `omp-server` FastAPI)

### Added

- **`openmem.server`** — production-shaped FastAPI HTTP server that
exposes the full OMP verb set as a RESTful service backed by
`AsyncMemory`. New install extra `[server]` (depends on
`openmem[async]` plus `fastapi>=0.115` and `uvicorn[standard]>=0.30`).
`from openmem.server import create_app` raises a clear `ImportError`
with `pip install 'openmem[server]'` when the extra is missing
(C-EXT-2).
- **`omp-server` CLI** (`pyproject.toml [project.scripts]`) —
`omp-server --provider <name> [--host …] [--port …] [--url …]`. CLI
flags > env vars > defaults; `--help` documents trusted-network
deployment (auth deferred); missing config exits 2 with a
`omp-server: missing config:` message; successful boot prints
`omp-server: serving <provider> at http://<host>:<port>` to stderr
exactly once (C-CLI-1..4).
- **`OmpServerConfig`** — frozen dataclass with the four CFG-INV-1..4
invariants (port range, body-size range, postgres URL when
`provider=postgres`, matching `*_API_KEY` for managed providers).
Importable without the FastAPI extras.
- **9 routes** mirroring `spec/omp-0.1.openapi.yaml` 1:1:
`POST/GET/PATCH/DELETE /memories[/{id}]`, `GET /memories/search`,
`POST /context`, `GET /audit`, `GET /capabilities`, `GET /healthz`.
Every handler is `async def` and obtains its `AsyncMemory` via
`Depends(get_memory)` so client disconnect (FR-018) propagates as
`CancelledError` through the AsyncMemory cancellation contract.
- **11-row error envelope mapping** (FR-016/017): every adapter
exception (`NotFoundError`, `InvalidRequestError`, `UnauthorizedError`,
`ScopeDeniedError`, `RateLimitedError`, `UnsupportedCapabilityError`,
`ProviderError(ingestion_timeout)`, `ProviderError(other)`,
unhandled `Exception`, oversized body, pool exhaustion) maps to the
documented HTTP status + `{"error": {"code", "message", "type"}}`
body. `RequestValidationError` from Pydantic is normalized to
`400 invalid_request`.
- **`MaxRequestSizeMiddleware`** (FR-021): pure-ASGI guard that rejects
bodies > `OmpServerConfig.max_request_bytes` (1 MiB default; range
1 KiB..100 MiB) with `413 payload_too_large` BEFORE Pydantic runs.
Trusts `Content-Length` when present, else streams in bounded chunks.
- **`LoggingMiddleware`** (FR-020): emits exactly one INFO access line
per request in the format
`<iso8601> INFO <method> <path> <status> <latency_ms>ms req=<id>`
with no body, no `user_id`, no `Authorization` header, and no value
whose key matches `(?i)password|secret|token|key|api_key`.
`X-Request-Id` is honored if it matches `^[A-Za-z0-9._\-]{1,64}$`,
otherwise replaced with a fresh UUID4; the resolved id is echoed on
every response (C-LOG-1..4).
- **CORS default-deny** (FR-022): no `CORSMiddleware` is installed
unless `OmpServerConfig.cors_origins` is non-empty. When enabled,
`allow_credentials=False` and methods are limited to
`GET/POST/PATCH/DELETE` (C-CORS-1/2).
- **Provider-aware `/healthz`** (FR-019): postgres acquires a pool
connection within 1 s; passthrough does a `HEAD` upstream within 2 s;
mem0/supermemory/letta return 200 unconditionally to avoid spending
paid API credits on health checks (C-HEA-1..4, documented in
`--help`).
- **`X-User-Id` header propagation** (C-UID-1..3): GET/DELETE routes
read `user_id` from the header; POST/PATCH read it from the JSON
body. Empty/whitespace `user_id` is rejected with `400 invalid_request`
BEFORE the adapter is called. `user_id` never appears in log lines.
- **OpenAPI conformance test** (FR-015) loads `spec/omp-0.1.openapi.yaml`
via PyYAML, resolves `$ref` schemas, and validates real
`httpx.AsyncClient` responses for every route × representative
status case via `jsonschema`.

### Changed

- `sdk-python/pyproject.toml`: bumped `version` to `0.5.0`; added the
`omp-server` console script.

### Preserved

- The sync `openmem.Memory` class and the `openmem.AsyncMemory` facade
are unchanged. The full sync + async + server suites pass at 476
passed / 26 skipped, total coverage 85.92 % (gate ≥ 85 %).

## [0.4.0] — Unreleased (M3.2 PR-A — `AsyncMemory`)

### Added
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ print(ctx.text)

- `openmem-eval` — manual benchmark harness comparing recall, MRR, and latency across configured providers. **Never runs in CI.** Default invocation is a dry-run that makes zero network calls. See [specs/004-eval-kit/quickstart.md](specs/004-eval-kit/quickstart.md) for usage and [docs/eval/](docs/eval/README.md) for a sample report and trace from a real postgres run.
- `openmem.AsyncMemory` — async/await-native facade mirroring `openmem.Memory`. Postgres + passthrough adapters use native async clients (asyncpg, httpx); mem0/supermemory/letta are wrapped with a per-instance thread pool. Cancellation propagates within 50 ms on the native tier. Install with `pip install 'openmem[async]'` and see [specs/005-async-fastapi/quickstart.md](specs/005-async-fastapi/quickstart.md) §1–§4.
- `omp-server` — production-shaped FastAPI HTTP server that exposes the full OMP verb set over HTTP using `AsyncMemory` under the hood. Install with `pip install 'openmem[server]'` then boot, e.g.:

```sh
omp-server --provider postgres --url postgresql://user:pass@host:5432/db --port 8080
```

Trusted-network deployment only — auth is deferred to a future release. See [specs/005-async-fastapi/contracts/http-server.md](specs/005-async-fastapi/contracts/http-server.md) for the route table, error-envelope mapping, and CORS / size-limit / health-check contracts.

## HTTP server

```sh
pip install 'openmem[server]'
omp-server --provider postgres --url postgresql://user:pass@host:5432/db
# omp-server: serving postgres at http://127.0.0.1:8080
```

Routes mirror `spec/omp-0.1.openapi.yaml` exactly: `POST/GET/PATCH/DELETE /memories[/{id}]`, `GET /memories/search`, `POST /context`, `GET /audit`, `GET /capabilities`, `GET /healthz`. Every request emits one INFO access log line with no `user_id` and no secret-keyed values.

## License

Expand Down
15 changes: 15 additions & 0 deletions sdk-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ while the worker thread completes in the background. See
[../specs/005-async-fastapi/quickstart.md](../specs/005-async-fastapi/quickstart.md)
for the full contract.

## HTTP server (`omp-server`)

```sh
pip install 'openmem[server]'
omp-server --provider postgres --url postgresql://user:pass@host:5432/db
# omp-server: serving postgres at http://127.0.0.1:8080
```

Routes mirror `spec/omp-0.1.openapi.yaml` 1:1 (`POST/GET/PATCH/DELETE
/memories[/{id}]`, `GET /memories/search`, `POST /context`, `GET /audit`,
`GET /capabilities`, `GET /healthz`). The 11-row error envelope, 1 MiB
default body limit, and default-deny CORS are documented in
[../specs/005-async-fastapi/contracts/http-server.md](../specs/005-async-fastapi/contracts/http-server.md).
Trusted-network deployment only — auth is deferred.

## Environment variables

| Var | Purpose |
Expand Down
2 changes: 1 addition & 1 deletion sdk-python/openmem/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
SearchResult,
)

__version__ = "0.4.0"
__version__ = "0.5.0"

__all__ = [
"__version__",
Expand Down
51 changes: 51 additions & 0 deletions sdk-python/openmem/server/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""`openmem.server` — FastAPI HTTP server (PR-B / M3.2).

Public surface (lazy-loaded so `OmpServerConfig` is importable without
the FastAPI extras):

* :class:`OmpServerConfig` — frozen dataclass (no FastAPI dependency).
* :func:`create_app` — build a FastAPI app from a config.
* :data:`app` — module-level FastAPI built from environment variables;
exists for `uvicorn openmem.server:app` workflows.

Importing :func:`create_app` or :data:`app` without the `[server]`
extras raises :class:`ImportError` with the install hint, mirroring the
behavior of `openmem.AsyncMemory` for the `[async]` extras.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from openmem.server.config import OmpServerConfig

__all__ = ["OmpServerConfig", "create_app", "app"]


def _require_fastapi() -> None:
"""Eagerly probe FastAPI; raise the standard install-hint error."""
try:
import fastapi # noqa: F401
except ImportError as exc: # pragma: no cover - exercised in bare venv
raise ImportError(
"openmem.server requires the server extras. "
"Install with: pip install 'openmem[server]'"
) from exc


def __getattr__(name: str) -> Any:
if name == "create_app":
_require_fastapi()
from openmem.server.app import create_app as _create_app

return _create_app
if name == "app":
_require_fastapi()
from openmem.server.app import build_app_from_env

return build_app_from_env()
raise AttributeError(f"module 'openmem.server' has no attribute {name!r}")


if TYPE_CHECKING: # pragma: no cover
from openmem.server.app import create_app # noqa: F401
120 changes: 120 additions & 0 deletions sdk-python/openmem/server/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""FastAPI application factory (PR-B / T042).

`create_app(config)` builds the FastAPI app, wires middlewares + exception
handlers + routers, and arranges startup/shutdown to manage the
`AsyncMemory` lifecycle.

`build_app_from_env()` reads `OMP_*` env vars and constructs a config —
used by `uvicorn openmem.server:app`.
"""

from __future__ import annotations

import os
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from openmem.async_memory import AsyncMemory
from openmem.server.config import OmpServerConfig
from openmem.server.errors import register_exception_handlers
from openmem.server.middleware import (
LoggingMiddleware,
MaxRequestSizeMiddleware,
)
from openmem.server.routes import health_router, router

__all__ = ["create_app", "build_app_from_env"]


def create_app(config: OmpServerConfig) -> FastAPI:
"""Build the FastAPI app from a validated `OmpServerConfig`."""

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Build adapter on startup so a bad config fails the boot, not the
# first request. AsyncMemory is the same facade exercised by tests.
mem = AsyncMemory(provider=config.provider, **config.adapter_kwargs())
await mem.__aenter__()
app.state.memory = mem
app.state.config = config
try:
yield
finally:
await mem.close()

app = FastAPI(
title="omp-server",
version="0.1.0",
description=(
"Open Memory Protocol HTTP server. "
"trusted-network deployment only — auth deferred."
),
lifespan=lifespan,
)

# Order: outermost first.
# 1. Logging (so it sees the final status code from inner middlewares).
app.add_middleware(LoggingMiddleware)
# 2. Size guard before any body parsing (C-SIZ-1 — must precede Pydantic).
app.add_middleware(
MaxRequestSizeMiddleware, max_bytes=config.max_request_bytes
)
# 3. CORS only if explicitly enabled (C-CORS-1: default-deny).
if config.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=list(config.cors_origins),
allow_credentials=False,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Content-Type", "X-User-Id", "X-Request-Id"],
)

register_exception_handlers(app)
app.include_router(router)
app.include_router(health_router)

return app


def build_app_from_env() -> FastAPI:
"""Construct an app from `OMP_*` env vars.

Recognized variables (CLI > env > default; this function only reads env):

* `OMP_PROVIDER` required
* `OMP_HOST` default 127.0.0.1
* `OMP_PORT` default 8080
* `OMP_MAX_REQUEST_BYTES` default 1048576
* `OMP_CORS_ORIGINS` comma-separated; default empty (CORS off)
* `OMP_LOG_LEVEL` default info
* `OMP_POSTGRES_URL` required if provider=postgres
* `OMP_PASSTHROUGH_BASE_URL` required if provider=passthrough
* `MEM0_API_KEY` / `SUPERMEMORY_API_KEY` / `LETTA_API_KEY`
"""
provider = os.environ.get("OMP_PROVIDER")
if not provider:
raise RuntimeError(
"OMP_PROVIDER env var is required (set --provider on CLI or "
"use openmem.server.create_app directly)"
)
cors_raw = os.environ.get("OMP_CORS_ORIGINS", "").strip()
cors = tuple(c.strip() for c in cors_raw.split(",") if c.strip())
cfg = OmpServerConfig(
provider=provider,
host=os.environ.get("OMP_HOST", "127.0.0.1"),
port=int(os.environ.get("OMP_PORT", "8080")),
max_request_bytes=int(
os.environ.get("OMP_MAX_REQUEST_BYTES", str(1024 * 1024))
),
cors_origins=cors,
log_level=os.environ.get("OMP_LOG_LEVEL", "info"),
postgres_url=os.environ.get("OMP_POSTGRES_URL"),
passthrough_base_url=os.environ.get("OMP_PASSTHROUGH_BASE_URL"),
mem0_api_key=os.environ.get("MEM0_API_KEY"),
supermemory_api_key=os.environ.get("SUPERMEMORY_API_KEY"),
letta_api_key=os.environ.get("LETTA_API_KEY"),
)
return create_app(cfg)
Loading
Loading