superlocalmemory 3.6.8 → 3.6.10
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.
- package/CHANGELOG.md +83 -0
- package/README.md +8 -4
- package/package.json +1 -1
- package/pyproject.toml +6 -1
- package/src/superlocalmemory/__init__.py +6 -2
- package/src/superlocalmemory/cli/compress_cmd.py +32 -70
- package/src/superlocalmemory/cli/daemon.py +25 -3
- package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
- package/src/superlocalmemory/cli/setup_wizard.py +49 -0
- package/src/superlocalmemory/core/config.py +28 -0
- package/src/superlocalmemory/core/engine.py +15 -4
- package/src/superlocalmemory/core/health_monitor.py +32 -9
- package/src/superlocalmemory/mcp/agent_context.py +111 -0
- package/src/superlocalmemory/mcp/tools_active.py +47 -12
- package/src/superlocalmemory/mcp/tools_core.py +22 -2
- package/src/superlocalmemory/mcp/tools_mesh.py +37 -38
- package/src/superlocalmemory/optimize/cache/boundary_store.py +23 -9
- package/src/superlocalmemory/optimize/cache/exact.py +7 -4
- package/src/superlocalmemory/optimize/cache/key_builder.py +13 -0
- package/src/superlocalmemory/optimize/cache/manager.py +70 -8
- package/src/superlocalmemory/optimize/cache/semantic.py +10 -5
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +1 -7
- package/src/superlocalmemory/optimize/compress/router.py +82 -87
- package/src/superlocalmemory/optimize/config/__init__.py +16 -0
- package/src/superlocalmemory/optimize/config/defaults.py +1 -6
- package/src/superlocalmemory/optimize/config/schema.py +2 -19
- package/src/superlocalmemory/optimize/config/store.py +15 -1
- package/src/superlocalmemory/optimize/metrics/counters.py +15 -7
- package/src/superlocalmemory/optimize/proxy/_helpers.py +100 -2
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +12 -0
- package/src/superlocalmemory/optimize/proxy/capture.py +243 -0
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +31 -0
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +12 -0
- package/src/superlocalmemory/optimize/proxy/server.py +29 -0
- package/src/superlocalmemory/optimize/storage/db.py +78 -11
- package/src/superlocalmemory/optimize/storage/schema.py +11 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -3
- package/src/superlocalmemory/server/routes/optimize.py +6 -8
- package/src/superlocalmemory/server/unified_daemon.py +68 -11
- package/src/superlocalmemory/ui/index.html +18 -14
- package/src/superlocalmemory/ui/js/auto-settings.js +3 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +3 -0
- package/src/superlocalmemory/ui/js/optimize.js +9 -9
- package/src/superlocalmemory.egg-info/PKG-INFO +10 -5
- package/src/superlocalmemory.egg-info/SOURCES.txt +2 -2
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
- package/src/superlocalmemory/optimize/compress/extractive_code.py +0 -311
- package/src/superlocalmemory/optimize/compress/extractive_json.py +0 -72
|
@@ -358,6 +358,7 @@ async def _stream_and_cache_forward(
|
|
|
358
358
|
body_bytes: bytes,
|
|
359
359
|
upstream_url: str,
|
|
360
360
|
on_complete: "Callable[[bytes], Any] | None" = None,
|
|
361
|
+
max_accumulate: int | None = None,
|
|
361
362
|
) -> Response | StreamingResponse:
|
|
362
363
|
"""Stream-forward with optional post-stream cache-store callback.
|
|
363
364
|
|
|
@@ -388,10 +389,12 @@ async def _stream_and_cache_forward(
|
|
|
388
389
|
)
|
|
389
390
|
|
|
390
391
|
acc: list[bytes] = []
|
|
392
|
+
acc_bytes = 0
|
|
393
|
+
acc_capped = False
|
|
391
394
|
complete_called = False
|
|
392
395
|
|
|
393
396
|
async def _generate() -> AsyncIterator[bytes]:
|
|
394
|
-
nonlocal complete_called
|
|
397
|
+
nonlocal complete_called, acc_bytes, acc_capped
|
|
395
398
|
stream_error = False
|
|
396
399
|
try:
|
|
397
400
|
async with proxy.http_client.stream(
|
|
@@ -399,7 +402,17 @@ async def _stream_and_cache_forward(
|
|
|
399
402
|
) as upstream_resp:
|
|
400
403
|
async for chunk in upstream_resp.aiter_bytes():
|
|
401
404
|
if chunk:
|
|
402
|
-
|
|
405
|
+
# Always forward to the client; only bound what we hold
|
|
406
|
+
# in memory for the on_complete callback (CWE-400).
|
|
407
|
+
if max_accumulate is None or acc_bytes < max_accumulate:
|
|
408
|
+
acc.append(chunk)
|
|
409
|
+
acc_bytes += len(chunk)
|
|
410
|
+
elif not acc_capped:
|
|
411
|
+
acc_capped = True
|
|
412
|
+
logger.debug(
|
|
413
|
+
"[%s] stream accumulator capped at %d bytes",
|
|
414
|
+
request_id, max_accumulate,
|
|
415
|
+
)
|
|
403
416
|
yield chunk
|
|
404
417
|
except httpx.RemoteProtocolError as exc:
|
|
405
418
|
stream_error = True
|
|
@@ -447,6 +460,91 @@ async def _stream_and_cache_forward(
|
|
|
447
460
|
)
|
|
448
461
|
|
|
449
462
|
|
|
463
|
+
async def capture_passthrough_forward(
|
|
464
|
+
proxy: Any,
|
|
465
|
+
request: Request,
|
|
466
|
+
*,
|
|
467
|
+
provider: str,
|
|
468
|
+
upstream_url: str,
|
|
469
|
+
allowed_headers: frozenset,
|
|
470
|
+
request_id: str,
|
|
471
|
+
model_hint: str = "",
|
|
472
|
+
sse_parser: "Callable[[bytes], bytes | None] | None" = None,
|
|
473
|
+
is_stream: bool = False,
|
|
474
|
+
) -> Response | StreamingResponse:
|
|
475
|
+
"""Shadow-capture passthrough (v3.6.10, plan §7).
|
|
476
|
+
|
|
477
|
+
Pure passthrough to upstream + record the exchange to the capture corpus.
|
|
478
|
+
NO cache, NO compression — capture mode observes only authentic traffic.
|
|
479
|
+
Fail-open: a capture or forward error degrades to a normal forward/error
|
|
480
|
+
response; the user's request is never blocked by capture.
|
|
481
|
+
"""
|
|
482
|
+
from superlocalmemory.optimize.proxy.capture import (
|
|
483
|
+
extract_usage,
|
|
484
|
+
record_exchange_async,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
body_bytes = await request.body()
|
|
488
|
+
fwd_headers = _build_forward_headers(request, allowed_headers)
|
|
489
|
+
fwd_headers["content-length"] = str(len(body_bytes))
|
|
490
|
+
|
|
491
|
+
if is_stream:
|
|
492
|
+
async def _on_complete(acc: bytes) -> None:
|
|
493
|
+
parsed = sse_parser(acc) if sse_parser else None
|
|
494
|
+
payload = parsed if parsed is not None else acc
|
|
495
|
+
itok, otok, mdl = extract_usage(provider, parsed)
|
|
496
|
+
await record_exchange_async(
|
|
497
|
+
provider=provider,
|
|
498
|
+
model=mdl or model_hint,
|
|
499
|
+
request_body=body_bytes,
|
|
500
|
+
response_body=payload,
|
|
501
|
+
content_type="text/event-stream",
|
|
502
|
+
input_tokens=itok,
|
|
503
|
+
output_tokens=otok,
|
|
504
|
+
status_code=200,
|
|
505
|
+
stream=True,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Bound the in-memory accumulator (CWE-400): the corpus only keeps the
|
|
509
|
+
# first 1 MB per side anyway, so cap accumulation there.
|
|
510
|
+
from superlocalmemory.optimize.proxy.capture import _MAX_CAPTURE_BODY_BYTES
|
|
511
|
+
return await _stream_and_cache_forward(
|
|
512
|
+
proxy, request_id, fwd_headers, body_bytes, upstream_url,
|
|
513
|
+
on_complete=_on_complete,
|
|
514
|
+
max_accumulate=_MAX_CAPTURE_BODY_BYTES,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
if proxy.http_client is None:
|
|
518
|
+
return await _fail_open_forward(proxy, request, upstream_url)
|
|
519
|
+
try:
|
|
520
|
+
upstream_resp = await proxy.http_client.post(
|
|
521
|
+
upstream_url, content=body_bytes, headers=fwd_headers,
|
|
522
|
+
)
|
|
523
|
+
except Exception as exc:
|
|
524
|
+
logger.error("[%s] capture passthrough upstream error: %r", request_id, exc)
|
|
525
|
+
return await _fail_open_forward(proxy, request, upstream_url)
|
|
526
|
+
|
|
527
|
+
resp_bytes = upstream_resp.content
|
|
528
|
+
itok, otok, mdl = extract_usage(provider, resp_bytes)
|
|
529
|
+
await record_exchange_async(
|
|
530
|
+
provider=provider,
|
|
531
|
+
model=mdl or model_hint,
|
|
532
|
+
request_body=body_bytes,
|
|
533
|
+
response_body=resp_bytes,
|
|
534
|
+
content_type="application/json",
|
|
535
|
+
input_tokens=itok,
|
|
536
|
+
output_tokens=otok,
|
|
537
|
+
status_code=upstream_resp.status_code,
|
|
538
|
+
stream=False,
|
|
539
|
+
)
|
|
540
|
+
return Response(
|
|
541
|
+
content=resp_bytes,
|
|
542
|
+
status_code=upstream_resp.status_code,
|
|
543
|
+
media_type="application/json",
|
|
544
|
+
headers=_filter_response_headers(dict(upstream_resp.headers)),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
450
548
|
async def _safe_cache_check(hooks: HookChain, ctx: ProxyRequest) -> CachedResponse:
|
|
451
549
|
try:
|
|
452
550
|
result = hooks.cache.check(ctx)
|
|
@@ -25,7 +25,9 @@ from superlocalmemory.optimize.proxy._helpers import (
|
|
|
25
25
|
_safe_compress,
|
|
26
26
|
_stream_and_cache_forward,
|
|
27
27
|
_stream_forward,
|
|
28
|
+
capture_passthrough_forward,
|
|
28
29
|
)
|
|
30
|
+
from superlocalmemory.optimize.proxy.capture import capture_enabled
|
|
29
31
|
from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
|
|
30
32
|
|
|
31
33
|
logger = logging.getLogger("slm.optimize.proxy.anthropic")
|
|
@@ -192,6 +194,16 @@ async def handle_messages(proxy: object, request: Request) -> Response:
|
|
|
192
194
|
|
|
193
195
|
stream = bool(body.get("stream", False))
|
|
194
196
|
has_tools = _body_has_tools(body)
|
|
197
|
+
|
|
198
|
+
# v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
|
|
199
|
+
if capture_enabled():
|
|
200
|
+
return await capture_passthrough_forward(
|
|
201
|
+
proxy, request, provider="anthropic", upstream_url=upstream_url,
|
|
202
|
+
allowed_headers=_ANTHROPIC_FORWARD_HEADERS, request_id=request_id,
|
|
203
|
+
model_hint=str(body.get("model", "")),
|
|
204
|
+
sse_parser=_parse_sse_to_json, is_stream=stream,
|
|
205
|
+
)
|
|
206
|
+
|
|
195
207
|
ctx = ProxyRequest(
|
|
196
208
|
provider="anthropic",
|
|
197
209
|
method="POST",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""capture.py — Lossless shadow-capture of real proxy traffic (v3.6.10, plan §7).
|
|
2
|
+
|
|
3
|
+
Purpose: build a dogfood corpus of real {request, response, model, tokens,
|
|
4
|
+
content_type} pairs so the cache + compression benchmark (benchmarks/optimize/)
|
|
5
|
+
can be replayed against authentic traffic instead of only synthetic prompts.
|
|
6
|
+
|
|
7
|
+
Activation: set ``SLM_OPTIMIZE_CAPTURE=1`` in the daemon's environment. When on:
|
|
8
|
+
* the proxy runs in PURE PASSTHROUGH — cache + compression hooks are disabled
|
|
9
|
+
at load time (see server._load_hooks), so capture never observes a mutated
|
|
10
|
+
request or a cache hit; every line is a genuine upstream exchange.
|
|
11
|
+
* each completed exchange is appended as one JSON line to
|
|
12
|
+
``~/.superlocalmemory/optimize_capture.jsonl`` (0600, gitignored).
|
|
13
|
+
|
|
14
|
+
ISOLATION GUARANTEE: this module writes ONLY to optimize_capture.jsonl. It never
|
|
15
|
+
opens memory.db, llmcache.db, or any SLM memory store. (Plan §9 hard rule.)
|
|
16
|
+
|
|
17
|
+
FAIL-OPEN: a capture failure (disk full, permission, encode error) is logged and
|
|
18
|
+
swallowed — it MUST NOT break the proxied request the user is waiting on.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import threading
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("slm.optimize.proxy.capture")
|
|
32
|
+
|
|
33
|
+
_CAPTURE_DIRNAME = ".superlocalmemory"
|
|
34
|
+
_CAPTURE_FILENAME = "optimize_capture.jsonl"
|
|
35
|
+
_CAPTURE_ENV = "SLM_OPTIMIZE_CAPTURE"
|
|
36
|
+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
37
|
+
|
|
38
|
+
# Cap a single captured body so a pathological 10 MB request can't bloat the
|
|
39
|
+
# corpus line beyond what the replay harness will read back. Bodies above this
|
|
40
|
+
# are recorded truncated with a marker (capture is for benchmarking, not audit).
|
|
41
|
+
_MAX_CAPTURE_BODY_BYTES = 1 * 1024 * 1024 # 1 MB per side
|
|
42
|
+
|
|
43
|
+
# Providers whose response bodies follow the OpenAI usage schema
|
|
44
|
+
# ({"usage": {"prompt_tokens", "completion_tokens"}, "model"}).
|
|
45
|
+
_OPENAI_FORMAT_PROVIDERS = frozenset({"openai", "gemini-openai-compat"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def capture_enabled() -> bool:
|
|
49
|
+
"""True iff ``SLM_OPTIMIZE_CAPTURE`` is set to a truthy value."""
|
|
50
|
+
return os.environ.get(_CAPTURE_ENV, "").strip().lower() in _TRUTHY
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _capture_path() -> Path:
|
|
54
|
+
return Path.home() / _CAPTURE_DIRNAME / _CAPTURE_FILENAME
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ShadowCapture:
|
|
58
|
+
"""Thread-safe append-only JSONL writer for proxy exchanges (singleton)."""
|
|
59
|
+
|
|
60
|
+
_instance: "ShadowCapture | None" = None
|
|
61
|
+
_instance_lock = threading.Lock()
|
|
62
|
+
|
|
63
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
64
|
+
self._path = path or _capture_path()
|
|
65
|
+
self._write_lock = threading.Lock()
|
|
66
|
+
self._count = 0
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def get_instance(cls) -> "ShadowCapture":
|
|
70
|
+
# Double-checked locking: cheap fast-path after first construction.
|
|
71
|
+
if cls._instance is None:
|
|
72
|
+
with cls._instance_lock:
|
|
73
|
+
if cls._instance is None:
|
|
74
|
+
cls._instance = cls()
|
|
75
|
+
return cls._instance
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def reset_instance(cls) -> None:
|
|
79
|
+
"""Test hook — drop the singleton so a fresh path can be injected."""
|
|
80
|
+
with cls._instance_lock:
|
|
81
|
+
cls._instance = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def path(self) -> Path:
|
|
85
|
+
return self._path
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def count(self) -> int:
|
|
89
|
+
return self._count
|
|
90
|
+
|
|
91
|
+
def record(self, entry: dict[str, Any]) -> bool:
|
|
92
|
+
"""Append one capture entry as a JSON line. Returns True on success.
|
|
93
|
+
|
|
94
|
+
Fail-open: any error is logged and False is returned; never raised.
|
|
95
|
+
|
|
96
|
+
Security: opens with a single ``os.open`` carrying ``O_CREAT |
|
|
97
|
+
O_APPEND | O_NOFOLLOW`` and mode ``0o600`` on EVERY write. O_NOFOLLOW
|
|
98
|
+
refuses a symlink pre-placed at the path (symlink-append attack), and
|
|
99
|
+
the unconditional 0600-on-create removes the stat/exists TOCTOU that
|
|
100
|
+
could otherwise drop the file to the process umask.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
line = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
|
|
104
|
+
except (TypeError, ValueError) as exc:
|
|
105
|
+
logger.warning("capture: entry not JSON-serialisable, dropped: %r", exc)
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with self._write_lock:
|
|
110
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
flags = os.O_CREAT | os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
|
|
112
|
+
fd = os.open(self._path, flags, 0o600)
|
|
113
|
+
with os.fdopen(fd, "a", encoding="utf-8") as fh:
|
|
114
|
+
fh.write(line + "\n")
|
|
115
|
+
self._count += 1
|
|
116
|
+
return True
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
# PermissionError / symlink-refusal (ELOOP) are security-relevant —
|
|
119
|
+
# surface the errno but still fail open so the request is never blocked.
|
|
120
|
+
logger.warning("capture: write failed (fail-open): %r", exc)
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _truncate(raw: bytes) -> tuple[str, bool]:
|
|
125
|
+
"""Decode bytes for storage; truncate beyond the per-side cap."""
|
|
126
|
+
truncated = len(raw) > _MAX_CAPTURE_BODY_BYTES
|
|
127
|
+
head = raw[:_MAX_CAPTURE_BODY_BYTES] if truncated else raw
|
|
128
|
+
return head.decode("utf-8", errors="replace"), truncated
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_entry(
|
|
132
|
+
*,
|
|
133
|
+
provider: str,
|
|
134
|
+
model: str,
|
|
135
|
+
request_body: bytes,
|
|
136
|
+
response_body: bytes,
|
|
137
|
+
content_type: str,
|
|
138
|
+
input_tokens: int,
|
|
139
|
+
output_tokens: int,
|
|
140
|
+
status_code: int,
|
|
141
|
+
stream: bool,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Construct a capture entry dict from a completed exchange.
|
|
144
|
+
|
|
145
|
+
No timestamp is stamped here (Date.now is intentionally avoided in some
|
|
146
|
+
runtimes); the replay harness keys on content, not time, and the file's own
|
|
147
|
+
line order preserves arrival sequence.
|
|
148
|
+
"""
|
|
149
|
+
req_str, req_trunc = _truncate(request_body)
|
|
150
|
+
resp_str, resp_trunc = _truncate(response_body)
|
|
151
|
+
return {
|
|
152
|
+
"provider": provider,
|
|
153
|
+
"model": model,
|
|
154
|
+
"content_type": content_type,
|
|
155
|
+
"stream": stream,
|
|
156
|
+
"status_code": status_code,
|
|
157
|
+
"input_tokens": int(input_tokens),
|
|
158
|
+
"output_tokens": int(output_tokens),
|
|
159
|
+
"request": req_str,
|
|
160
|
+
"response": resp_str,
|
|
161
|
+
"request_truncated": req_trunc,
|
|
162
|
+
"response_truncated": resp_trunc,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def extract_usage(provider: str, body: bytes | None) -> tuple[int, int, str]:
|
|
167
|
+
"""Best-effort (input_tokens, output_tokens, model) from a provider JSON body.
|
|
168
|
+
|
|
169
|
+
Works on the normalised JSON the SSE parsers emit AND on non-streaming
|
|
170
|
+
upstream JSON. Returns (0, 0, "") when the body is missing/unparseable —
|
|
171
|
+
capture must never fail because usage couldn't be read.
|
|
172
|
+
"""
|
|
173
|
+
if not body:
|
|
174
|
+
return 0, 0, ""
|
|
175
|
+
try:
|
|
176
|
+
data = json.loads(body)
|
|
177
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
178
|
+
return 0, 0, ""
|
|
179
|
+
if not isinstance(data, dict):
|
|
180
|
+
return 0, 0, ""
|
|
181
|
+
|
|
182
|
+
if provider == "gemini":
|
|
183
|
+
usage = data.get("usageMetadata") or {}
|
|
184
|
+
return (
|
|
185
|
+
int(usage.get("promptTokenCount", 0) or 0),
|
|
186
|
+
int(usage.get("candidatesTokenCount", 0) or 0),
|
|
187
|
+
str(data.get("modelVersion", "") or ""),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
usage = data.get("usage") or {}
|
|
191
|
+
if provider == "anthropic":
|
|
192
|
+
return (
|
|
193
|
+
int(usage.get("input_tokens", 0) or 0),
|
|
194
|
+
int(usage.get("output_tokens", 0) or 0),
|
|
195
|
+
str(data.get("model", "") or ""),
|
|
196
|
+
)
|
|
197
|
+
# OpenAI-format providers (explicit allowlist so a future provider variant
|
|
198
|
+
# is not silently parsed with the wrong schema — it warns + returns zeros).
|
|
199
|
+
if provider not in _OPENAI_FORMAT_PROVIDERS:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"capture.extract_usage: unknown provider %r — recording zero tokens", provider
|
|
202
|
+
)
|
|
203
|
+
return 0, 0, str(data.get("model", "") or "")
|
|
204
|
+
return (
|
|
205
|
+
int(usage.get("prompt_tokens", 0) or 0),
|
|
206
|
+
int(usage.get("completion_tokens", 0) or 0),
|
|
207
|
+
str(data.get("model", "") or ""),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def record_exchange(
|
|
212
|
+
*,
|
|
213
|
+
provider: str,
|
|
214
|
+
model: str,
|
|
215
|
+
request_body: bytes,
|
|
216
|
+
response_body: bytes,
|
|
217
|
+
content_type: str = "application/json",
|
|
218
|
+
input_tokens: int = 0,
|
|
219
|
+
output_tokens: int = 0,
|
|
220
|
+
status_code: int = 200,
|
|
221
|
+
stream: bool = False,
|
|
222
|
+
) -> bool:
|
|
223
|
+
"""Build + append a capture entry. Fail-open. Returns True on success."""
|
|
224
|
+
entry = build_entry(
|
|
225
|
+
provider=provider,
|
|
226
|
+
model=model,
|
|
227
|
+
request_body=request_body,
|
|
228
|
+
response_body=response_body,
|
|
229
|
+
content_type=content_type,
|
|
230
|
+
input_tokens=input_tokens,
|
|
231
|
+
output_tokens=output_tokens,
|
|
232
|
+
status_code=status_code,
|
|
233
|
+
stream=stream,
|
|
234
|
+
)
|
|
235
|
+
return ShadowCapture.get_instance().record(entry)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def record_exchange_async(**kwargs: Any) -> bool:
|
|
239
|
+
"""Async wrapper for ``record_exchange`` that offloads the synchronous file
|
|
240
|
+
write to a worker thread so it never blocks the proxy event loop — relevant
|
|
241
|
+
when many streaming responses complete in the same loop iteration.
|
|
242
|
+
"""
|
|
243
|
+
return await asyncio.to_thread(lambda: record_exchange(**kwargs))
|
|
@@ -47,7 +47,10 @@ from superlocalmemory.optimize.proxy._helpers import (
|
|
|
47
47
|
_safe_compress,
|
|
48
48
|
_stream_and_cache_forward,
|
|
49
49
|
_stream_forward,
|
|
50
|
+
capture_passthrough_forward,
|
|
50
51
|
)
|
|
52
|
+
from superlocalmemory.optimize.proxy.capture import capture_enabled
|
|
53
|
+
from superlocalmemory.optimize.proxy.openai_surface import _parse_openai_sse_to_json
|
|
51
54
|
from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
|
|
52
55
|
|
|
53
56
|
logger = logging.getLogger("slm.optimize.proxy.gemini")
|
|
@@ -238,6 +241,24 @@ async def handle_gemini_native(
|
|
|
238
241
|
stream = "streamGenerateContent" in model_and_method
|
|
239
242
|
has_tools = _body_has_tools(body)
|
|
240
243
|
|
|
244
|
+
# v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
|
|
245
|
+
if capture_enabled():
|
|
246
|
+
cap_model = model_and_method.split(":", 1)[0].replace("models/", "")
|
|
247
|
+
cap_url = upstream_url
|
|
248
|
+
if stream:
|
|
249
|
+
_allowed = {
|
|
250
|
+
k: v for k, v in request.query_params.items()
|
|
251
|
+
if k.lower() in _GEMINI_ALLOWED_QUERY_PARAMS
|
|
252
|
+
}
|
|
253
|
+
_allowed["alt"] = "sse"
|
|
254
|
+
cap_url = f"{upstream_url}?{urllib.parse.urlencode(_allowed)}"
|
|
255
|
+
return await capture_passthrough_forward(
|
|
256
|
+
proxy, request, provider="gemini", upstream_url=cap_url,
|
|
257
|
+
allowed_headers=_GEMINI_NATIVE_FORWARD_HEADERS, request_id=request_id,
|
|
258
|
+
model_hint=cap_model,
|
|
259
|
+
sse_parser=_parse_gemini_sse_to_json, is_stream=stream,
|
|
260
|
+
)
|
|
261
|
+
|
|
241
262
|
ctx = ProxyRequest(
|
|
242
263
|
provider="gemini",
|
|
243
264
|
method="POST",
|
|
@@ -407,6 +428,16 @@ async def handle_gemini_openai_compat(proxy: object, request: Request) -> Respon
|
|
|
407
428
|
has_tools = _body_has_tools(body)
|
|
408
429
|
stream = bool(body.get("stream", False))
|
|
409
430
|
|
|
431
|
+
# v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
|
|
432
|
+
if capture_enabled():
|
|
433
|
+
return await capture_passthrough_forward(
|
|
434
|
+
proxy, request, provider="gemini-openai-compat",
|
|
435
|
+
upstream_url=upstream_url,
|
|
436
|
+
allowed_headers=_GEMINI_OPENAI_COMPAT_FORWARD_HEADERS,
|
|
437
|
+
request_id=request_id, model_hint=str(body.get("model", "")),
|
|
438
|
+
sse_parser=_parse_openai_sse_to_json, is_stream=stream,
|
|
439
|
+
)
|
|
440
|
+
|
|
410
441
|
ctx = ProxyRequest(
|
|
411
442
|
provider="gemini-openai-compat",
|
|
412
443
|
method="POST",
|
|
@@ -33,7 +33,9 @@ from superlocalmemory.optimize.proxy._helpers import (
|
|
|
33
33
|
_safe_compress,
|
|
34
34
|
_stream_and_cache_forward,
|
|
35
35
|
_stream_forward,
|
|
36
|
+
capture_passthrough_forward,
|
|
36
37
|
)
|
|
38
|
+
from superlocalmemory.optimize.proxy.capture import capture_enabled
|
|
37
39
|
from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
|
|
38
40
|
|
|
39
41
|
logger = logging.getLogger("slm.optimize.proxy.openai")
|
|
@@ -316,6 +318,16 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
|
|
|
316
318
|
|
|
317
319
|
stream = bool(body.get("stream", False))
|
|
318
320
|
has_tools = _body_has_tools(body)
|
|
321
|
+
|
|
322
|
+
# v3.6.10 shadow-capture (plan §7): pure passthrough + corpus record.
|
|
323
|
+
if capture_enabled():
|
|
324
|
+
return await capture_passthrough_forward(
|
|
325
|
+
proxy, request, provider="openai", upstream_url=upstream_url,
|
|
326
|
+
allowed_headers=_OPENAI_FORWARD_HEADERS, request_id=request_id,
|
|
327
|
+
model_hint=str(body.get("model", "")),
|
|
328
|
+
sse_parser=_parse_openai_sse_to_json, is_stream=stream,
|
|
329
|
+
)
|
|
330
|
+
|
|
319
331
|
ctx = ProxyRequest(
|
|
320
332
|
provider="openai", method="POST", path="/v1/chat/completions",
|
|
321
333
|
headers=_redact_headers(dict(request.headers)),
|
|
@@ -75,6 +75,24 @@ class ProxyApp:
|
|
|
75
75
|
self._request_counter += 1
|
|
76
76
|
return f"slm_{int(time.monotonic() * 1000)}_{self._request_counter:06d}"
|
|
77
77
|
|
|
78
|
+
def reload_from_config(self, config: OptimizeConfig) -> None:
|
|
79
|
+
"""Hot-swap cache/compress behavior when optimize.json changes (v3.6.10).
|
|
80
|
+
|
|
81
|
+
Called by the ConfigStore change-callback (UI save → immediate; external
|
|
82
|
+
file/CLI edit → within the 2s watchdog poll). Rebuilds the HookChain so
|
|
83
|
+
``cache_enabled`` and ``compress_enabled`` can be toggled INDEPENDENTLY
|
|
84
|
+
at runtime with no daemon restart. Note: ``proxy_enabled`` (whether the
|
|
85
|
+
proxy claims /v1/* at all) is a startup decision and is NOT changed here.
|
|
86
|
+
"""
|
|
87
|
+
self.config = config
|
|
88
|
+
self.hooks = _load_hooks(config)
|
|
89
|
+
logger.info(
|
|
90
|
+
"slm.optimize.proxy reloaded (config v%s): cache=%s compress=%s",
|
|
91
|
+
getattr(config, "config_version", "?"),
|
|
92
|
+
type(self.hooks.cache).__name__ if self.hooks.cache else "None",
|
|
93
|
+
type(self.hooks.compress).__name__ if self.hooks.compress else "None",
|
|
94
|
+
)
|
|
95
|
+
|
|
78
96
|
|
|
79
97
|
def build_proxy_router(proxy: ProxyApp) -> APIRouter:
|
|
80
98
|
"""Build and return the FastAPI router for all proxy surfaces."""
|
|
@@ -132,6 +150,17 @@ def build_proxy_router(proxy: ProxyApp) -> APIRouter:
|
|
|
132
150
|
|
|
133
151
|
|
|
134
152
|
def _load_hooks(config: OptimizeConfig) -> HookChain:
|
|
153
|
+
# v3.6.10 shadow-capture (plan §7): capture mode is PURE passthrough — no
|
|
154
|
+
# cache, no compression — so the corpus records only authentic upstream
|
|
155
|
+
# exchanges. This is defense-in-depth alongside the per-surface guard.
|
|
156
|
+
from superlocalmemory.optimize.proxy.capture import capture_enabled
|
|
157
|
+
if capture_enabled():
|
|
158
|
+
logger.info(
|
|
159
|
+
"slm.optimize.proxy: SLM_OPTIMIZE_CAPTURE on — cache/compress "
|
|
160
|
+
"DISABLED, recording exchanges to optimize_capture.jsonl"
|
|
161
|
+
)
|
|
162
|
+
return HookChain.empty()
|
|
163
|
+
|
|
135
164
|
cache_hook = None
|
|
136
165
|
compress_hook = None
|
|
137
166
|
|