amfs-http-server 0.3.2__tar.gz → 0.3.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amfs-http-server
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: AMFS HTTP/REST API server with SSE support
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amfs-http-server"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "AMFS HTTP/REST API server with SSE support"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
@@ -5,6 +5,8 @@ import hashlib
5
5
  import logging
6
6
  import os
7
7
  import secrets
8
+ import threading
9
+ import time
8
10
 
9
11
  from fastapi import HTTPException, Security, status
10
12
  from fastapi.security import APIKeyHeader
@@ -13,6 +15,10 @@ logger = logging.getLogger(__name__)
13
15
 
14
16
  API_KEY_HEADER = APIKeyHeader(name="X-AMFS-API-Key", auto_error=False)
15
17
 
18
+ _AUTH_CACHE_TTL = 120.0
19
+ _auth_cache: dict[str, float] = {}
20
+ _auth_cache_lock = threading.Lock()
21
+
16
22
 
17
23
  def get_api_keys() -> set[str]:
18
24
  raw = os.environ.get("AMFS_API_KEYS", "")
@@ -25,12 +31,22 @@ def _check_db_key(api_key: str) -> bool:
25
31
  """Check if an API key exists in amfs_api_keys table (active keys only).
26
32
 
27
33
  Keys are stored as SHA-256 hex digests, so we hash the incoming raw key
28
- before comparing.
34
+ before comparing. Results are cached in-process for ``_AUTH_CACHE_TTL``
35
+ seconds to avoid opening a new DB connection on every request. Only
36
+ positive results are cached; revoked/invalid keys always hit the DB.
29
37
 
30
38
  Uses SECURITY DEFINER functions to bypass RLS — this connection does not
31
39
  set amfs.current_account_id, so direct table queries would return nothing
32
40
  when FORCE ROW LEVEL SECURITY is enabled.
33
41
  """
42
+ key_hash = hashlib.sha256(api_key.encode()).hexdigest()
43
+ now = time.monotonic()
44
+
45
+ with _auth_cache_lock:
46
+ cached_at = _auth_cache.get(key_hash)
47
+ if cached_at is not None and (now - cached_at) < _AUTH_CACHE_TTL:
48
+ return True
49
+
34
50
  try:
35
51
  import psycopg
36
52
  from psycopg.rows import dict_row
@@ -39,8 +55,6 @@ def _check_db_key(api_key: str) -> bool:
39
55
  if not dsn:
40
56
  return False
41
57
 
42
- key_hash = hashlib.sha256(api_key.encode()).hexdigest()
43
-
44
58
  with psycopg.connect(dsn, row_factory=dict_row) as conn:
45
59
  row = conn.execute(
46
60
  "SELECT * FROM amfs_authenticate_api_key(%s)",
@@ -51,7 +65,11 @@ def _check_db_key(api_key: str) -> bool:
51
65
  "SELECT amfs_touch_api_key_usage(%s)",
52
66
  (row["key_id"],),
53
67
  )
54
- return row is not None
68
+ if row is not None:
69
+ with _auth_cache_lock:
70
+ _auth_cache[key_hash] = now
71
+ return True
72
+ return False
55
73
  except Exception:
56
74
  logger.debug("DB API key check failed (table may not exist)", exc_info=True)
57
75
  return False
@@ -0,0 +1,267 @@
1
+ """Memory-augmenting OpenRouter proxy (OpenAI-compatible chat completions).
2
+
3
+ This is the "Option C" proxy: a drop-in replacement for the OpenRouter chat
4
+ completions endpoint that (1) retrieves relevant memory for the caller's scope
5
+ and injects it as context, (2) forwards the request to OpenRouter unchanged
6
+ otherwise, and (3) writes salient facts from the exchange back to memory and
7
+ seals a decision trace — all without the client changing anything but the base
8
+ URL.
9
+
10
+ Design principles:
11
+ - Fail-open: any memory error (retrieval, injection, write-back) must never
12
+ break the LLM call. On failure we forward the original request untouched.
13
+ - Non-blocking: memory retrieval and write-back are synchronous SDK calls, so
14
+ they run in a threadpool (``asyncio.to_thread``) to keep the event loop free.
15
+ Write-back and trace sealing are fire-and-forget background tasks.
16
+ - Streaming: ``stream: true`` is passed through as an SSE byte stream.
17
+
18
+ Configuration (env):
19
+ - OPENROUTER_API_KEY upstream key (required to enable the route)
20
+ - OPENROUTER_BASE_URL default https://openrouter.ai/api/v1
21
+ - AMFS_PROXY_INJECT_LIMIT max memory facts injected (default 5)
22
+ - AMFS_PROXY_WRITE_BACK "true" to enable fact write-back (default false)
23
+ - AMFS_PROXY_SAFETY_GATE "false" to disable the Pro SafetyGate on write-back
24
+ (default true; no-op when amfs_safety is absent)
25
+
26
+ TODO(integration-test): tests/integration/test_openrouter_proxy.py exercises the
27
+ inject/forward/write-back path against a mocked upstream; it is skipped in unit
28
+ CI (requires fastapi TestClient + httpx-mock) and is unvalidated here.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import logging
36
+ import os
37
+ from typing import Any, Callable
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/")
42
+ API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
43
+ INJECT_LIMIT = int(os.environ.get("AMFS_PROXY_INJECT_LIMIT", "5"))
44
+ WRITE_BACK = os.environ.get("AMFS_PROXY_WRITE_BACK", "false").lower() == "true"
45
+ # Auto-run the Pro SafetyGate on write-back when available (secret block + PII
46
+ # redact). Disabled explicitly with AMFS_PROXY_SAFETY_GATE=false. Soft/optional:
47
+ # the gate lives in the Pro ``amfs_safety`` package; OSS-only deployments skip it.
48
+ SAFETY_GATE = os.environ.get("AMFS_PROXY_SAFETY_GATE", "true").lower() == "true"
49
+
50
+ _SAFETY_GATE_CACHE: dict[str, Any] = {}
51
+
52
+
53
+ def _gate_value(mem: Any, scope: str, key: str, value: Any) -> tuple[bool, Any]:
54
+ """Run the optional Pro SafetyGate over a proposed write-back value.
55
+
56
+ Returns ``(allowed, value_to_write)`` — the value is redacted when the gate
57
+ masks sensitive content. Fail-open: any import/build/scan error allows the
58
+ original value through (the LLM exchange must never be lost to a gate bug).
59
+ """
60
+ if not SAFETY_GATE:
61
+ return True, value
62
+ try:
63
+ if "gate" not in _SAFETY_GATE_CACHE:
64
+ from amfs_safety import SafetyGate # Pro package, optional
65
+
66
+ adapter = getattr(mem, "_adapter", None)
67
+ _SAFETY_GATE_CACHE["gate"] = SafetyGate(adapter=adapter)
68
+ gate = _SAFETY_GATE_CACHE["gate"]
69
+
70
+ from datetime import datetime, timezone
71
+
72
+ from amfs_core.models import MemoryEntry, Provenance
73
+
74
+ entry = MemoryEntry(
75
+ entity_path=scope,
76
+ key=key,
77
+ value=value,
78
+ provenance=Provenance(
79
+ agent_id="openrouter-proxy",
80
+ session_id="proxy",
81
+ written_at=datetime.now(timezone.utc),
82
+ ),
83
+ confidence=0.5,
84
+ )
85
+ decision = gate.check_write(entry)
86
+ if not decision.allowed:
87
+ logger.info("proxy: write-back blocked by SafetyGate (%s)", decision.max_severity)
88
+ return False, value
89
+ return True, decision.entry.value
90
+ except Exception: # noqa: BLE001 - fail-open
91
+ logger.debug("proxy: SafetyGate unavailable/failed; allowing write", exc_info=True)
92
+ return True, value
93
+
94
+ _MEMORY_HEADER = (
95
+ "You have access to the following facts from persistent memory. "
96
+ "Treat them as authoritative context; if none are relevant, ignore them.\n"
97
+ )
98
+
99
+
100
+ def is_configured() -> bool:
101
+ return bool(API_KEY)
102
+
103
+
104
+ def _last_user_text(messages: list[dict[str, Any]]) -> str:
105
+ for msg in reversed(messages):
106
+ if msg.get("role") == "user":
107
+ content = msg.get("content")
108
+ if isinstance(content, str):
109
+ return content
110
+ if isinstance(content, list): # OpenAI content-parts form
111
+ return " ".join(p.get("text", "") for p in content if isinstance(p, dict))
112
+ return ""
113
+
114
+
115
+ def _retrieve_facts(get_memory: Callable[[], Any], scope: str | None, query: str) -> list[str]:
116
+ """Best-effort memory retrieval. Returns formatted fact strings (fail-open)."""
117
+ if not query:
118
+ return []
119
+ try:
120
+ mem = get_memory()
121
+ except Exception: # noqa: BLE001
122
+ logger.debug("proxy: get_memory failed", exc_info=True)
123
+ return []
124
+ # Prefer the Pro MultiStrategyRetriever (adaptive RRF + rerank) when the
125
+ # amfs_retrieval package is installed; otherwise SDK full-text/semantic
126
+ # search; otherwise list the scope. All fail-open.
127
+ try:
128
+ from amfs_http.pro_provider import build_pro_retriever, pro_retrieve_values
129
+
130
+ adapter = getattr(mem, "_adapter", None)
131
+ retriever = build_pro_retriever(adapter)
132
+ pro_values = pro_retrieve_values(retriever, scope, query, INJECT_LIMIT)
133
+ if pro_values is not None:
134
+ return [
135
+ v if isinstance(v, str) else json.dumps(v, default=str)
136
+ for v in pro_values
137
+ ][:INJECT_LIMIT]
138
+ except Exception: # noqa: BLE001 - Pro path optional, fall through to OSS
139
+ logger.debug("proxy: pro retrieval skipped", exc_info=True)
140
+
141
+ # Prefer full-text/semantic search; fall back to listing the scope.
142
+ try:
143
+ search = getattr(mem, "search", None)
144
+ if callable(search):
145
+ results = search(query=query, entity_path=scope, limit=INJECT_LIMIT)
146
+ else:
147
+ results = mem._adapter.list(scope)[:INJECT_LIMIT]
148
+ except Exception: # noqa: BLE001
149
+ logger.debug("proxy: memory search failed", exc_info=True)
150
+ return []
151
+ facts: list[str] = []
152
+ for r in results or []:
153
+ value = getattr(r, "value", None)
154
+ if value is None and isinstance(r, dict):
155
+ value = r.get("value")
156
+ if value is not None:
157
+ facts.append(value if isinstance(value, str) else json.dumps(value, default=str))
158
+ return facts[:INJECT_LIMIT]
159
+
160
+
161
+ def _inject(messages: list[dict[str, Any]], facts: list[str]) -> list[dict[str, Any]]:
162
+ if not facts:
163
+ return messages
164
+ block = _MEMORY_HEADER + "\n".join(f"- {f}" for f in facts)
165
+ # Prepend as a system message so it does not overwrite the caller's system prompt.
166
+ return [{"role": "system", "content": block}, *messages]
167
+
168
+
169
+ def _write_back(get_memory: Callable[[], Any], scope: str | None,
170
+ query: str, answer: str) -> None:
171
+ """Persist a salient fact from the exchange + seal a trace (best-effort)."""
172
+ if not WRITE_BACK or not scope or not answer:
173
+ return
174
+ try:
175
+ mem = get_memory()
176
+ key = f"proxy-exchange-{abs(hash(query)) % 10_000_000}"
177
+ value = {"q": query[:500], "a": answer[:1500]}
178
+ allowed, value = _gate_value(mem, scope, key, value)
179
+ if not allowed:
180
+ return
181
+ mem.write(scope, key, value, confidence=0.5)
182
+ commit = getattr(mem, "commit_outcome", None)
183
+ if callable(commit):
184
+ commit("proxy-exchange", "success")
185
+ except Exception: # noqa: BLE001
186
+ logger.debug("proxy: write-back failed", exc_info=True)
187
+
188
+
189
+ def mount_openrouter_proxy(app, *, get_memory: Callable[[], Any]) -> None:
190
+ """Mount POST /api/v1/proxy/chat/completions if OpenRouter is configured."""
191
+ if not is_configured():
192
+ logger.info("OpenRouter proxy disabled (OPENROUTER_API_KEY not set)")
193
+ return
194
+
195
+ import httpx
196
+ from fastapi import Request
197
+ from fastapi.responses import JSONResponse, StreamingResponse
198
+
199
+ client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
200
+
201
+ def _scope_of(request: Request, body: dict[str, Any]) -> str | None:
202
+ return (body.get("amfs_scope")
203
+ or request.headers.get("X-AMFS-Scope")
204
+ or os.environ.get("AMFS_PROXY_DEFAULT_SCOPE"))
205
+
206
+ def _upstream_headers(request: Request) -> dict[str, str]:
207
+ headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
208
+ # Pass through OpenRouter attribution headers when present.
209
+ for h in ("HTTP-Referer", "X-Title"):
210
+ if h in request.headers:
211
+ headers[h] = request.headers[h]
212
+ return headers
213
+
214
+ @app.post("/api/v1/proxy/chat/completions")
215
+ async def proxy_chat_completions(request: Request):
216
+ raw = await request.body()
217
+ try:
218
+ body = json.loads(raw)
219
+ except Exception:
220
+ return JSONResponse({"error": "invalid JSON body"}, status_code=400)
221
+
222
+ scope = _scope_of(request, body)
223
+ messages = body.get("messages") or []
224
+ query = _last_user_text(messages)
225
+
226
+ # 1) Retrieve + inject memory (fail-open, off the event loop).
227
+ try:
228
+ facts = await asyncio.to_thread(_retrieve_facts, get_memory, scope, query)
229
+ if facts:
230
+ body = {**body, "messages": _inject(messages, facts)}
231
+ except Exception: # noqa: BLE001
232
+ logger.debug("proxy: injection skipped", exc_info=True)
233
+
234
+ body.pop("amfs_scope", None)
235
+ url = f"{BASE_URL}/chat/completions"
236
+ headers = _upstream_headers(request)
237
+
238
+ # 2a) Streaming: pass the SSE stream straight through.
239
+ if body.get("stream"):
240
+ async def _iter():
241
+ async with client.stream("POST", url, headers=headers, json=body) as resp:
242
+ async for chunk in resp.aiter_raw():
243
+ yield chunk
244
+ # NOTE: write-back on streamed responses requires accumulating the
245
+ # SSE deltas; deferred (TODO) to keep streaming zero-overhead.
246
+ return StreamingResponse(_iter(), media_type="text/event-stream")
247
+
248
+ # 2b) Non-streaming: forward, then schedule background write-back.
249
+ try:
250
+ resp = await client.post(url, headers=headers, json=body)
251
+ except Exception: # noqa: BLE001
252
+ logger.warning("proxy: upstream request failed", exc_info=True)
253
+ return JSONResponse({"error": "upstream unavailable"}, status_code=502)
254
+
255
+ data = resp.json() if resp.content else {}
256
+ try:
257
+ answer = data["choices"][0]["message"]["content"]
258
+ except Exception: # noqa: BLE001
259
+ answer = ""
260
+
261
+ if WRITE_BACK and answer:
262
+ asyncio.create_task(
263
+ asyncio.to_thread(_write_back, get_memory, scope, query, answer))
264
+
265
+ return JSONResponse(data, status_code=resp.status_code)
266
+
267
+ logger.info("OpenRouter memory proxy mounted → POST /api/v1/proxy/chat/completions")
@@ -0,0 +1,94 @@
1
+ """Optional Pro retrieval/sealing provider for the OpenRouter proxy.
2
+
3
+ The OSS proxy retrieves memory with the plain SDK ``search`` (full-text / list).
4
+ When the Pro ``amfs_retrieval`` package is installed, this module upgrades that to
5
+ the ``MultiStrategyRetriever`` (adaptive-weighted RRF + optional cross-encoder
6
+ rerank over a real default embedder), which is what wins the precision/adversarial
7
+ benchmarks. When ``amfs_traces`` is installed and sealing is enabled, write-backs
8
+ can be sealed into a signed ``ImmutableDecisionTrace``.
9
+
10
+ Everything here is soft/optional and fail-open:
11
+ - OSS-only deployments (no Pro packages) get ``None`` and the proxy falls back to
12
+ SDK search — no error, no behaviour change.
13
+ - Any construction/among failure degrades to the OSS path.
14
+
15
+ Env:
16
+ - AMFS_PROXY_PRO_RETRIEVAL "false" to force the OSS search path (default true)
17
+ - AMFS_PROXY_PRO_RERANK "false" to skip cross-encoder rerank (default true)
18
+
19
+ NOTE(integration-test): the Pro retrieval path requires the amfs-internal packages
20
+ and a populated adapter; it is exercised in the amfs-internal retrieval eval, not
21
+ in OSS unit CI. The unit test here only covers graceful absence + value extraction.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ from typing import Any
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ PRO_RETRIEVAL = os.environ.get("AMFS_PROXY_PRO_RETRIEVAL", "true").lower() == "true"
33
+ PRO_RERANK = os.environ.get("AMFS_PROXY_PRO_RERANK", "true").lower() == "true"
34
+
35
+ _cache: dict[str, Any] = {}
36
+
37
+
38
+ def build_pro_retriever(adapter: Any) -> Any | None:
39
+ """Build a Pro MultiStrategyRetriever over ``adapter``, or None if unavailable.
40
+
41
+ Cached on the adapter identity so the embedder/reranker are loaded once.
42
+ """
43
+ if not PRO_RETRIEVAL or adapter is None:
44
+ return None
45
+ cache_key = f"retriever:{id(adapter)}"
46
+ if cache_key in _cache:
47
+ return _cache[cache_key]
48
+ try:
49
+ from amfs_retrieval import MultiStrategyRetriever, create_pro_embedder
50
+
51
+ reranker = None
52
+ if PRO_RERANK:
53
+ try:
54
+ from amfs_retrieval import CrossEncoderReranker
55
+
56
+ reranker = CrossEncoderReranker()
57
+ except Exception: # noqa: BLE001 - rerank optional
58
+ logger.debug("pro_provider: reranker unavailable", exc_info=True)
59
+
60
+ retriever = MultiStrategyRetriever(
61
+ adapter, embedder=create_pro_embedder(), reranker=reranker
62
+ )
63
+ _cache[cache_key] = retriever
64
+ logger.info("pro_provider: Pro MultiStrategyRetriever enabled for proxy")
65
+ return retriever
66
+ except Exception: # noqa: BLE001 - Pro package absent or failed to build
67
+ logger.debug("pro_provider: Pro retrieval unavailable", exc_info=True)
68
+ _cache[cache_key] = None
69
+ return None
70
+
71
+
72
+ def _result_value(result: Any) -> Any:
73
+ """Extract the stored value from a RetrievalResult-like object or dict."""
74
+ entry = getattr(result, "entry", None)
75
+ if entry is None and isinstance(result, dict):
76
+ entry = result.get("entry", result)
77
+ if isinstance(entry, dict):
78
+ return entry.get("value")
79
+ return getattr(entry, "value", None)
80
+
81
+
82
+ def pro_retrieve_values(
83
+ retriever: Any, scope: str | None, query: str, limit: int
84
+ ) -> list[Any] | None:
85
+ """Run Pro retrieval and return raw stored values (fail-open -> None)."""
86
+ if retriever is None or not query:
87
+ return None
88
+ try:
89
+ results = retriever.retrieve(query, entity_path=scope, limit=limit)
90
+ except Exception: # noqa: BLE001
91
+ logger.debug("pro_provider: retrieve failed", exc_info=True)
92
+ return None
93
+ values = [v for v in (_result_value(r) for r in results or []) if v is not None]
94
+ return values or None