superlocalmemory 3.6.2 → 3.6.4
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 +180 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/consolidation_engine.py +8 -0
- package/src/superlocalmemory/core/engine.py +6 -1
- package/src/superlocalmemory/core/fact_consolidator.py +54 -13
- package/src/superlocalmemory/core/graph_pruner.py +55 -19
- package/src/superlocalmemory/core/maintenance.py +9 -0
- package/src/superlocalmemory/core/store_pipeline.py +120 -51
- package/src/superlocalmemory/mcp/_stdin_guard.py +60 -0
- package/src/superlocalmemory/mcp/server.py +34 -5
- package/src/superlocalmemory/optimize/adapters/wrap.py +12 -3
- package/src/superlocalmemory/optimize/cache/manager.py +63 -9
- package/src/superlocalmemory/optimize/compress/router.py +7 -9
- package/src/superlocalmemory/optimize/proxy/_helpers.py +236 -1
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +187 -5
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +373 -14
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +329 -7
- package/src/superlocalmemory/optimize/proxy/server.py +10 -3
- package/src/superlocalmemory/retrieval/engine.py +17 -6
- package/src/superlocalmemory/retrieval/entity_channel.py +10 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +3 -1
- package/src/superlocalmemory/storage/database.py +113 -9
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +3 -1
|
@@ -102,6 +102,37 @@ def enrich_fact(
|
|
|
102
102
|
)
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Vector dual-write helper (P1-2 / embeddings-vector-01)
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=None):
|
|
110
|
+
"""Dual-write a fact's embedding to the ANN index + sqlite-vec store.
|
|
111
|
+
|
|
112
|
+
Embeds on-demand when the fact has no embedding (e.g. consolidated
|
|
113
|
+
summary facts created without one), so UPDATE/SUPERSEDE and consolidated
|
|
114
|
+
facts remain visible to the semantic channel instead of having a row in
|
|
115
|
+
``atomic_facts`` but none in the vector store.
|
|
116
|
+
"""
|
|
117
|
+
if not getattr(fact, "embedding", None) and embedder is not None and fact.content:
|
|
118
|
+
try:
|
|
119
|
+
fact.embedding = embedder.embed(fact.content)
|
|
120
|
+
except Exception as _emb_exc: # pragma: no cover - defensive
|
|
121
|
+
logger.debug("on-demand embed failed for %s: %s", fact.fact_id, _emb_exc)
|
|
122
|
+
return
|
|
123
|
+
if not getattr(fact, "embedding", None):
|
|
124
|
+
return
|
|
125
|
+
if ann_index:
|
|
126
|
+
ann_index.add(fact.fact_id, fact.embedding)
|
|
127
|
+
# V3.2: VectorStore upsert (sqlite-vec) -- dual-write (Rule 12)
|
|
128
|
+
if vector_store and getattr(vector_store, "available", False):
|
|
129
|
+
vector_store.upsert(
|
|
130
|
+
fact_id=fact.fact_id,
|
|
131
|
+
profile_id=profile_id,
|
|
132
|
+
embedding=fact.embedding,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
105
136
|
# ---------------------------------------------------------------------------
|
|
106
137
|
# run_store (was MemoryEngine.store)
|
|
107
138
|
# ---------------------------------------------------------------------------
|
|
@@ -175,10 +206,20 @@ def run_store(
|
|
|
175
206
|
)
|
|
176
207
|
db.store_memory(record)
|
|
177
208
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
209
|
+
try:
|
|
210
|
+
facts = fact_extractor.extract_facts(
|
|
211
|
+
turns=[content], session_id=session_id,
|
|
212
|
+
session_date=parsed_date, speaker_a=speaker,
|
|
213
|
+
)
|
|
214
|
+
except Exception as _extract_exc:
|
|
215
|
+
# P0-1 (remember-write-04): an extractor EXCEPTION (transient LLM/embed
|
|
216
|
+
# backend error) must NOT orphan the already-committed memory. The None
|
|
217
|
+
# guard below only handled a None *return*, not a raise. Treat a raise
|
|
218
|
+
# as "no facts" so the verbatim/raw fallback persists the content.
|
|
219
|
+
logger.warning(
|
|
220
|
+
"extract_facts() raised — falling back to raw fact: %s", _extract_exc,
|
|
221
|
+
)
|
|
222
|
+
facts = None
|
|
182
223
|
|
|
183
224
|
# v3.4.38: Defensive None guard. extract_facts() returns None on transient
|
|
184
225
|
# failures (embedding worker timeout, LLM call fail). Without this guard,
|
|
@@ -257,58 +298,77 @@ def run_store(
|
|
|
257
298
|
)
|
|
258
299
|
|
|
259
300
|
if consolidator:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
301
|
+
try:
|
|
302
|
+
action = consolidator.consolidate(fact, profile_id)
|
|
303
|
+
except Exception as _consolidate_exc:
|
|
304
|
+
# P0-1 (remember-write-03): a consolidate failure (e.g. LLM
|
|
305
|
+
# timeout) must NOT orphan the already-committed memory. Fall
|
|
306
|
+
# back to storing the raw enriched fact so the content stays
|
|
307
|
+
# retrievable across all channels.
|
|
308
|
+
logger.warning(
|
|
309
|
+
"consolidate() failed for fact %s — storing raw fact as "
|
|
310
|
+
"fallback: %s", fact.fact_id, _consolidate_exc,
|
|
311
|
+
)
|
|
312
|
+
action = None
|
|
313
|
+
|
|
314
|
+
if action is not None:
|
|
315
|
+
if action.action_type.value == "noop":
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
# Opinion confidence tracking: reinforce or decay
|
|
319
|
+
if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
|
|
320
|
+
try:
|
|
321
|
+
existing = db.get_fact(action.new_fact_id)
|
|
322
|
+
if existing and existing.fact_type == FactType.OPINION:
|
|
323
|
+
new_conf = min(1.0, existing.confidence + 0.1)
|
|
324
|
+
db.update_fact(action.new_fact_id, {"confidence": new_conf})
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
|
|
328
|
+
try:
|
|
329
|
+
old_id = getattr(action, "old_fact_id", None)
|
|
330
|
+
if old_id:
|
|
331
|
+
old_fact = db.get_fact(old_id)
|
|
332
|
+
if old_fact:
|
|
333
|
+
new_conf = max(0.0, old_fact.confidence - 0.2)
|
|
334
|
+
db.update_fact(old_id, {"confidence": new_conf})
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
if action.action_type.value in ("update", "supersede"):
|
|
339
|
+
updated_fact = db.get_fact(action.new_fact_id)
|
|
340
|
+
if updated_fact:
|
|
341
|
+
# P1-2 (embeddings-vector-01): the merged/superseding
|
|
342
|
+
# fact must reach the vector store (embed on-demand if
|
|
343
|
+
# it has none) — otherwise it is invisible to the
|
|
344
|
+
# semantic channel despite living in atomic_facts.
|
|
345
|
+
_upsert_fact_vectors(
|
|
346
|
+
updated_fact, profile_id, ann_index, vector_store, embedder,
|
|
347
|
+
)
|
|
348
|
+
if graph_builder:
|
|
349
|
+
graph_builder.build_edges(updated_fact, profile_id)
|
|
350
|
+
if observation_builder:
|
|
351
|
+
for eid in updated_fact.canonical_entities:
|
|
352
|
+
observation_builder.update_profile(
|
|
353
|
+
eid, updated_fact, profile_id,
|
|
354
|
+
)
|
|
355
|
+
stored_ids.append(action.new_fact_id)
|
|
356
|
+
continue
|
|
357
|
+
# ADD case: consolidator already stored the fact (F8 fix)
|
|
358
|
+
# Fall through to post-processing below
|
|
359
|
+
else:
|
|
360
|
+
# Consolidate failed → store the raw fact ourselves so the
|
|
361
|
+
# memory is never left without a retrievable fact, then fall
|
|
362
|
+
# through to post-processing (embeddings, graph, context).
|
|
363
|
+
db.store_fact(fact)
|
|
298
364
|
else:
|
|
299
365
|
db.store_fact(fact)
|
|
300
366
|
|
|
301
367
|
stored_ids.append(fact.fact_id)
|
|
302
368
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if fact.embedding and vector_store and vector_store.available:
|
|
307
|
-
vector_store.upsert(
|
|
308
|
-
fact_id=fact.fact_id,
|
|
309
|
-
profile_id=profile_id,
|
|
310
|
-
embedding=fact.embedding,
|
|
311
|
-
)
|
|
369
|
+
# Dual-write embedding to ANN index + vector store (embed on-demand if
|
|
370
|
+
# a consolidated ADD fact arrived without one). See _upsert_fact_vectors.
|
|
371
|
+
_upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder)
|
|
312
372
|
# Phase 2: Generate contextual description (after consolidator, before graph_builder)
|
|
313
373
|
if context_generator:
|
|
314
374
|
try:
|
|
@@ -489,6 +549,15 @@ def run_store_fact_direct(
|
|
|
489
549
|
and graph edges are all populated — even for auxiliary data.
|
|
490
550
|
Creates a parent memory record to satisfy FK constraint.
|
|
491
551
|
"""
|
|
552
|
+
# remember-write-02: gate low-quality content (empty, bare category tags,
|
|
553
|
+
# placeholder/template leakage) at the WRITE boundary, matching run_store's
|
|
554
|
+
# gate. Previously this direct path had no filter, so junk entered the KB
|
|
555
|
+
# and polluted evidence/stats/embeddings (the read-side filter only hid it).
|
|
556
|
+
from superlocalmemory.core.injection import is_low_quality
|
|
557
|
+
if is_low_quality(fact.content):
|
|
558
|
+
logger.debug("run_store_fact_direct: skipping low-quality content")
|
|
559
|
+
return fact.fact_id
|
|
560
|
+
|
|
492
561
|
# Create parent memory record (FK: atomic_facts.memory_id → memories.memory_id)
|
|
493
562
|
if not fact.memory_id:
|
|
494
563
|
record = MemoryRecord(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Pure decision logic for the MCP stdin-EOF self-termination monitor.
|
|
6
|
+
|
|
7
|
+
Isolated here (no imports, no side effects) so the kqueue ``EV_EOF``
|
|
8
|
+
handling contract is unit-testable without importing the FastMCP server
|
|
9
|
+
module, which starts daemon threads and auto-starts the SLM daemon at
|
|
10
|
+
import time.
|
|
11
|
+
|
|
12
|
+
Background (v3.6.4 fix)
|
|
13
|
+
-----------------------
|
|
14
|
+
The stdin-EOF monitor (``superlocalmemory.mcp.server._stdin_eof_monitor``)
|
|
15
|
+
exists to reap orphaned ``slm mcp`` processes when an IDE/agent abandons the
|
|
16
|
+
stdio pipe without quitting. It registers ``EVFILT_READ | EV_EOF`` on stdin
|
|
17
|
+
and terminates the process when the write-end closes.
|
|
18
|
+
|
|
19
|
+
On macOS, ``EVFILT_READ`` reports ``EV_EOF`` *together with* still-readable
|
|
20
|
+
bytes (``ev.data > 0``) when the write-end is closed while a final request
|
|
21
|
+
is still buffered in the pipe. The original monitor exited on the EOF flag
|
|
22
|
+
alone — dropping that buffered request and tearing down a session that
|
|
23
|
+
still had a pending in-flight call. Under strict MCP hosts whose transport
|
|
24
|
+
half-closes stdin around reconnect/teardown, this surfaced as the server
|
|
25
|
+
self-terminating mid-request, which the host then logged as a keepalive
|
|
26
|
+
failure and respawned (observed against the Hermes agent).
|
|
27
|
+
|
|
28
|
+
The guard defers termination until the buffer is genuinely drained
|
|
29
|
+
(``ev.data <= 0``), letting the FastMCP reader consume the last request
|
|
30
|
+
first. Genuine disconnects (EOF with an empty buffer) still terminate
|
|
31
|
+
immediately — behaviour identical to before for the common case.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
__all__ = ["eof_action"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def eof_action(flags: int, data: int, eof_flag: int) -> str:
|
|
40
|
+
"""Decide how the stdin-EOF monitor should react to one kqueue event.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
flags: ``kevent.flags`` bitmask returned by ``kqueue.control``.
|
|
44
|
+
data: ``kevent.data`` — for ``EVFILT_READ`` this is the number of
|
|
45
|
+
bytes still readable on the descriptor.
|
|
46
|
+
eof_flag: the platform ``select.KQ_EV_EOF`` constant (injected so
|
|
47
|
+
this function stays import-free and trivially testable).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
- ``"exit"`` — genuine end-of-stream: write-end closed and the
|
|
51
|
+
buffer is drained (``data <= 0``). Safe to self-terminate.
|
|
52
|
+
- ``"drain"`` — write-end closed but unread bytes remain
|
|
53
|
+
(``data > 0``). Must NOT terminate yet; let the reader consume
|
|
54
|
+
the buffered request first, otherwise it is silently dropped.
|
|
55
|
+
- ``"ignore"`` — no EOF on this event (ordinary readability or a
|
|
56
|
+
spurious wake); nothing to do.
|
|
57
|
+
"""
|
|
58
|
+
if not (flags & eof_flag):
|
|
59
|
+
return "ignore"
|
|
60
|
+
return "drain" if data > 0 else "exit"
|
|
@@ -288,11 +288,26 @@ _watchdog_thread.start()
|
|
|
288
288
|
# closes WITHOUT consuming any bytes, so it cannot race with FastMCP's asyncio
|
|
289
289
|
# stdin reader. On Linux (no kqueue), the watchdog alone provides coverage.
|
|
290
290
|
def _stdin_eof_monitor() -> None:
|
|
291
|
-
"""Exit when the IDE closes our stdin pipe (kqueue — macOS only).
|
|
292
|
-
|
|
291
|
+
"""Exit when the IDE closes our stdin pipe (kqueue — macOS only).
|
|
292
|
+
|
|
293
|
+
V3.6.4: kqueue ``EVFILT_READ`` reports ``EV_EOF`` *together with*
|
|
294
|
+
still-readable bytes (``ev.data > 0``) when the write-end closes while a
|
|
295
|
+
final request is buffered. Exiting on the EOF flag alone (pre-3.6.4)
|
|
296
|
+
dropped that in-flight request and self-terminated a session that still
|
|
297
|
+
had work to deliver — strict MCP hosts (e.g. the Hermes agent) then
|
|
298
|
+
logged a keepalive failure and respawned the process. We now defer
|
|
299
|
+
termination until the buffer is genuinely drained (see ``_stdin_guard``).
|
|
300
|
+
"""
|
|
301
|
+
import select as _sel, os as _os_eof, time as _time
|
|
302
|
+
from superlocalmemory.mcp._stdin_guard import eof_action
|
|
293
303
|
_mlog = logging.getLogger(__name__ + ".stdin_monitor")
|
|
294
304
|
if not hasattr(_sel, "kqueue"):
|
|
295
305
|
return # Linux / non-macOS: watchdog covers process death
|
|
306
|
+
# Bounded grace for the FastMCP reader to drain a buffered final request
|
|
307
|
+
# before we tear down. ~2 s ceiling (40 × 50 ms): a genuine EOF means the
|
|
308
|
+
# session is ending regardless, so we never wait indefinitely.
|
|
309
|
+
_DRAIN_POLL_S = 0.05
|
|
310
|
+
_DRAIN_MAX_POLLS = 40
|
|
296
311
|
try:
|
|
297
312
|
fd = sys.stdin.fileno()
|
|
298
313
|
kq = _sel.kqueue()
|
|
@@ -301,9 +316,23 @@ def _stdin_eof_monitor() -> None:
|
|
|
301
316
|
while True:
|
|
302
317
|
evs = kq.control(None, 4, 30.0) # 30 s poll — low cost
|
|
303
318
|
for ev in evs:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
319
|
+
action = eof_action(ev.flags, ev.data, _sel.KQ_EV_EOF)
|
|
320
|
+
if action == "ignore":
|
|
321
|
+
continue
|
|
322
|
+
if action == "drain":
|
|
323
|
+
# Write-end closed but unread bytes remain. Let the
|
|
324
|
+
# FastMCP reader consume the final request(s); poll until
|
|
325
|
+
# drained or the grace ceiling elapses.
|
|
326
|
+
for _ in range(_DRAIN_MAX_POLLS):
|
|
327
|
+
_time.sleep(_DRAIN_POLL_S)
|
|
328
|
+
recheck = kq.control(None, 4, 0) # non-blocking
|
|
329
|
+
if not recheck:
|
|
330
|
+
break
|
|
331
|
+
ev = recheck[0]
|
|
332
|
+
if eof_action(ev.flags, ev.data, _sel.KQ_EV_EOF) != "drain":
|
|
333
|
+
break
|
|
334
|
+
_mlog.info("stdin write-end closed (kqueue EOF, drained), self-terminating")
|
|
335
|
+
_os_eof._exit(0)
|
|
307
336
|
except Exception as exc:
|
|
308
337
|
_mlog.debug("stdin EOF monitor error: %s — watchdog will cover", exc)
|
|
309
338
|
|
|
@@ -21,10 +21,19 @@ _STATIC_MECHANISMS = {"settings-file", "config-file", "print-only"}
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def _proxy_configured() -> bool:
|
|
24
|
-
"""Return True if proxy_enabled=True in optimize.json (no liveness check).
|
|
24
|
+
"""Return True if proxy_enabled=True in optimize.json (no liveness check).
|
|
25
|
+
|
|
26
|
+
Reads from the module-level _store if it has been set (daemon process or
|
|
27
|
+
tests that call _set_config_store). Falls back to a fresh ConfigStore read
|
|
28
|
+
from disk when _store is None — which is always the case in CLI subprocess
|
|
29
|
+
context because _set_config_store() is only called by the daemon on startup.
|
|
30
|
+
"""
|
|
25
31
|
try:
|
|
26
|
-
from superlocalmemory.optimize.config import
|
|
27
|
-
|
|
32
|
+
from superlocalmemory.optimize.config import _store
|
|
33
|
+
if _store is not None:
|
|
34
|
+
return _store.get().proxy_enabled
|
|
35
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
36
|
+
return ConfigStore().get().proxy_enabled
|
|
28
37
|
except Exception:
|
|
29
38
|
return False
|
|
30
39
|
|
|
@@ -153,9 +153,42 @@ class CacheManager:
|
|
|
153
153
|
# ---- INTERFACE-CONTRACT §4 public methods ----
|
|
154
154
|
|
|
155
155
|
def build_key(self, req: Any, tenant_id: str) -> str | None:
|
|
156
|
-
"""Build a deterministic cache key for req + tenant_id.
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
"""Build a deterministic cache key for req + tenant_id.
|
|
157
|
+
|
|
158
|
+
BUG-FIX (v3.6.3): Two bugs repaired here:
|
|
159
|
+
1. tenant_id="default" failed KeyBuilder's 64-char hex SHA-256 validation,
|
|
160
|
+
silently raising ValueError caught by fail-open wrappers → cache never
|
|
161
|
+
stored or retrieved anything via the proxy path. Fix: normalize any
|
|
162
|
+
non-hex tenant_id to its SHA-256 digest before passing to KeyBuilder.
|
|
163
|
+
2. ProxyRequest objects have a `body` dict, not model_id/messages/system
|
|
164
|
+
attributes. The old `getattr(req, "model_id", "")` path silently
|
|
165
|
+
returned empty strings → all proxy requests got the same (invalid) key.
|
|
166
|
+
Fix: detect ProxyRequest and extract fields from .body.
|
|
167
|
+
"""
|
|
168
|
+
import hashlib as _hashlib
|
|
169
|
+
import json as _json
|
|
170
|
+
import re as _re
|
|
171
|
+
_HEX64 = _re.compile(r"[0-9a-f]{64}")
|
|
172
|
+
# Normalize tenant_id: KeyBuilder requires a 64-char lowercase hex SHA-256.
|
|
173
|
+
if not _HEX64.fullmatch(tenant_id or ""):
|
|
174
|
+
tenant_id = _hashlib.sha256(tenant_id.encode()).hexdigest()
|
|
175
|
+
|
|
176
|
+
if isinstance(req, ProxyRequest):
|
|
177
|
+
# Extract semantic fields from the parsed JSON body.
|
|
178
|
+
body = req.body or {}
|
|
179
|
+
model_id = body.get("model", "") or ""
|
|
180
|
+
messages = body.get("messages", []) or []
|
|
181
|
+
system_raw = body.get("system", "") or ""
|
|
182
|
+
# Anthropic allows system as a list of content blocks — normalise to str.
|
|
183
|
+
if isinstance(system_raw, list):
|
|
184
|
+
system = _json.dumps(system_raw, sort_keys=True, separators=(",", ":"))
|
|
185
|
+
else:
|
|
186
|
+
system = str(system_raw)
|
|
187
|
+
# params: everything except fields extracted above and stream flag.
|
|
188
|
+
_SKIP = frozenset({"model", "messages", "system", "stream"})
|
|
189
|
+
params = {k: v for k, v in body.items() if k not in _SKIP}
|
|
190
|
+
elif isinstance(req, dict):
|
|
191
|
+
model_id = req.get("model", "") or ""
|
|
159
192
|
messages = req.get("messages", []) or []
|
|
160
193
|
params = req.get("params", {}) or {}
|
|
161
194
|
system = req.get("system", "") or ""
|
|
@@ -174,9 +207,18 @@ class CacheManager:
|
|
|
174
207
|
)
|
|
175
208
|
|
|
176
209
|
def get(self, req: Any, tenant_id: str) -> "CachedResponse | None":
|
|
177
|
-
"""CacheHook.check() entry point.
|
|
210
|
+
"""CacheHook.check() entry point.
|
|
211
|
+
|
|
212
|
+
BUG-FIX (v3.6.3): Previously returned None on cache miss, which caused
|
|
213
|
+
_safe_cache_check to return CachedResponse(cache_key=""). The empty
|
|
214
|
+
cache_key is falsy, so the store condition in handle_messages
|
|
215
|
+
(``cache_result.cache_key``) was always False → cache was NEVER
|
|
216
|
+
populated. Fix: return a miss CachedResponse that carries the computed
|
|
217
|
+
key so the store path can proceed.
|
|
218
|
+
"""
|
|
178
219
|
key = self.build_key(req, tenant_id)
|
|
179
220
|
if key is None:
|
|
221
|
+
# Uncacheable (non-zero temperature, etc.) — signal with None.
|
|
180
222
|
return None
|
|
181
223
|
row = self._exact.get(key, tenant_id)
|
|
182
224
|
if row is not None:
|
|
@@ -188,7 +230,8 @@ class CacheManager:
|
|
|
188
230
|
ttl_seconds=0,
|
|
189
231
|
)
|
|
190
232
|
self._metrics.exact_misses += 1
|
|
191
|
-
|
|
233
|
+
# Return miss WITH the key so callers can use it for cache storage.
|
|
234
|
+
return CachedResponse(hit=False, data=None, cache_key=key, ttl_seconds=0)
|
|
192
235
|
|
|
193
236
|
def set(self, req: Any, resp: Any, tenant_id: str) -> None:
|
|
194
237
|
"""CacheHook.store() entry point."""
|
|
@@ -196,8 +239,10 @@ class CacheManager:
|
|
|
196
239
|
key = self.build_key(req, tenant_id)
|
|
197
240
|
if key is None:
|
|
198
241
|
return
|
|
199
|
-
if isinstance(req,
|
|
200
|
-
model_id = req.get("model", "")
|
|
242
|
+
if isinstance(req, ProxyRequest):
|
|
243
|
+
model_id = (req.body or {}).get("model", "") or ""
|
|
244
|
+
elif isinstance(req, dict):
|
|
245
|
+
model_id = req.get("model", "") or ""
|
|
201
246
|
else:
|
|
202
247
|
model_id = getattr(req, "model_id", "") or ""
|
|
203
248
|
tags = [
|
|
@@ -216,9 +261,18 @@ class CacheManager:
|
|
|
216
261
|
# ---- CacheHook protocol implementation (INTERFACE-CONTRACT §3) ----
|
|
217
262
|
|
|
218
263
|
def check(self, req: ProxyRequest) -> "CachedResponse | None":
|
|
219
|
-
"""CacheHook.check() — look up by ProxyRequest; fail-open on error.
|
|
264
|
+
"""CacheHook.check() — look up by ProxyRequest; fail-open on error.
|
|
265
|
+
|
|
266
|
+
BUG-FIX (v3.6.3): on_miss() was never called from the proxy path,
|
|
267
|
+
so MetricsCollector.misses stayed at 0 and the dashboard always showed
|
|
268
|
+
0 misses. Fixed by calling on_miss() here whenever get() returns a
|
|
269
|
+
cache-miss result.
|
|
270
|
+
"""
|
|
220
271
|
try:
|
|
221
|
-
|
|
272
|
+
result = self.get(req, tenant_id="default")
|
|
273
|
+
if result is not None and not result.hit:
|
|
274
|
+
MetricsCollector.get_instance().on_miss()
|
|
275
|
+
return result
|
|
222
276
|
except Exception as exc:
|
|
223
277
|
logger.warning("CacheManager.check raised (fail-open): %s", exc)
|
|
224
278
|
return None
|
|
@@ -71,10 +71,6 @@ class CompressRouter:
|
|
|
71
71
|
|
|
72
72
|
if not cfg.compress_enabled:
|
|
73
73
|
return req
|
|
74
|
-
if req.stream:
|
|
75
|
-
return req
|
|
76
|
-
if req.has_tools:
|
|
77
|
-
return req # §6.5 safety rule
|
|
78
74
|
|
|
79
75
|
body = dict(req.body)
|
|
80
76
|
messages = body.get("messages", [])
|
|
@@ -139,8 +135,8 @@ class CompressRouter:
|
|
|
139
135
|
try:
|
|
140
136
|
saved = max(0, before_tokens - after_tokens)
|
|
141
137
|
if self._metrics_counters is not None:
|
|
142
|
-
# M-02:
|
|
143
|
-
self._metrics_counters.on_compress(
|
|
138
|
+
# M-02: pass before/after directly (bytes_original, bytes_after contract)
|
|
139
|
+
self._metrics_counters.on_compress(before_tokens, after_tokens)
|
|
144
140
|
logger.debug("on_compress: saved=%d tokens lossy=%s", saved, lossy)
|
|
145
141
|
except Exception as exc:
|
|
146
142
|
logger.debug("on_compress metrics update failed (non-fatal): %s", exc)
|
|
@@ -169,9 +165,11 @@ class CompressRouter:
|
|
|
169
165
|
for idx, msg in enumerate(messages):
|
|
170
166
|
role = msg.get("role", "")
|
|
171
167
|
is_tool_msg = (
|
|
172
|
-
role == "tool"
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
role == "tool" # OpenAI tool result messages (plain text, skip entirely)
|
|
169
|
+
# Anthropic tool_result blocks are NOT skipped — _compress_content_block
|
|
170
|
+
# handles type=="tool_result" blocks by compressing their text content
|
|
171
|
+
# while preserving the block structure. This is where the bulk of
|
|
172
|
+
# Claude Code output lives (bash results, file contents, JSON data).
|
|
175
173
|
)
|
|
176
174
|
if idx in protect_indices or is_tool_msg:
|
|
177
175
|
new_messages.append(msg)
|