Skip to content

Commit 1544961

Browse files
committed
Harden Engram runtime and verification
1 parent 0d3d839 commit 1544961

30 files changed

Lines changed: 1585 additions & 51 deletions

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ LOG_LEVEL=info
2323
MCP_SERVICE_KEY=
2424
ENGRAM_SERVICE_KEY=
2525
API_PUBLIC_URL=http://localhost:8000
26-
CORS_ORIGINS=http://localhost:3001
26+
CORS_ORIGINS=http://localhost:3001,http://localhost:3011
2727
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
2828
CLERK_SECRET_KEY=

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
with:
1414
python-version: "3.11"
1515
- run: python -m compileall api
16+
- run: python -m pip install asyncpg==0.29.0 pydantic==2.7.0 pydantic-settings==2.3.0 httpx==0.27.0 pytest==8.2.2
17+
- run: python -m pytest api/test_user_auth_cache.py api/test_provider_and_extraction_parsing.py
1618

1719
mcp:
1820
runs-on: ubuntu-latest
@@ -27,6 +29,8 @@ jobs:
2729
working-directory: mcp
2830
- run: npm run build
2931
working-directory: mcp
32+
- run: npm run verify:defaults
33+
working-directory: mcp
3034

3135
dashboard:
3236
runs-on: ubuntu-latest

api/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class Settings(BaseSettings):
2424
log_level: str = "info"
2525
cors_origins: str = "http://localhost:3001"
2626
engram_service_key: str = ""
27+
proxy_auth_cache_ttl_seconds: int = 300
28+
proxy_auth_cache_max_entries: int = 4096
2729
engram_test_api_url: str = "http://localhost:8000"
2830
engram_test_provider: str = "openai"
2931
engram_test_model: str = "gpt-4o-mini"

api/db/schema.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@ CREATE TABLE IF NOT EXISTS users (
55
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
66
external_id TEXT UNIQUE NOT NULL,
77
api_key_hash TEXT UNIQUE NOT NULL,
8+
max_memories_injected INT NOT NULL DEFAULT 5,
9+
retrieval_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.5,
10+
dedup_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.95,
811
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
912
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
1013
);
1114

15+
ALTER TABLE users ADD COLUMN IF NOT EXISTS max_memories_injected INT NOT NULL DEFAULT 5;
16+
ALTER TABLE users ADD COLUMN IF NOT EXISTS retrieval_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.5;
17+
ALTER TABLE users ADD COLUMN IF NOT EXISTS dedup_threshold DOUBLE PRECISION NOT NULL DEFAULT 0.95;
18+
1219
CREATE TABLE IF NOT EXISTS memories (
1320
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
1421
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from api.config import settings
99
from api.db.connection import check_database, close_pool, init_pool
10+
from api.db.schema import apply_schema
1011
from api.routes import logs, memories, proxy, users
1112
from api.services.embedding import is_model_loaded, load_model
1213

@@ -16,6 +17,7 @@
1617

1718
@asynccontextmanager
1819
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
20+
await apply_schema()
1921
await init_pool()
2022
load_model()
2123
yield

api/models/user.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,15 @@ class UserResponse(BaseModel):
2020

2121
class UserCreateResponse(UserResponse):
2222
api_key: str
23+
24+
25+
class UserConfigUpdate(BaseModel):
26+
max_memories_injected: int | None = Field(default=None, ge=1, le=20)
27+
retrieval_threshold: float | None = Field(default=None, ge=0, le=1)
28+
dedup_threshold: float | None = Field(default=None, ge=0, le=1)
29+
30+
31+
class UserConfigResponse(BaseModel):
32+
max_memories_injected: int
33+
retrieval_threshold: float
34+
dedup_threshold: float

api/routes/memories.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def create_memory_route(
4646
user: asyncpg.Record = Depends(get_current_user),
4747
db: asyncpg.Connection = Depends(get_db),
4848
) -> dict[str, object]:
49-
return await create_memory(user["id"], payload.content, db)
49+
return await create_memory(user["id"], payload.content, db, float(user["dedup_threshold"]))
5050

5151

5252
@router.get("/{memory_id}", response_model=MemoryResponse)

api/routes/proxy.py

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import asyncio
2+
from collections.abc import Mapping
23
import json
34
import logging
4-
from uuid import UUID
55

66
import asyncpg
7-
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
7+
from fastapi import APIRouter, Header, HTTPException, Request, Response
88

9-
from api.dependencies import get_current_user, get_db
9+
from api.db.connection import get_pool
1010
from api.services.extraction import run_extraction_task
11-
from api.services.proxy import build_proxy_result
11+
from api.services.proxy import ProxyResult, build_proxy_passthrough_result, build_proxy_result
12+
from api.services.users import get_cached_user_by_api_key, get_user_by_api_key
1213

1314

1415
logger = logging.getLogger(__name__)
@@ -18,36 +19,105 @@
1819
@router.post("/v1/chat")
1920
async def proxy_chat(
2021
request: Request,
22+
x_engram_key: str = Header(...),
2123
x_engram_user_id: str = Header(...),
2224
x_engram_provider: str = Header(default="openai"),
2325
x_engram_disable_injection: bool = Header(default=False),
2426
x_engram_disable_extraction: bool = Header(default=False),
25-
db: asyncpg.Connection = Depends(get_db),
26-
user: asyncpg.Record = Depends(get_current_user),
2727
) -> Response:
2828
body = await parse_request_body(request)
29+
result = await build_proxy_response_with_available_auth(
30+
x_engram_key,
31+
x_engram_user_id,
32+
x_engram_provider,
33+
x_engram_disable_injection,
34+
x_engram_disable_extraction,
35+
body,
36+
request.headers,
37+
)
38+
return create_response(result)
39+
40+
41+
async def build_proxy_response_with_available_auth(
42+
api_key: str,
43+
requested_external_id: str,
44+
provider: str,
45+
disable_injection: bool,
46+
disable_extraction: bool,
47+
body: dict[str, object],
48+
headers: Mapping[str, str],
49+
) -> ProxyResult:
50+
try:
51+
pool = get_pool()
52+
except RuntimeError as error:
53+
logger.warning("Database pool unavailable, trying cached proxy auth: %s", error)
54+
return await build_cached_proxy_response(api_key, requested_external_id, provider, body, headers)
55+
acquire_context = pool.acquire()
2956
try:
57+
db = await acquire_context.__aenter__()
58+
except (asyncpg.PostgresError, OSError, ConnectionError, RuntimeError) as error:
59+
logger.warning("Database unavailable, trying cached proxy auth: %s", error)
60+
return await build_cached_proxy_response(api_key, requested_external_id, provider, body, headers)
61+
try:
62+
try:
63+
user = await get_user_by_api_key(api_key, db)
64+
except (asyncpg.PostgresError, OSError, ConnectionError) as error:
65+
logger.warning("Database auth unavailable, trying cached proxy auth: %s", error)
66+
return await build_cached_proxy_response(api_key, requested_external_id, provider, body, headers)
67+
if user is None:
68+
raise HTTPException(status_code=401, detail="Invalid API key")
3069
result = await build_proxy_result(
3170
user["id"],
3271
user["external_id"],
33-
x_engram_user_id,
72+
requested_external_id,
3473
body,
35-
x_engram_provider,
36-
x_engram_disable_injection,
37-
request.headers,
74+
provider,
75+
disable_injection,
76+
headers,
3877
db,
78+
int(user["max_memories_injected"]),
79+
float(user["retrieval_threshold"]),
3980
)
4081
except PermissionError as error:
4182
raise HTTPException(status_code=403, detail=str(error)) from error
4283
except ValueError as error:
4384
raise HTTPException(status_code=422, detail=str(error)) from error
4485
except RuntimeError as error:
4586
raise HTTPException(status_code=502, detail=str(error)) from error
46-
if not x_engram_disable_extraction and result.status_code < 400:
87+
finally:
88+
try:
89+
await acquire_context.__aexit__(None, None, None)
90+
except (asyncpg.PostgresError, OSError, ConnectionError) as error:
91+
logger.warning("Database connection release failed: %s", error)
92+
if not disable_extraction and result.status_code < 400:
4793
try:
48-
asyncio.create_task(run_extraction_task(user["id"], result.conversation_id, body, result.content))
94+
asyncio.create_task(run_extraction_task(user["id"], result.conversation_id, body, result.content, float(user["dedup_threshold"])))
4995
except Exception as error:
5096
logger.warning("Failed to schedule extraction: %s", error)
97+
return result
98+
99+
100+
async def build_cached_proxy_response(
101+
api_key: str,
102+
requested_external_id: str,
103+
provider: str,
104+
body: dict[str, object],
105+
headers: Mapping[str, str],
106+
) -> ProxyResult:
107+
user = get_cached_user_by_api_key(api_key)
108+
if user is None:
109+
raise HTTPException(status_code=503, detail="Database unavailable and API key is not cached")
110+
try:
111+
return await build_proxy_passthrough_result(str(user["external_id"]), requested_external_id, body, provider, headers)
112+
except PermissionError as error:
113+
raise HTTPException(status_code=403, detail=str(error)) from error
114+
except ValueError as error:
115+
raise HTTPException(status_code=422, detail=str(error)) from error
116+
except RuntimeError as error:
117+
raise HTTPException(status_code=502, detail=str(error)) from error
118+
119+
120+
def create_response(result: ProxyResult) -> Response:
51121
return Response(
52122
content=result.content,
53123
status_code=result.status_code,

api/routes/users.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from fastapi import APIRouter, Depends, HTTPException, Response, status
33

44
from api.dependencies import get_current_user, get_db, require_service_key
5-
from api.models.user import ServiceUserKeyCreate, UserCreate, UserCreateResponse, UserResponse
6-
from api.services.users import create_or_issue_user_key, create_user, delete_user
5+
from api.models.user import ServiceUserKeyCreate, UserConfigResponse, UserConfigUpdate, UserCreate, UserCreateResponse, UserResponse
6+
from api.services.users import create_or_issue_user_key, create_user, delete_user, get_user_config, regenerate_user_key, update_user_config
77

88

99
router = APIRouter()
@@ -47,6 +47,43 @@ async def get_current_user_route(user: asyncpg.Record = Depends(get_current_user
4747
}
4848

4949

50+
@router.get("/me/config", response_model=UserConfigResponse)
51+
async def get_current_user_config_route(
52+
user: asyncpg.Record = Depends(get_current_user),
53+
db: asyncpg.Connection = Depends(get_db),
54+
) -> dict[str, object]:
55+
return await get_user_config(user["id"], db)
56+
57+
58+
@router.patch("/me/config", response_model=UserConfigResponse)
59+
async def update_current_user_config_route(
60+
payload: UserConfigUpdate,
61+
user: asyncpg.Record = Depends(get_current_user),
62+
db: asyncpg.Connection = Depends(get_db),
63+
) -> dict[str, object]:
64+
return await update_user_config(
65+
user["id"],
66+
payload.max_memories_injected,
67+
payload.retrieval_threshold,
68+
payload.dedup_threshold,
69+
db,
70+
)
71+
72+
73+
@router.post("/me/api-key", response_model=UserCreateResponse)
74+
async def regenerate_current_user_key_route(
75+
user: asyncpg.Record = Depends(get_current_user),
76+
db: asyncpg.Connection = Depends(get_db),
77+
) -> dict[str, object]:
78+
row, api_key = await regenerate_user_key(user, db)
79+
return {
80+
"id": row["id"],
81+
"external_id": row["external_id"],
82+
"api_key": api_key,
83+
"created_at": row["created_at"],
84+
}
85+
86+
5087
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
5188
async def delete_current_user_route(
5289
user: asyncpg.Record = Depends(get_current_user),

api/services/deduplication.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ async def store_memory_with_deduplication(
1818
conversation_id: object | None,
1919
confidence: float,
2020
db: asyncpg.Connection,
21+
dedup_threshold: float | None = None,
2122
) -> StoreMemoryResult:
23+
dedup_threshold_value = settings.dedup_threshold if dedup_threshold is None else dedup_threshold
2224
vector_literal = format_embedding_for_pgvector(embedding)
2325
nearest = await db.fetchrow(
2426
"""
@@ -32,7 +34,7 @@ async def store_memory_with_deduplication(
3234
vector_literal,
3335
user_id,
3436
)
35-
if nearest is not None and nearest["score"] > settings.dedup_threshold:
37+
if nearest is not None and nearest["score"] > dedup_threshold_value:
3638
return {"action": "skipped", "memory": dict(nearest)}
3739
if nearest is not None and nearest["score"] > settings.memory_refinement_threshold:
3840
updated = await db.fetchrow(

0 commit comments

Comments
 (0)