amfs-http-server 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ node_modules/
12
+ .next/
13
+ !uv.lock
14
+ !pnpm-lock.yaml
15
+ .amfs/
16
+ test.py
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: amfs-http-server
3
+ Version: 0.1.0
4
+ Summary: AMFS HTTP/REST API server with SSE support
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: amfs
8
+ Requires-Dist: amfs-core
9
+ Requires-Dist: fastapi>=0.115
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: sse-starlette>=2.0
12
+ Requires-Dist: uvicorn[standard]>=0.32
13
+ Provides-Extra: pro
14
+ Requires-Dist: amfs-patterns; extra == 'pro'
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "amfs-http-server"
3
+ version = "0.1.0"
4
+ description = "AMFS HTTP/REST API server with SSE support"
5
+ requires-python = ">=3.11"
6
+ license = "Apache-2.0"
7
+ dependencies = [
8
+ "amfs",
9
+ "amfs-core",
10
+ "fastapi>=0.115",
11
+ "uvicorn[standard]>=0.32",
12
+ "sse-starlette>=2.0",
13
+ "httpx>=0.27",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ pro = ["amfs-patterns"]
18
+
19
+ [project.scripts]
20
+ amfs-http = "amfs_http.server:main"
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/amfs_http"]
@@ -0,0 +1 @@
1
+ """AMFS HTTP/REST API server — universal access to Agent Memory over HTTP."""
@@ -0,0 +1,84 @@
1
+ """API key authentication for the AMFS HTTP server."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import logging
6
+ import os
7
+ import secrets
8
+
9
+ from fastapi import HTTPException, Security, status
10
+ from fastapi.security import APIKeyHeader
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ API_KEY_HEADER = APIKeyHeader(name="X-AMFS-API-Key", auto_error=False)
15
+
16
+
17
+ def get_api_keys() -> set[str]:
18
+ raw = os.environ.get("AMFS_API_KEYS", "")
19
+ if not raw:
20
+ return set()
21
+ return {k.strip() for k in raw.split(",") if k.strip()}
22
+
23
+
24
+ def _check_db_key(api_key: str) -> bool:
25
+ """Check if an API key exists in amfs_api_keys table (active keys only).
26
+
27
+ Keys are stored as SHA-256 hex digests, so we hash the incoming raw key
28
+ before comparing.
29
+ """
30
+ try:
31
+ import psycopg
32
+ from psycopg.rows import dict_row
33
+
34
+ dsn = os.environ.get("AMFS_POSTGRES_DSN")
35
+ if not dsn:
36
+ return False
37
+
38
+ key_hash = hashlib.sha256(api_key.encode()).hexdigest()
39
+
40
+ with psycopg.connect(dsn, row_factory=dict_row) as conn:
41
+ row = conn.execute(
42
+ "SELECT id FROM amfs_api_keys "
43
+ "WHERE key_hash = %s AND active = TRUE "
44
+ "AND (expires_at IS NULL OR expires_at > NOW()) "
45
+ "LIMIT 1",
46
+ (key_hash,),
47
+ ).fetchone()
48
+ if row:
49
+ conn.execute(
50
+ "UPDATE amfs_api_keys SET last_used = NOW() WHERE id = %s",
51
+ (row["id"],),
52
+ )
53
+ return row is not None
54
+ except Exception:
55
+ logger.debug("DB API key check failed (table may not exist)", exc_info=True)
56
+ return False
57
+
58
+
59
+ def generate_api_key() -> str:
60
+ return f"amfs_{secrets.token_urlsafe(32)}"
61
+
62
+
63
+ async def verify_api_key(api_key: str | None = Security(API_KEY_HEADER)) -> str | None:
64
+ env_keys = get_api_keys()
65
+ if not env_keys:
66
+ if not os.environ.get("AMFS_POSTGRES_DSN"):
67
+ return None
68
+ if api_key and _check_db_key(api_key):
69
+ return api_key
70
+ return None
71
+
72
+ if api_key and api_key in env_keys:
73
+ return api_key
74
+
75
+ if api_key and _check_db_key(api_key):
76
+ return api_key
77
+
78
+ if api_key is None and not env_keys:
79
+ return None
80
+
81
+ raise HTTPException(
82
+ status_code=status.HTTP_401_UNAUTHORIZED,
83
+ detail="Invalid or missing API key",
84
+ )
@@ -0,0 +1,107 @@
1
+ """Request and response models for the AMFS HTTP API."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class WriteRequest(BaseModel):
11
+ entity_path: str
12
+ key: str
13
+ value: Any = None
14
+ confidence: float = 1.0
15
+ pattern_refs: list[str] = Field(default_factory=list)
16
+ memory_type: str = "fact"
17
+ shared: bool = True
18
+ branch: str = "main"
19
+
20
+
21
+ class OutcomeRequest(BaseModel):
22
+ outcome_ref: str
23
+ outcome_type: str
24
+ causal_entry_keys: list[str] | None = None
25
+ causal_confidence: float = 1.0
26
+
27
+
28
+ class SearchRequest(BaseModel):
29
+ query: str | None = None
30
+ entity_path: str | None = None
31
+ min_confidence: float = 0.0
32
+ max_confidence: float | None = None
33
+ agent_id: str | None = None
34
+ since: datetime | None = None
35
+ pattern_ref: str | None = None
36
+ limit: int = 100
37
+ sort_by: str = "confidence"
38
+ branch: str = "main"
39
+
40
+
41
+ class ContextRequest(BaseModel):
42
+ label: str
43
+ summary: str
44
+ source: str | None = None
45
+
46
+
47
+ class CreateAPIKeyRequest(BaseModel):
48
+ name: str
49
+ key_type: str = "agent"
50
+ scopes: list[dict[str, str]] = Field(default_factory=lambda: [{"pattern": "*", "permission": "read_write"}])
51
+ rate_limit_rpm: int = 120
52
+ expires_at: datetime | None = None
53
+
54
+
55
+ # ──────────────────────────────────────────────────────────────────────
56
+ # Teams (Pro)
57
+ # ──────────────────────────────────────────────────────────────────────
58
+
59
+
60
+ class CreateTeamRequest(BaseModel):
61
+ name: str
62
+ slug: str
63
+ description: str = ""
64
+
65
+
66
+ class UpdateTeamRequest(BaseModel):
67
+ name: str | None = None
68
+ description: str | None = None
69
+
70
+
71
+ class AddTeamMemberRequest(BaseModel):
72
+ email: str
73
+ display_name: str = ""
74
+ role: str = "developer"
75
+
76
+
77
+ class UpdateTeamMemberRequest(BaseModel):
78
+ role: str | None = None
79
+ display_name: str | None = None
80
+
81
+
82
+ # ──────────────────────────────────────────────────────────────────────
83
+ # Patterns (Pro)
84
+ # ──────────────────────────────────────────────────────────────────────
85
+
86
+
87
+ class RunPatternDetectionRequest(BaseModel):
88
+ incident_threshold: int = 2
89
+ stale_days: int = 30
90
+ hot_entity_stddev: float = 2.0
91
+ drift_stddev: float = 2.0
92
+ entity_path: str | None = None
93
+
94
+
95
+ # ──────────────────────────────────────────────────────────────────────
96
+ # Events (Shared Pool Ingestion)
97
+ # ──────────────────────────────────────────────────────────────────────
98
+
99
+
100
+ class EventRequest(BaseModel):
101
+ """Direct shared-pool event ingestion without connector framework."""
102
+
103
+ source: str
104
+ entity_path: str
105
+ key: str
106
+ value: Any = None
107
+ event_type: str = "generic"
@@ -0,0 +1,253 @@
1
+ """Pro SaaS proxy — forwards Pro feature requests to the hosted Pro API.
2
+
3
+ When AMFS_PRO_URL and AMFS_PRO_API_KEY are configured, this module:
4
+ 1. Mounts proxy routes for hot-context and anticipation endpoints
5
+ 2. Provides an async event forwarder for the CortexWorker
6
+ 3. Falls back gracefully when the Pro API is unreachable
7
+
8
+ Customer flow:
9
+ Dashboard → OSS HTTP Server (proxy) → Pro SaaS API
10
+ CortexWorker → Pro SaaS API (event forwarding)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import os
19
+ import threading
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ PRO_URL = os.environ.get("AMFS_PRO_URL", "").rstrip("/")
25
+ PRO_KEY = os.environ.get("AMFS_PRO_API_KEY", "")
26
+
27
+
28
+ def is_pro_configured() -> bool:
29
+ return bool(PRO_URL and PRO_KEY)
30
+
31
+
32
+ def _pro_headers() -> dict[str, str]:
33
+ return {
34
+ "Authorization": f"Bearer {PRO_KEY}",
35
+ "Content-Type": "application/json",
36
+ }
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────────────
40
+ # Route proxying — mount onto the FastAPI app
41
+ # ─────────────────────────────────────────────────────────────────────
42
+
43
+
44
+ def mount_pro_proxy(app) -> None:
45
+ """Mount proxy routes that forward Pro requests to the SaaS API."""
46
+ if not is_pro_configured():
47
+ return
48
+
49
+ import httpx
50
+ from fastapi import Depends, Query
51
+
52
+ _client = httpx.AsyncClient(timeout=10.0)
53
+
54
+ logger.info("Pro SaaS proxy enabled → %s", PRO_URL)
55
+
56
+ try:
57
+ from amfs_http.auth import verify_api_key
58
+ except ImportError:
59
+ async def verify_api_key():
60
+ return None
61
+
62
+ @app.get("/api/v1/cortex/hot-context")
63
+ async def proxy_hot_context(
64
+ agent_id: str | None = Query(None),
65
+ _auth: str | None = Depends(verify_api_key),
66
+ ) -> dict[str, Any]:
67
+ """Proxy hot-context request to Pro SaaS API."""
68
+ params = {}
69
+ if agent_id:
70
+ params["agent_id"] = agent_id
71
+ try:
72
+ resp = await _client.get(
73
+ f"{PRO_URL}/api/v1/pro/cortex/hot-context",
74
+ params=params,
75
+ headers=_pro_headers(),
76
+ )
77
+ return resp.json()
78
+ except Exception:
79
+ logger.debug("Pro API unreachable for hot-context", exc_info=True)
80
+ return {"agent_id": agent_id, "status": "unavailable", "focus_entities": [], "last_read_entities": []}
81
+
82
+ @app.get("/api/v1/cortex/anticipation")
83
+ async def proxy_anticipation(
84
+ entity_path: str | None = Query(None),
85
+ limit: int = Query(default=20, le=100),
86
+ _auth: str | None = Depends(verify_api_key),
87
+ ) -> dict[str, Any]:
88
+ """Fetch local digests, send to Pro SaaS for anticipation ranking."""
89
+ try:
90
+ from amfs_http.server import _cortex_worker
91
+ if _cortex_worker:
92
+ adapter = _cortex_worker._compiler._adapter
93
+ all_digests = adapter.list_digests()
94
+ digest_dicts = [d.model_dump(mode="json") for d in all_digests]
95
+ else:
96
+ digest_dicts = []
97
+
98
+ resp = await _client.post(
99
+ f"{PRO_URL}/api/v1/pro/cortex/rank",
100
+ headers=_pro_headers(),
101
+ json={
102
+ "digests": digest_dicts,
103
+ "entity_path": entity_path,
104
+ },
105
+ )
106
+ data = resp.json()
107
+ ranked = data.get("digests", [])[:limit]
108
+ return {"digests": ranked, "total": data.get("total", 0)}
109
+ except Exception:
110
+ logger.debug("Pro API unreachable for anticipation", exc_info=True)
111
+ return {"digests": [], "message": "Pro API unavailable"}
112
+
113
+ # --- Trace Pro actions (verify, replay, diff) ---
114
+
115
+ @app.post("/api/v1/pro/traces/{trace_id}/verify")
116
+ async def proxy_verify_trace(
117
+ trace_id: str,
118
+ _auth: str | None = Depends(verify_api_key),
119
+ ) -> dict[str, Any]:
120
+ """Proxy trace verification to Pro SaaS API."""
121
+ try:
122
+ resp = await _client.post(
123
+ f"{PRO_URL}/api/v1/pro/traces/{trace_id}/verify",
124
+ headers=_pro_headers(),
125
+ )
126
+ if resp.status_code == 404:
127
+ from fastapi import HTTPException
128
+ raise HTTPException(status_code=404, detail="Trace not found")
129
+ return resp.json()
130
+ except Exception as exc:
131
+ if hasattr(exc, "status_code"):
132
+ raise
133
+ logger.debug("Pro API unreachable for trace verify", exc_info=True)
134
+ return {"error": "Pro API unavailable"}
135
+
136
+ @app.post("/api/v1/pro/traces/{trace_id}/replay")
137
+ async def proxy_replay_trace(
138
+ trace_id: str,
139
+ _auth: str | None = Depends(verify_api_key),
140
+ ) -> dict[str, Any]:
141
+ """Proxy trace replay to Pro SaaS API."""
142
+ try:
143
+ resp = await _client.post(
144
+ f"{PRO_URL}/api/v1/pro/traces/{trace_id}/replay",
145
+ headers=_pro_headers(),
146
+ )
147
+ if resp.status_code == 404:
148
+ from fastapi import HTTPException
149
+ raise HTTPException(status_code=404, detail="Trace not found")
150
+ return resp.json()
151
+ except Exception as exc:
152
+ if hasattr(exc, "status_code"):
153
+ raise
154
+ logger.debug("Pro API unreachable for trace replay", exc_info=True)
155
+ return {"error": "Pro API unavailable"}
156
+
157
+ @app.post("/api/v1/pro/traces/{trace_id}/diff")
158
+ async def proxy_diff_trace(
159
+ trace_id: str,
160
+ _auth: str | None = Depends(verify_api_key),
161
+ ) -> dict[str, Any]:
162
+ """Proxy trace diff to Pro SaaS API."""
163
+ try:
164
+ resp = await _client.post(
165
+ f"{PRO_URL}/api/v1/pro/traces/{trace_id}/diff",
166
+ headers=_pro_headers(),
167
+ )
168
+ if resp.status_code == 404:
169
+ from fastapi import HTTPException
170
+ raise HTTPException(status_code=404, detail="Trace not found")
171
+ return resp.json()
172
+ except Exception as exc:
173
+ if hasattr(exc, "status_code"):
174
+ raise
175
+ logger.debug("Pro API unreachable for trace diff", exc_info=True)
176
+ return {"error": "Pro API unavailable"}
177
+
178
+ logger.info("Pro proxy routes mounted: hot-context, anticipation, trace-verify, trace-replay, trace-diff")
179
+
180
+
181
+ # ─────────────────────────────────────────────────────────────────────
182
+ # Event forwarding — used by CortexWorker to push events to Pro SaaS
183
+ # ─────────────────────────────────────────────────────────────────────
184
+
185
+
186
+ class ProEventForwarder:
187
+ """Async-safe event forwarder for the CortexWorker.
188
+
189
+ Buffers events and sends them in batches to the Pro SaaS API.
190
+ Runs a background thread with its own event loop so it works
191
+ from the synchronous CortexWorker.
192
+ """
193
+
194
+ def __init__(self, batch_size: int = 10, flush_interval: float = 2.0) -> None:
195
+ self._buffer: list[dict[str, Any]] = []
196
+ self._lock = threading.Lock()
197
+ self._batch_size = batch_size
198
+ self._flush_interval = flush_interval
199
+ self._stop = threading.Event()
200
+ self._thread: threading.Thread | None = None
201
+
202
+ def start(self) -> None:
203
+ self._thread = threading.Thread(target=self._run_loop, daemon=True, name="pro-event-forwarder")
204
+ self._thread.start()
205
+ logger.info("Pro event forwarder started → %s", PRO_URL)
206
+
207
+ def stop(self) -> None:
208
+ self._stop.set()
209
+
210
+ def enqueue(self, event: dict[str, Any]) -> None:
211
+ batch = None
212
+ with self._lock:
213
+ self._buffer.append(event)
214
+ if len(self._buffer) >= self._batch_size:
215
+ batch = self._buffer[:]
216
+ self._buffer.clear()
217
+ if batch:
218
+ self._send_batch(batch)
219
+
220
+ def _run_loop(self) -> None:
221
+ while not self._stop.is_set():
222
+ self._stop.wait(self._flush_interval)
223
+ with self._lock:
224
+ if not self._buffer:
225
+ continue
226
+ batch = self._buffer[:]
227
+ self._buffer.clear()
228
+ self._send_batch(batch)
229
+
230
+ def _send_batch(self, batch: list[dict[str, Any]]) -> None:
231
+ if not batch:
232
+ return
233
+ try:
234
+ import urllib.request
235
+ req = urllib.request.Request(
236
+ f"{PRO_URL}/api/v1/pro/cortex/ingest",
237
+ data=json.dumps({"events": batch}).encode(),
238
+ headers=_pro_headers(),
239
+ method="POST",
240
+ )
241
+ with urllib.request.urlopen(req, timeout=5) as resp:
242
+ logger.debug("Forwarded %d events to Pro API (status=%d)", len(batch), resp.status)
243
+ except Exception:
244
+ logger.debug("Failed to forward %d events to Pro API", len(batch), exc_info=True)
245
+
246
+
247
+ def create_forwarder() -> ProEventForwarder | None:
248
+ """Create and start a Pro event forwarder if Pro is configured."""
249
+ if not is_pro_configured():
250
+ return None
251
+ forwarder = ProEventForwarder()
252
+ forwarder.start()
253
+ return forwarder