superlocalmemory 3.6.9 → 3.6.11
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 +112 -10
- package/README.md +67 -9
- package/package.json +1 -1
- package/pyproject.toml +6 -1
- package/skills/slm-optimize/README.md +55 -0
- package/skills/slm-optimize/SKILL.md +139 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/compress_cmd.py +32 -70
- package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
- package/src/superlocalmemory/cli/setup_wizard.py +49 -0
- package/src/superlocalmemory/mcp/agent_context.py +111 -0
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_active.py +7 -8
- package/src/superlocalmemory/mcp/tools_core.py +16 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +304 -0
- 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 +102 -11
- package/src/superlocalmemory/optimize/storage/schema.py +11 -0
- package/src/superlocalmemory/server/routes/optimize.py +6 -8
- package/src/superlocalmemory/server/unified_daemon.py +26 -5
- 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 +69 -10
- package/src/superlocalmemory.egg-info/SOURCES.txt +3 -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
|
@@ -26,7 +26,6 @@ from superlocalmemory.optimize.config.store import ConfigStore
|
|
|
26
26
|
logger = logging.getLogger("slm.optimize.compress.router")
|
|
27
27
|
|
|
28
28
|
_MIN_CHARS_FOR_COMPRESSION: int = 500
|
|
29
|
-
_MIN_RATIO_STRUCTURED: float = 0.60
|
|
30
29
|
|
|
31
30
|
|
|
32
31
|
class CompressRouter:
|
|
@@ -50,8 +49,6 @@ class CompressRouter:
|
|
|
50
49
|
return cls._instance
|
|
51
50
|
|
|
52
51
|
def __init__(self) -> None:
|
|
53
|
-
self._json_compressor: "JSONCompressor | None" = None
|
|
54
|
-
self._code_compressor: "CodeCompressor | None" = None
|
|
55
52
|
self._llmlingua_compressor: "LLMLinguaCompressor | None" = None
|
|
56
53
|
self._ccr_store: "CCRStore | None" = None
|
|
57
54
|
self._aligner: "CacheAligner | None" = None
|
|
@@ -103,12 +100,17 @@ class CompressRouter:
|
|
|
103
100
|
tenant_id="default",
|
|
104
101
|
)
|
|
105
102
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
# S-01/Stage-9 fix: build new_bytes BEFORE the improvement guard.
|
|
104
|
+
# Layer 1 normalization saves characters (not word-count tokens), so
|
|
105
|
+
# tokens_after == tokens_before for "normalize" strategy. Checking byte
|
|
106
|
+
# length of the serialized body correctly detects Layer 1 savings.
|
|
109
107
|
body["messages"] = new_messages
|
|
110
108
|
new_bytes = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
|
|
111
109
|
|
|
110
|
+
bytes_saved = len(req.body_bytes) - len(new_bytes)
|
|
111
|
+
if bytes_saved <= 0 and tokens_after >= tokens_before:
|
|
112
|
+
return req # neither bytes nor tokens improved
|
|
113
|
+
|
|
112
114
|
# CONTRACT §3: fire on_compress metrics callback
|
|
113
115
|
lossy = strategy == "llmlingua2_prose"
|
|
114
116
|
self.on_compress(tokens_before, tokens_after, lossy)
|
|
@@ -160,7 +162,12 @@ class CompressRouter:
|
|
|
160
162
|
primary_strategy = "none"
|
|
161
163
|
new_messages: list[dict[str, Any]] = []
|
|
162
164
|
|
|
163
|
-
|
|
165
|
+
# K-05: protect last N *user* turns, not last N messages of any role
|
|
166
|
+
user_indices = [i for i, m in enumerate(messages) if m.get("role") == "user"]
|
|
167
|
+
protect_indices: set[int] = set(user_indices[-protect_recent:]) if protect_recent > 0 else set()
|
|
168
|
+
# Always protect the very last message (current turn, any role)
|
|
169
|
+
if messages:
|
|
170
|
+
protect_indices.add(len(messages) - 1)
|
|
164
171
|
|
|
165
172
|
for idx, msg in enumerate(messages):
|
|
166
173
|
role = msg.get("role", "")
|
|
@@ -248,96 +255,77 @@ class CompressRouter:
|
|
|
248
255
|
) -> tuple[str, int, int, str]:
|
|
249
256
|
tokens_before = _token_estimate(text)
|
|
250
257
|
|
|
251
|
-
|
|
258
|
+
if len(text) < _MIN_CHARS_FOR_COMPRESSION:
|
|
259
|
+
return text, tokens_before, tokens_before, "none"
|
|
260
|
+
|
|
261
|
+
# K-01/K-02/K-03: NEVER compress structured content (JSON or code)
|
|
252
262
|
stripped = text.strip()
|
|
253
263
|
if stripped.startswith(("{", "[")):
|
|
264
|
+
# PERF-02: for large content, structural bracket-match avoids O(n) json.loads().
|
|
265
|
+
# Conservative: matching outer brackets → treat as JSON and skip compression.
|
|
266
|
+
# K-01 mandate is safety-first: false-positive (non-JSON treated as JSON) is
|
|
267
|
+
# safe; false-negative (JSON compressed) would be a correctness violation.
|
|
268
|
+
_last = stripped[-1] if stripped else ""
|
|
269
|
+
if len(stripped) > 8192 and (
|
|
270
|
+
(stripped[0] == "{" and _last == "}") or (stripped[0] == "[" and _last == "]")
|
|
271
|
+
):
|
|
272
|
+
return text, tokens_before, tokens_before, "none" # large JSON → passthrough
|
|
254
273
|
try:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
obj["__slm_ccr__"] = ccr_id
|
|
269
|
-
compressed = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
270
|
-
elif isinstance(obj, list):
|
|
271
|
-
# RB-02: list-root embedding
|
|
272
|
-
wrapper = {"__slm_ccr__": ccr_id, "__slm_data__": obj}
|
|
273
|
-
compressed = json.dumps(wrapper, ensure_ascii=False, separators=(",", ":"))
|
|
274
|
-
except Exception:
|
|
275
|
-
pass
|
|
276
|
-
self._ccr_update_compressed(ccr_id, compressed.encode())
|
|
277
|
-
logger.debug("[%s] JSON compressed %.2f ratio ccr_id=%s", request_id, ratio, ccr_id)
|
|
278
|
-
return compressed, tokens_before_adj, tokens_after, "extractive_json"
|
|
279
|
-
return text, tokens_before, tokens_before, "none"
|
|
280
|
-
except (json.JSONDecodeError, Exception):
|
|
281
|
-
pass
|
|
282
|
-
|
|
283
|
-
# Code detection
|
|
284
|
-
lang = _detect_language(text)
|
|
285
|
-
if lang is not None:
|
|
286
|
-
compressor = self._get_code_compressor()
|
|
287
|
-
# RB-03: compress first, compute ratio, then store CCR only if beneficial
|
|
288
|
-
compressed_probe = compressor.compress(text, language=lang, ccr_id="")
|
|
289
|
-
tokens_after = _token_estimate(compressed_probe)
|
|
290
|
-
ratio = tokens_after / tokens_before if tokens_before else 1.0
|
|
291
|
-
if ratio < _MIN_RATIO_STRUCTURED:
|
|
292
|
-
# B-03: store-before-compress
|
|
293
|
-
ccr_id = self._ccr_store_original(text.encode(), model, tenant_id)
|
|
294
|
-
compressed = compressor.compress(text, language=lang, ccr_id=ccr_id)
|
|
295
|
-
if ccr_id:
|
|
296
|
-
self._ccr_update_compressed(ccr_id, compressed.encode())
|
|
297
|
-
logger.debug("[%s] Code compressed lang=%s ratio=%.2f ccr_id=%s",
|
|
298
|
-
request_id, lang, ratio, ccr_id)
|
|
299
|
-
return compressed, tokens_before, tokens_after, "extractive_code"
|
|
300
|
-
return text, tokens_before, tokens_before, "none"
|
|
274
|
+
json.loads(stripped)
|
|
275
|
+
return text, tokens_before, tokens_before, "none" # valid JSON → passthrough
|
|
276
|
+
except json.JSONDecodeError:
|
|
277
|
+
pass # not valid JSON — treat as prose
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
logger.warning("compress: unexpected error probing JSON content: %s", exc)
|
|
280
|
+
|
|
281
|
+
if _detect_language(text) is not None:
|
|
282
|
+
return text, tokens_before, tokens_before, "none" # code → passthrough
|
|
283
|
+
|
|
284
|
+
# Layer 1 — lossless whitespace normalization (always-on, safe)
|
|
285
|
+
normalized = self._normalize_whitespace(text)
|
|
286
|
+
tokens_after_l1 = _token_estimate(normalized)
|
|
301
287
|
|
|
302
|
-
#
|
|
303
|
-
# (Phase 3 — opt-in prose tier; off by default; gated by config
|
|
304
|
-
# field compress_prose added in LLD-04 INTERFACE-CONTRACT v2.)
|
|
288
|
+
# Layer 2 — LLMLingua-2 prose compression (aggressive + opt-in only)
|
|
305
289
|
cfg = self._get_config()
|
|
306
290
|
prose_enabled = bool(getattr(cfg, "compress_prose", False))
|
|
307
|
-
if aggressive and prose_enabled:
|
|
291
|
+
if aggressive and prose_enabled: # pragma: no cover — LLMLingua optional dep
|
|
308
292
|
compressor = self._get_llmlingua_compressor()
|
|
309
293
|
if compressor is not None:
|
|
310
|
-
# B-03: store
|
|
294
|
+
# B-03: store original BEFORE lossy compression
|
|
311
295
|
ccr_id = self._ccr_store_original(text.encode(), model, tenant_id)
|
|
312
|
-
compressed = compressor.compress(
|
|
296
|
+
compressed = compressor.compress(normalized)
|
|
313
297
|
if ccr_id:
|
|
314
298
|
self._ccr_update_compressed(ccr_id, compressed.encode())
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
299
|
+
tokens_after_l2 = _token_estimate(compressed)
|
|
300
|
+
if tokens_after_l2 < tokens_before:
|
|
301
|
+
logger.info(
|
|
302
|
+
"[%s] LLMLingua-2 prose compressed rate=%.2f ccr_id=%s (LOSSY)",
|
|
303
|
+
request_id,
|
|
304
|
+
tokens_after_l2 / tokens_before if tokens_before else 1.0,
|
|
305
|
+
ccr_id,
|
|
306
|
+
)
|
|
307
|
+
return compressed, tokens_before, tokens_after_l2, "llmlingua2_prose"
|
|
323
308
|
|
|
324
|
-
|
|
309
|
+
# S-01 fix: compare character length, not word count.
|
|
310
|
+
# _token_estimate() is word-count — whitespace normalization saves characters/bytes
|
|
311
|
+
# but never removes words, so token counts are identical before and after Layer 1.
|
|
312
|
+
# Character comparison correctly detects when normalization reduced the body size.
|
|
313
|
+
if len(normalized) < len(text):
|
|
314
|
+
return normalized, tokens_before, tokens_after_l1, "normalize"
|
|
325
315
|
|
|
326
|
-
|
|
316
|
+
return text, tokens_before, tokens_before, "none"
|
|
327
317
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _normalize_whitespace(text: str) -> str:
|
|
320
|
+
"""Layer 1 lossless: collapse excess blank lines, strip trailing spaces per line."""
|
|
321
|
+
import re
|
|
322
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
323
|
+
lines = [line.rstrip() for line in text.split("\n")]
|
|
324
|
+
return "\n".join(lines)
|
|
333
325
|
|
|
334
|
-
|
|
335
|
-
if self._code_compressor is None:
|
|
336
|
-
from superlocalmemory.optimize.compress.extractive_code import CodeCompressor
|
|
337
|
-
self._code_compressor = CodeCompressor()
|
|
338
|
-
return self._code_compressor
|
|
326
|
+
# ── Lazy loaders ─────────────────────────────────────────────────────
|
|
339
327
|
|
|
340
|
-
def _get_llmlingua_compressor(self) -> "LLMLinguaCompressor | None":
|
|
328
|
+
def _get_llmlingua_compressor(self) -> "LLMLinguaCompressor | None": # pragma: no cover
|
|
341
329
|
if self._llmlingua_compressor is None:
|
|
342
330
|
try:
|
|
343
331
|
from superlocalmemory.optimize.compress.prose_llmlingua import LLMLinguaCompressor
|
|
@@ -396,21 +384,32 @@ class CompressRouter:
|
|
|
396
384
|
return CompressTextResult(
|
|
397
385
|
compressed_text=compressed, strategy=strat,
|
|
398
386
|
tokens_before=tb, tokens_after=ta,
|
|
387
|
+
lossy=strat == "llmlingua2_prose",
|
|
399
388
|
)
|
|
400
389
|
except Exception as exc:
|
|
401
390
|
logger.debug("compress_text failed (non-fatal): %s", exc)
|
|
391
|
+
t = _token_estimate(text)
|
|
402
392
|
return CompressTextResult(
|
|
403
393
|
compressed_text=text, strategy="none",
|
|
404
|
-
tokens_before=
|
|
394
|
+
tokens_before=t, tokens_after=t,
|
|
395
|
+
lossy=False,
|
|
405
396
|
)
|
|
406
397
|
|
|
407
398
|
|
|
408
399
|
@dataclass
|
|
409
400
|
class CompressTextResult:
|
|
401
|
+
"""Result of a compress_text() call.
|
|
402
|
+
|
|
403
|
+
UX-02 note: lossy=True only when strategy="llmlingua2_prose" (Layer 2).
|
|
404
|
+
In the default install (LLMLingua optional dep not installed), lossy is
|
|
405
|
+
always False — install `llmlingua>=0.2.0` and set compress_prose=True +
|
|
406
|
+
compress_mode="aggressive" to activate lossy compression.
|
|
407
|
+
"""
|
|
410
408
|
compressed_text: str
|
|
411
|
-
strategy: str # "
|
|
409
|
+
strategy: str # "normalize" | "llmlingua2_prose" | "none"
|
|
412
410
|
tokens_before: int
|
|
413
411
|
tokens_after: int
|
|
412
|
+
lossy: bool = False # K-10: True only for llmlingua2_prose (Layer 2)
|
|
414
413
|
|
|
415
414
|
|
|
416
415
|
# ── Module-level helpers ──────────────────────────────────────────────────────
|
|
@@ -419,10 +418,6 @@ def _token_estimate(text: str) -> int:
|
|
|
419
418
|
return len(text.split()) if text else 0
|
|
420
419
|
|
|
421
420
|
|
|
422
|
-
def _token_estimate_structured(text: str) -> int:
|
|
423
|
-
return max(1, len(text) // 4) if text else 0
|
|
424
|
-
|
|
425
|
-
|
|
426
421
|
def _msg_has_tool_result(msg: dict) -> bool:
|
|
427
422
|
"""B-09: Detect historical tool_result blocks in messages."""
|
|
428
423
|
content = msg.get("content", "")
|
|
@@ -24,6 +24,22 @@ def load_optimize_config() -> OptimizeConfig:
|
|
|
24
24
|
return get_optimize_config()
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def get_shared_store() -> "ConfigStore":
|
|
28
|
+
"""Return the process-wide ConfigStore singleton, creating it on first use.
|
|
29
|
+
|
|
30
|
+
This is the SINGLE instance the daemon, the /api/optimize routes, the proxy
|
|
31
|
+
hook-reload callback, and the hot-reload watchdog must all share so that a UI
|
|
32
|
+
config change reaches the live proxy (fixes W-02 hot-reload + W-05 fresh-store
|
|
33
|
+
-per-request). The daemon calls this at startup, registers a change callback,
|
|
34
|
+
and starts the watchdog.
|
|
35
|
+
"""
|
|
36
|
+
global _store
|
|
37
|
+
if _store is None:
|
|
38
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
39
|
+
_store = ConfigStore()
|
|
40
|
+
return _store
|
|
41
|
+
|
|
42
|
+
|
|
27
43
|
def _set_config_store(store: "ConfigStore") -> None:
|
|
28
44
|
global _store
|
|
29
45
|
_store = store
|
|
@@ -23,15 +23,10 @@ DEFAULT_OPTIMIZE_CONFIG = OptimizeConfig(
|
|
|
23
23
|
semantic_boundary_floor=0.85,
|
|
24
24
|
semantic_pad_latency_ms=0.0,
|
|
25
25
|
semantic_centroid_min_similarity=0.85,
|
|
26
|
-
compress_enabled=
|
|
26
|
+
compress_enabled=False,
|
|
27
27
|
compress_mode="safe",
|
|
28
|
-
compress_code=True,
|
|
29
|
-
compress_json=True,
|
|
30
28
|
compress_prose=False,
|
|
31
|
-
compress_ccr=True,
|
|
32
|
-
compress_align=True,
|
|
33
29
|
compress_protect_recent=4,
|
|
34
|
-
compress_llmlingua_allow_download=False,
|
|
35
30
|
ttl_seconds=86400,
|
|
36
31
|
ttl=TTLConfig(),
|
|
37
32
|
providers={
|
|
@@ -89,15 +89,10 @@ class OptimizeConfig:
|
|
|
89
89
|
semantic_centroid_min_similarity: float = 0.85
|
|
90
90
|
|
|
91
91
|
# Compress
|
|
92
|
-
compress_enabled: bool =
|
|
92
|
+
compress_enabled: bool = False
|
|
93
93
|
compress_mode: str = "safe"
|
|
94
|
-
compress_code: bool = True
|
|
95
|
-
compress_json: bool = True
|
|
96
94
|
compress_prose: bool = False
|
|
97
|
-
compress_ccr: bool = True
|
|
98
|
-
compress_align: bool = True
|
|
99
95
|
compress_protect_recent: int = 4
|
|
100
|
-
compress_llmlingua_allow_download: bool = False
|
|
101
96
|
|
|
102
97
|
# TTL + providers + pricing
|
|
103
98
|
ttl_seconds: int = 86400
|
|
@@ -182,17 +177,10 @@ class OptimizeConfig:
|
|
|
182
177
|
semantic_centroid_min_similarity=float(
|
|
183
178
|
d.get("semantic_centroid_min_similarity", 0.85)
|
|
184
179
|
),
|
|
185
|
-
compress_enabled=bool(d.get("compress_enabled",
|
|
180
|
+
compress_enabled=bool(d.get("compress_enabled", False)),
|
|
186
181
|
compress_mode=str(d.get("compress_mode", "safe")),
|
|
187
|
-
compress_code=bool(d.get("compress_code", True)),
|
|
188
|
-
compress_json=bool(d.get("compress_json", True)),
|
|
189
182
|
compress_prose=bool(d.get("compress_prose", False)),
|
|
190
|
-
compress_ccr=bool(d.get("compress_ccr", True)),
|
|
191
|
-
compress_align=bool(d.get("compress_align", True)),
|
|
192
183
|
compress_protect_recent=int(d.get("compress_protect_recent", 4)),
|
|
193
|
-
compress_llmlingua_allow_download=bool(
|
|
194
|
-
d.get("compress_llmlingua_allow_download", False)
|
|
195
|
-
),
|
|
196
184
|
ttl_seconds=int(d.get("ttl_seconds", 86400)),
|
|
197
185
|
providers=providers,
|
|
198
186
|
pricing=dict(d.get("pricing", {})),
|
|
@@ -230,13 +218,8 @@ class OptimizeConfig:
|
|
|
230
218
|
"semantic_centroid_min_similarity": self.semantic_centroid_min_similarity,
|
|
231
219
|
"compress_enabled": self.compress_enabled,
|
|
232
220
|
"compress_mode": self.compress_mode,
|
|
233
|
-
"compress_code": self.compress_code,
|
|
234
|
-
"compress_json": self.compress_json,
|
|
235
221
|
"compress_prose": self.compress_prose,
|
|
236
|
-
"compress_ccr": self.compress_ccr,
|
|
237
|
-
"compress_align": self.compress_align,
|
|
238
222
|
"compress_protect_recent": self.compress_protect_recent,
|
|
239
|
-
"compress_llmlingua_allow_download": self.compress_llmlingua_allow_download,
|
|
240
223
|
"ttl_seconds": self.ttl_seconds,
|
|
241
224
|
"providers": {k: v.as_dict() for k, v in self.providers.items()},
|
|
242
225
|
"pricing": self.pricing,
|
|
@@ -74,7 +74,13 @@ class ConfigStore:
|
|
|
74
74
|
return self._current_config
|
|
75
75
|
|
|
76
76
|
def save(self, config: OptimizeConfig) -> None:
|
|
77
|
-
"""Write config to optimize.json with version bump.
|
|
77
|
+
"""Write config to optimize.json with version bump.
|
|
78
|
+
|
|
79
|
+
Fires registered change callbacks immediately after a successful write
|
|
80
|
+
(outside the lock) so a UI/CLI save reaches the live proxy without
|
|
81
|
+
waiting for the 2s watchdog poll. The watchdog skips this write via
|
|
82
|
+
``_saved_by_self`` so callbacks fire exactly once.
|
|
83
|
+
"""
|
|
78
84
|
config.validate()
|
|
79
85
|
with self._lock:
|
|
80
86
|
new_version = self._version + 1
|
|
@@ -93,6 +99,14 @@ class ConfigStore:
|
|
|
93
99
|
self._version = new_version
|
|
94
100
|
finally:
|
|
95
101
|
self._saved_by_self = False
|
|
102
|
+
callbacks = list(self._change_callbacks)
|
|
103
|
+
# Fire callbacks OUTSIDE the lock — a callback may rebuild proxy hooks
|
|
104
|
+
# or call back into get(); keeping them off the lock avoids contention.
|
|
105
|
+
for cb in callbacks:
|
|
106
|
+
try:
|
|
107
|
+
cb(new_cfg)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.warning("ConfigStore save callback error: %s", exc)
|
|
96
110
|
|
|
97
111
|
def start_watchdog(self) -> None:
|
|
98
112
|
"""Start the background hot-reload watchdog thread.
|
|
@@ -24,7 +24,7 @@ class MetricsCollector:
|
|
|
24
24
|
Hook binding (from INTERFACE-CONTRACT §3):
|
|
25
25
|
on_hit(tokens_saved_input, tokens_saved_output): cache hit
|
|
26
26
|
on_miss(): cache miss
|
|
27
|
-
on_compress(
|
|
27
|
+
on_compress(tokens_before, tokens_after): compress ran (word-count proxy)
|
|
28
28
|
on_eviction(): entry expired/evicted
|
|
29
29
|
"""
|
|
30
30
|
|
|
@@ -69,20 +69,28 @@ class MetricsCollector:
|
|
|
69
69
|
with self._data_lock:
|
|
70
70
|
self._misses += 1
|
|
71
71
|
|
|
72
|
-
def on_compress(self,
|
|
73
|
-
"""Record a compression run
|
|
72
|
+
def on_compress(self, tokens_before: int, tokens_after: int) -> None:
|
|
73
|
+
"""Record a compression run. Arguments are word-count proxy estimates from _token_estimate().
|
|
74
|
+
|
|
75
|
+
M-03: consistent naming — these are token estimates, not byte counts.
|
|
76
|
+
Stored in compress_bytes_original/after fields for DB schema compat; unit is word-count.
|
|
77
|
+
"""
|
|
74
78
|
with self._data_lock:
|
|
75
79
|
self._compress_runs += 1
|
|
76
|
-
self._compress_bytes_original += max(0,
|
|
77
|
-
self._compress_bytes_after += max(0,
|
|
78
|
-
|
|
79
|
-
self._tokens_saved_compress += saved_tokens
|
|
80
|
+
self._compress_bytes_original += max(0, tokens_before)
|
|
81
|
+
self._compress_bytes_after += max(0, tokens_after)
|
|
82
|
+
self._tokens_saved_compress += max(0, tokens_before - tokens_after)
|
|
80
83
|
|
|
81
84
|
def on_eviction(self) -> None:
|
|
82
85
|
"""Record an eviction."""
|
|
83
86
|
with self._data_lock:
|
|
84
87
|
self._evictions += 1
|
|
85
88
|
|
|
89
|
+
def increment_skipped_temperature(self) -> None:
|
|
90
|
+
"""C-04: record a cache skip due to non-zero temperature."""
|
|
91
|
+
with self._data_lock:
|
|
92
|
+
self._calls_skipped += 1
|
|
93
|
+
|
|
86
94
|
def record_latency(self, ms: float) -> None:
|
|
87
95
|
"""Record latency overhead in milliseconds."""
|
|
88
96
|
if ms < 0:
|
|
@@ -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",
|