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.
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/PKG-INFO +1 -1
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/pyproject.toml +1 -1
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/src/amfs_http/auth.py +22 -4
- amfs_http_server-0.3.4/src/amfs_http/openrouter_proxy.py +267 -0
- amfs_http_server-0.3.4/src/amfs_http/pro_provider.py +94 -0
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/src/amfs_http/server.py +1320 -81
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/src/amfs_http/sse.py +43 -0
- amfs_http_server-0.3.4/src/amfs_http/tenant_middleware.py +86 -0
- amfs_http_server-0.3.2/src/amfs_http/tenant_middleware.py +0 -48
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/.gitignore +0 -0
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/src/amfs_http/models.py +0 -0
- {amfs_http_server-0.3.2 → amfs_http_server-0.3.4}/src/amfs_http/pro_proxy.py +0 -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
|
-
|
|
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
|