superlocalmemory 3.5.8 → 3.6.0
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/ATTRIBUTION.md +24 -0
- package/CHANGELOG.md +35 -0
- package/README.md +142 -35
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/cache_cmd.py +198 -0
- package/src/superlocalmemory/cli/commands.py +80 -2
- package/src/superlocalmemory/cli/compress_cmd.py +179 -0
- package/src/superlocalmemory/cli/help_cmd.py +197 -0
- package/src/superlocalmemory/cli/main.py +122 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +175 -0
- package/src/superlocalmemory/cli/optimize_constants.py +31 -0
- package/src/superlocalmemory/cli/proxy_cmd.py +95 -0
- package/src/superlocalmemory/core/config.py +5 -0
- package/src/superlocalmemory/core/engine.py +23 -0
- package/src/superlocalmemory/core/mcp_embedder_proxy.py +89 -0
- package/src/superlocalmemory/llm/backbone.py +10 -4
- package/src/superlocalmemory/mcp/server.py +34 -0
- package/src/superlocalmemory/mcp/tools_v3.py +6 -2
- package/src/superlocalmemory/optimize/NOTICE +11 -0
- package/src/superlocalmemory/optimize/__init__.py +0 -0
- package/src/superlocalmemory/optimize/adapters/__init__.py +68 -0
- package/src/superlocalmemory/optimize/adapters/_agent_registry.py +120 -0
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +115 -0
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +125 -0
- package/src/superlocalmemory/optimize/adapters/wrap.py +188 -0
- package/src/superlocalmemory/optimize/cache/__init__.py +31 -0
- package/src/superlocalmemory/optimize/cache/boundary_store.py +455 -0
- package/src/superlocalmemory/optimize/cache/centroid_store.py +158 -0
- package/src/superlocalmemory/optimize/cache/context_key.py +67 -0
- package/src/superlocalmemory/optimize/cache/exact.py +85 -0
- package/src/superlocalmemory/optimize/cache/invalidation.py +36 -0
- package/src/superlocalmemory/optimize/cache/key_builder.py +98 -0
- package/src/superlocalmemory/optimize/cache/manager.py +452 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +568 -0
- package/src/superlocalmemory/optimize/cache/stampede.py +50 -0
- package/src/superlocalmemory/optimize/compress/__init__.py +17 -0
- package/src/superlocalmemory/optimize/compress/align.py +153 -0
- package/src/superlocalmemory/optimize/compress/ccr.py +157 -0
- package/src/superlocalmemory/optimize/compress/extractive_code.py +311 -0
- package/src/superlocalmemory/optimize/compress/extractive_json.py +72 -0
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +77 -0
- package/src/superlocalmemory/optimize/compress/router.py +548 -0
- package/src/superlocalmemory/optimize/config/__init__.py +35 -0
- package/src/superlocalmemory/optimize/config/defaults.py +48 -0
- package/src/superlocalmemory/optimize/config/schema.py +255 -0
- package/src/superlocalmemory/optimize/config/store.py +209 -0
- package/src/superlocalmemory/optimize/metrics/__init__.py +8 -0
- package/src/superlocalmemory/optimize/metrics/counters.py +138 -0
- package/src/superlocalmemory/optimize/metrics/estimator.py +90 -0
- package/src/superlocalmemory/optimize/metrics/exporters.py +77 -0
- package/src/superlocalmemory/optimize/metrics/persistence.py +115 -0
- package/src/superlocalmemory/optimize/proxy/__init__.py +28 -0
- package/src/superlocalmemory/optimize/proxy/_helpers.py +257 -0
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +171 -0
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +121 -0
- package/src/superlocalmemory/optimize/proxy/lifecycle.py +126 -0
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +125 -0
- package/src/superlocalmemory/optimize/proxy/server.py +151 -0
- package/src/superlocalmemory/optimize/storage/__init__.py +0 -0
- package/src/superlocalmemory/optimize/storage/db.py +1016 -0
- package/src/superlocalmemory/optimize/storage/schema.py +184 -0
- package/src/superlocalmemory/server/routes/optimize.py +166 -0
- package/src/superlocalmemory/server/routes/v3_api.py +63 -1
- package/src/superlocalmemory/server/unified_daemon.py +105 -0
- package/src/superlocalmemory/ui/index.html +98 -0
- package/src/superlocalmemory/ui/js/ng-shell.js +2 -1
- package/src/superlocalmemory/ui/js/optimize.js +173 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +2 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +51 -0
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""exact.py — SQLite-backed exact-match get/set, cacheable-response guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from superlocalmemory.optimize.cache.key_builder import CacheConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_NON_CACHEABLE_FINISH_REASONS: frozenset[str] = frozenset({
|
|
13
|
+
"tool_use",
|
|
14
|
+
"tool_calls",
|
|
15
|
+
"length",
|
|
16
|
+
"max_tokens",
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_cacheable_response(response: dict) -> bool:
|
|
21
|
+
finish = response.get("stop_reason") or response.get("finish_reason") or ""
|
|
22
|
+
if finish in _NON_CACHEABLE_FINISH_REASONS:
|
|
23
|
+
return False
|
|
24
|
+
choices = response.get("choices") or []
|
|
25
|
+
for choice in choices:
|
|
26
|
+
fr = (choice.get("finish_reason") or "")
|
|
27
|
+
if fr in _NON_CACHEABLE_FINISH_REASONS:
|
|
28
|
+
return False
|
|
29
|
+
msg = choice.get("message") or {}
|
|
30
|
+
if msg.get("tool_calls"):
|
|
31
|
+
return False
|
|
32
|
+
for block in response.get("content") or []:
|
|
33
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
34
|
+
return False
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExactCache:
|
|
39
|
+
"""Exact-match cache layer backed by llmcache.db."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, db: Any, config: CacheConfig | None = None) -> None:
|
|
42
|
+
self._db = db
|
|
43
|
+
self._config = config or CacheConfig()
|
|
44
|
+
|
|
45
|
+
def get(self, key: str, tenant_id: str) -> dict | None:
|
|
46
|
+
row = self._db.get(key, tenant_id)
|
|
47
|
+
if row is None:
|
|
48
|
+
return None
|
|
49
|
+
if row.ttl_expires is not None and row.ttl_expires < time.time():
|
|
50
|
+
self._db.delete(key, tenant_id)
|
|
51
|
+
return None
|
|
52
|
+
return json.loads(row.value.decode("utf-8"))
|
|
53
|
+
|
|
54
|
+
def set(
|
|
55
|
+
self,
|
|
56
|
+
key: str,
|
|
57
|
+
tenant_id: str,
|
|
58
|
+
response: dict,
|
|
59
|
+
tags: list[str],
|
|
60
|
+
model: str,
|
|
61
|
+
ttl: int | None = None,
|
|
62
|
+
) -> bool:
|
|
63
|
+
if not self.is_cacheable(response):
|
|
64
|
+
return False
|
|
65
|
+
serialized = json.dumps(response, separators=(",", ":"), default=str)
|
|
66
|
+
encoded = serialized.encode("utf-8")
|
|
67
|
+
if len(encoded) > self._config.max_response_bytes:
|
|
68
|
+
return False
|
|
69
|
+
effective_ttl = ttl if ttl is not None else self._config.default_ttl_seconds
|
|
70
|
+
expires_at = time.time() + effective_ttl if effective_ttl > 0 else None
|
|
71
|
+
self._db.set(
|
|
72
|
+
key=key,
|
|
73
|
+
tenant_id=tenant_id,
|
|
74
|
+
value=encoded,
|
|
75
|
+
model=model,
|
|
76
|
+
ttl_expires=expires_at,
|
|
77
|
+
tags=[],
|
|
78
|
+
)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
def delete(self, key: str, tenant_id: str) -> None:
|
|
82
|
+
self._db.delete(key, tenant_id)
|
|
83
|
+
|
|
84
|
+
def is_cacheable(self, response: dict) -> bool:
|
|
85
|
+
return _is_cacheable_response(response)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""invalidation.py — tag-based bulk eviction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from superlocalmemory.optimize.storage.db import CacheDB
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidationEngine:
|
|
12
|
+
"""Tag-based bulk eviction for llmcache.db."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, db: "CacheDB") -> None:
|
|
15
|
+
self._db = db
|
|
16
|
+
|
|
17
|
+
def register(self, key: str, tenant_id: str, tags: list[str]) -> None:
|
|
18
|
+
self._db.tag_register(key=key, tenant_id=tenant_id, tags=tags)
|
|
19
|
+
|
|
20
|
+
def invalidate_tag(self, tag: str) -> int:
|
|
21
|
+
return self._db.invalidate_by_tag(tag)
|
|
22
|
+
|
|
23
|
+
def invalidate_model(self, model_id: str) -> int:
|
|
24
|
+
return self.invalidate_tag(f"model:{model_id}")
|
|
25
|
+
|
|
26
|
+
def invalidate_tenant(self, tenant_id: str) -> int:
|
|
27
|
+
return self.invalidate_tag(f"tenant:{tenant_id}")
|
|
28
|
+
|
|
29
|
+
def invalidate_key(self, key: str, tenant_id: str) -> None:
|
|
30
|
+
self._db.delete(key, tenant_id)
|
|
31
|
+
|
|
32
|
+
def get_tags_for_key(self, key: str, tenant_id: str) -> list[str]:
|
|
33
|
+
row = self._db.get(key, tenant_id)
|
|
34
|
+
if row is None:
|
|
35
|
+
return []
|
|
36
|
+
return row.tags
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""key_builder.py — deterministic SHA-256 cache key, tenant-scoped."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
DETERMINISTIC_PARAMS: frozenset[str] = frozenset({
|
|
12
|
+
"max_tokens", "stop", "stop_sequences", "top_p", "top_k",
|
|
13
|
+
"response_format", "tools", "tool_choice",
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
EXCLUDED_PARAMS: frozenset[str] = frozenset({
|
|
17
|
+
"stream", "temperature", "seed", "user", "metadata",
|
|
18
|
+
"request_id", "idempotency_key", "timeout", "max_retries",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
_KEY_SCHEMA_VERSION = 1
|
|
22
|
+
_KEY_PREFIX = "slmcache"
|
|
23
|
+
|
|
24
|
+
_TENANT_ID_RE = re.compile(r"[0-9a-f]{64}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CacheConfig:
|
|
29
|
+
"""Runtime configuration for the cache engine."""
|
|
30
|
+
default_ttl_seconds: int = 3600
|
|
31
|
+
max_response_bytes: int = 1_048_576 # 1 MB
|
|
32
|
+
allow_nonzero_temperature_cache: bool = False
|
|
33
|
+
stampede_timeout_seconds: float = 30.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class KeyBuilder:
|
|
37
|
+
"""Builds deterministic, tenant-scoped, collision-resistant SHA-256 cache keys."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: CacheConfig | None = None) -> None:
|
|
40
|
+
self._config = config or CacheConfig()
|
|
41
|
+
|
|
42
|
+
def build(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
tenant_id: str,
|
|
46
|
+
model_id: str,
|
|
47
|
+
model_version: str,
|
|
48
|
+
system: str,
|
|
49
|
+
messages: list,
|
|
50
|
+
raw_params: dict | None = None,
|
|
51
|
+
) -> str | None:
|
|
52
|
+
if not _TENANT_ID_RE.fullmatch(tenant_id or ""):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"tenant_id must be a 64-char lowercase hex SHA-256 digest "
|
|
55
|
+
f"(got {len(tenant_id or '')} chars) — F4 multi-tenant isolation"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
raw_params = raw_params or {}
|
|
59
|
+
try:
|
|
60
|
+
temperature = float(raw_params.get("temperature", 0) or 0)
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
temperature = 0.0
|
|
63
|
+
|
|
64
|
+
if temperature != 0 and not self._config.allow_nonzero_temperature_cache:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
deterministic_params: dict[str, Any] = {}
|
|
68
|
+
if temperature == 0:
|
|
69
|
+
deterministic_params["temperature"] = 0
|
|
70
|
+
elif self._config.allow_nonzero_temperature_cache:
|
|
71
|
+
deterministic_params["temperature"] = temperature
|
|
72
|
+
|
|
73
|
+
for k in sorted(DETERMINISTIC_PARAMS):
|
|
74
|
+
if k in raw_params:
|
|
75
|
+
deterministic_params[k] = raw_params[k]
|
|
76
|
+
|
|
77
|
+
payload: dict[str, Any] = {
|
|
78
|
+
"v": _KEY_SCHEMA_VERSION,
|
|
79
|
+
"tenant": tenant_id,
|
|
80
|
+
"model": model_id,
|
|
81
|
+
"model_version": model_version,
|
|
82
|
+
"system": system,
|
|
83
|
+
"messages": messages,
|
|
84
|
+
"params": deterministic_params,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
canonical = json.dumps(
|
|
88
|
+
payload, sort_keys=True, separators=(",", ":"),
|
|
89
|
+
ensure_ascii=True, default=str,
|
|
90
|
+
)
|
|
91
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
92
|
+
return f"{_KEY_PREFIX}:v{_KEY_SCHEMA_VERSION}:{tenant_id}:resp:{digest}"
|
|
93
|
+
|
|
94
|
+
def tenant_tag(self, tenant_id: str) -> str:
|
|
95
|
+
return f"tenant:{tenant_id}"
|
|
96
|
+
|
|
97
|
+
def model_tag(self, model_id: str) -> str:
|
|
98
|
+
return f"model:{model_id}"
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""manager.py — CacheManager orchestrator (singleton, fail-open, stampede-shielded)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from superlocalmemory.optimize.cache.exact import ExactCache
|
|
12
|
+
from superlocalmemory.optimize.cache.invalidation import InvalidationEngine
|
|
13
|
+
from superlocalmemory.optimize.cache.key_builder import CacheConfig, KeyBuilder
|
|
14
|
+
from superlocalmemory.optimize.cache.stampede import StampedeShield
|
|
15
|
+
from superlocalmemory.optimize.metrics.counters import MetricsCollector
|
|
16
|
+
from superlocalmemory.optimize.proxy.lifecycle import (
|
|
17
|
+
CachedResponse,
|
|
18
|
+
ProxyRequest,
|
|
19
|
+
ProviderResponse,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# SemanticTier interface seam
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
class SemanticTier(ABC):
|
|
30
|
+
"""Abstract interface for the semantic cache tier (Phase 3).
|
|
31
|
+
|
|
32
|
+
INTERFACE-CONTRACT §4 conformance: lookup/learn/index_entry/is_enabled.
|
|
33
|
+
Phase 3 (LLD-03) implements VCacheSemantic.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def lookup(self, req, tenant_id: str, embed):
|
|
38
|
+
"""Return semantically similar cached response or None. Fail-open."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def learn(self, entry_id: str, similarity: float, was_correct: bool) -> None:
|
|
43
|
+
"""Update per-item MLE model with feedback. Fail-open."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def index_entry(
|
|
48
|
+
self, req, tenant_id: str, embed, resp
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Index a new response vector in the ANN index. Fail-open.
|
|
51
|
+
|
|
52
|
+
INTERFACE-CONTRACT v2 §4: canonical signature
|
|
53
|
+
(self, req, tenant_id, embed, resp).
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def is_enabled(self) -> bool: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NoOpSemantic(SemanticTier):
|
|
62
|
+
"""Phase 1 placeholder (also used when semantic_enabled=False)."""
|
|
63
|
+
|
|
64
|
+
def lookup(self, req, tenant_id: str, embed):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def learn(self, entry_id: str, similarity: float, was_correct: bool) -> None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def index_entry(self, req, tenant_id: str, embed, resp) -> None:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def is_enabled(self) -> bool:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Metrics
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
class CacheMetrics:
|
|
82
|
+
"""Thread-safe counters. A-20 fix: lock guards the two-counter read."""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
self.exact_hits: int = 0
|
|
87
|
+
self.exact_misses: int = 0
|
|
88
|
+
self.semantic_hits: int = 0
|
|
89
|
+
self.semantic_misses: int = 0
|
|
90
|
+
self.sets: int = 0
|
|
91
|
+
self.skipped_non_cacheable: int = 0
|
|
92
|
+
self.invalidations: int = 0
|
|
93
|
+
self.stampede_contentions: int = 0
|
|
94
|
+
self.errors: int = 0
|
|
95
|
+
|
|
96
|
+
def hit_rate(self) -> float:
|
|
97
|
+
with self._lock:
|
|
98
|
+
total = self.exact_hits + self.exact_misses
|
|
99
|
+
return self.exact_hits / total if total > 0 else 0.0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# CacheManager
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
class CacheManager:
|
|
107
|
+
"""Central orchestrator for the SLM Optimize exact cache."""
|
|
108
|
+
|
|
109
|
+
_instance: "CacheManager | None" = None
|
|
110
|
+
_instance_lock: threading.Lock = threading.Lock()
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
db: Any,
|
|
115
|
+
config: CacheConfig | None = None,
|
|
116
|
+
semantic_tier: SemanticTier | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._db = db
|
|
119
|
+
self._config = config or CacheConfig()
|
|
120
|
+
self._key_builder = KeyBuilder(self._config)
|
|
121
|
+
self._exact = ExactCache(db, self._config)
|
|
122
|
+
self._stampede = StampedeShield(timeout=self._config.stampede_timeout_seconds)
|
|
123
|
+
self._invalidation = InvalidationEngine(db)
|
|
124
|
+
self._semantic = semantic_tier or NoOpSemantic()
|
|
125
|
+
self._metrics = CacheMetrics()
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def get_instance(cls) -> "CacheManager":
|
|
129
|
+
if cls._instance is None:
|
|
130
|
+
with cls._instance_lock:
|
|
131
|
+
if cls._instance is None:
|
|
132
|
+
from superlocalmemory.optimize.storage.db import CacheDB as _CacheDB
|
|
133
|
+
_db = _CacheDB.get_default()
|
|
134
|
+
cls._instance = cls(db=_db)
|
|
135
|
+
return cls._instance
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def set_instance(cls, instance: "CacheManager") -> None:
|
|
139
|
+
with cls._instance_lock:
|
|
140
|
+
cls._instance = instance
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def reset_instance(cls) -> None:
|
|
144
|
+
"""Reset the singleton (testing only)."""
|
|
145
|
+
with cls._instance_lock:
|
|
146
|
+
if cls._instance is not None:
|
|
147
|
+
try:
|
|
148
|
+
cls._instance._db.close() # type: ignore[attr-defined]
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
cls._instance = None
|
|
152
|
+
|
|
153
|
+
# ---- INTERFACE-CONTRACT §4 public methods ----
|
|
154
|
+
|
|
155
|
+
def build_key(self, req: Any, tenant_id: str) -> str | None:
|
|
156
|
+
"""Build a deterministic cache key for req + tenant_id."""
|
|
157
|
+
if isinstance(req, dict):
|
|
158
|
+
model_id = req.get("model", "")
|
|
159
|
+
messages = req.get("messages", []) or []
|
|
160
|
+
params = req.get("params", {}) or {}
|
|
161
|
+
system = req.get("system", "") or ""
|
|
162
|
+
else:
|
|
163
|
+
model_id = getattr(req, "model_id", "") or ""
|
|
164
|
+
messages = getattr(req, "messages", []) or []
|
|
165
|
+
params = getattr(req, "params", {}) or {}
|
|
166
|
+
system = getattr(req, "system", "") or ""
|
|
167
|
+
return self._key_builder.build(
|
|
168
|
+
tenant_id=tenant_id,
|
|
169
|
+
model_id=model_id,
|
|
170
|
+
model_version="",
|
|
171
|
+
system=system,
|
|
172
|
+
messages=messages,
|
|
173
|
+
raw_params=params,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def get(self, req: Any, tenant_id: str) -> "CachedResponse | None":
|
|
177
|
+
"""CacheHook.check() entry point."""
|
|
178
|
+
key = self.build_key(req, tenant_id)
|
|
179
|
+
if key is None:
|
|
180
|
+
return None
|
|
181
|
+
row = self._exact.get(key, tenant_id)
|
|
182
|
+
if row is not None:
|
|
183
|
+
self._metrics.exact_hits += 1
|
|
184
|
+
return CachedResponse(
|
|
185
|
+
hit=True,
|
|
186
|
+
data=json_dumps_bytes(row),
|
|
187
|
+
cache_key=key,
|
|
188
|
+
ttl_seconds=0,
|
|
189
|
+
)
|
|
190
|
+
self._metrics.exact_misses += 1
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def set(self, req: Any, resp: Any, tenant_id: str) -> None:
|
|
194
|
+
"""CacheHook.store() entry point."""
|
|
195
|
+
import json as _json
|
|
196
|
+
key = self.build_key(req, tenant_id)
|
|
197
|
+
if key is None:
|
|
198
|
+
return
|
|
199
|
+
if isinstance(req, dict):
|
|
200
|
+
model_id = req.get("model", "")
|
|
201
|
+
else:
|
|
202
|
+
model_id = getattr(req, "model_id", "") or ""
|
|
203
|
+
tags = [
|
|
204
|
+
self._key_builder.model_tag(model_id),
|
|
205
|
+
self._key_builder.tenant_tag(tenant_id),
|
|
206
|
+
]
|
|
207
|
+
# resp may be ProviderResponse (proxy) or dict (manager)
|
|
208
|
+
if isinstance(resp, dict):
|
|
209
|
+
response_dict = resp
|
|
210
|
+
else:
|
|
211
|
+
response_dict = _json.loads(resp.body_bytes) if hasattr(resp, "body_bytes") else {}
|
|
212
|
+
self._exact.set(key, tenant_id, response_dict, tags, model_id)
|
|
213
|
+
self._invalidation.register(key, tenant_id, tags)
|
|
214
|
+
self._metrics.sets += 1
|
|
215
|
+
|
|
216
|
+
# ---- CacheHook protocol implementation (INTERFACE-CONTRACT §3) ----
|
|
217
|
+
|
|
218
|
+
def check(self, req: ProxyRequest) -> "CachedResponse | None":
|
|
219
|
+
"""CacheHook.check() — look up by ProxyRequest; fail-open on error."""
|
|
220
|
+
try:
|
|
221
|
+
return self.get(req, tenant_id="default")
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
logger.warning("CacheManager.check raised (fail-open): %s", exc)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def store(self, req: ProxyRequest, resp: ProviderResponse) -> None:
|
|
227
|
+
"""CacheHook.store() — persist response; fail-open on error."""
|
|
228
|
+
try:
|
|
229
|
+
self.set(req, resp, tenant_id="default")
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
logger.warning("CacheManager.store raised (fail-open): %s", exc)
|
|
232
|
+
|
|
233
|
+
def on_hit(self, req: ProxyRequest, resp: bytes, tokens_saved: int) -> None:
|
|
234
|
+
"""CacheHook.on_hit() — forward token savings to MetricsCollector.
|
|
235
|
+
|
|
236
|
+
Contract §7: cache skip saves BOTH input+output tokens (whole call avoided).
|
|
237
|
+
tokens_saved = input tokens saved (from request).
|
|
238
|
+
Output tokens estimated from response body size (~4 bytes per token).
|
|
239
|
+
"""
|
|
240
|
+
output_tokens = 0
|
|
241
|
+
if resp:
|
|
242
|
+
# Rough estimate: 4 bytes per token for English text
|
|
243
|
+
output_tokens = len(resp) // 4
|
|
244
|
+
MetricsCollector.get_instance().on_hit(
|
|
245
|
+
tokens_saved_input=tokens_saved,
|
|
246
|
+
tokens_saved_output=output_tokens,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def on_miss(self, req: ProxyRequest) -> None:
|
|
250
|
+
"""CacheHook.on_miss() — forward miss event to MetricsCollector."""
|
|
251
|
+
MetricsCollector.get_instance().on_miss()
|
|
252
|
+
|
|
253
|
+
def set_semantic_tier(self, tier: SemanticTier) -> None:
|
|
254
|
+
self._semantic = tier
|
|
255
|
+
|
|
256
|
+
# ---- core request path ----
|
|
257
|
+
|
|
258
|
+
def get_or_call(
|
|
259
|
+
self,
|
|
260
|
+
*,
|
|
261
|
+
tenant_id: str,
|
|
262
|
+
model_id: str,
|
|
263
|
+
model_version: str,
|
|
264
|
+
system: str,
|
|
265
|
+
messages: list,
|
|
266
|
+
raw_params: dict,
|
|
267
|
+
upstream_fn: Callable[[], dict],
|
|
268
|
+
http_status: int = 200,
|
|
269
|
+
ttl: int | None = None,
|
|
270
|
+
extra_tags: list | None = None,
|
|
271
|
+
) -> dict:
|
|
272
|
+
from types import SimpleNamespace as _NS
|
|
273
|
+
_req = _NS(
|
|
274
|
+
model_id=model_id, model_version=model_version,
|
|
275
|
+
system=system, messages=messages, params=raw_params,
|
|
276
|
+
)
|
|
277
|
+
try:
|
|
278
|
+
return self._get_or_call_inner(
|
|
279
|
+
req=_req,
|
|
280
|
+
tenant_id=tenant_id, model_id=model_id, model_version=model_version,
|
|
281
|
+
system=system, messages=messages, raw_params=raw_params,
|
|
282
|
+
upstream_fn=upstream_fn, http_status=http_status,
|
|
283
|
+
ttl=ttl, extra_tags=extra_tags or [],
|
|
284
|
+
)
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
logger.error(
|
|
287
|
+
"CacheManager.get_or_call failed — falling through to upstream: %s", exc,
|
|
288
|
+
exc_info=True,
|
|
289
|
+
)
|
|
290
|
+
self._metrics.errors += 1
|
|
291
|
+
return upstream_fn()
|
|
292
|
+
|
|
293
|
+
def _get_or_call_inner(
|
|
294
|
+
self,
|
|
295
|
+
*,
|
|
296
|
+
req: Any,
|
|
297
|
+
tenant_id: str,
|
|
298
|
+
model_id: str,
|
|
299
|
+
model_version: str,
|
|
300
|
+
system: str,
|
|
301
|
+
messages: list,
|
|
302
|
+
raw_params: dict,
|
|
303
|
+
upstream_fn: Callable[[], dict],
|
|
304
|
+
http_status: int,
|
|
305
|
+
ttl: int | None,
|
|
306
|
+
extra_tags: list,
|
|
307
|
+
) -> dict:
|
|
308
|
+
key = self._key_builder.build(
|
|
309
|
+
tenant_id=tenant_id, model_id=model_id, model_version=model_version,
|
|
310
|
+
system=system, messages=messages, raw_params=raw_params,
|
|
311
|
+
)
|
|
312
|
+
if key is None:
|
|
313
|
+
self._metrics.exact_misses += 1
|
|
314
|
+
return upstream_fn()
|
|
315
|
+
|
|
316
|
+
cached = self._exact.get(key, tenant_id)
|
|
317
|
+
if cached is not None:
|
|
318
|
+
self._metrics.exact_hits += 1
|
|
319
|
+
return cached
|
|
320
|
+
|
|
321
|
+
if self._semantic.is_enabled():
|
|
322
|
+
# vCache path: exact miss → optional semantic hit
|
|
323
|
+
# (CacheManager does not pre-embed; the semantic tier embeds
|
|
324
|
+
# lazily via its injected EmbeddingService. The VCache lookup
|
|
325
|
+
# returns a response dict or None on miss/explore.)
|
|
326
|
+
try:
|
|
327
|
+
sem_hit = self._semantic.lookup(req, tenant_id, None)
|
|
328
|
+
if sem_hit is not None:
|
|
329
|
+
self._metrics.semantic_hits += 1
|
|
330
|
+
return sem_hit
|
|
331
|
+
except Exception as exc:
|
|
332
|
+
logger.warning("SemanticTier.lookup raised (fail-open): %s", exc)
|
|
333
|
+
self._metrics.semantic_misses += 1
|
|
334
|
+
|
|
335
|
+
self._metrics.exact_misses += 1
|
|
336
|
+
|
|
337
|
+
with self._stampede.lock(key):
|
|
338
|
+
cached = self._exact.get(key, tenant_id)
|
|
339
|
+
if cached is not None:
|
|
340
|
+
self._metrics.stampede_contentions += 1
|
|
341
|
+
return cached
|
|
342
|
+
|
|
343
|
+
response = upstream_fn()
|
|
344
|
+
|
|
345
|
+
if 200 <= http_status < 300:
|
|
346
|
+
tags = [
|
|
347
|
+
self._key_builder.model_tag(model_id),
|
|
348
|
+
self._key_builder.tenant_tag(tenant_id),
|
|
349
|
+
*extra_tags,
|
|
350
|
+
]
|
|
351
|
+
stored = self._exact.set(key, tenant_id, response, tags, model_id, ttl)
|
|
352
|
+
if stored:
|
|
353
|
+
self._invalidation.register(key, tenant_id, tags)
|
|
354
|
+
self._metrics.sets += 1
|
|
355
|
+
else:
|
|
356
|
+
self._metrics.skipped_non_cacheable += 1
|
|
357
|
+
|
|
358
|
+
return response
|
|
359
|
+
|
|
360
|
+
# ---- tenant scoping ----
|
|
361
|
+
|
|
362
|
+
def for_tenant(self, tenant_id: str) -> "_TenantScopedManager":
|
|
363
|
+
return _TenantScopedManager(manager=self, tenant_id=tenant_id)
|
|
364
|
+
|
|
365
|
+
# ---- invalidation API ----
|
|
366
|
+
|
|
367
|
+
def invalidate_tag(self, tag: str) -> int:
|
|
368
|
+
count = self._invalidation.invalidate_tag(tag)
|
|
369
|
+
self._metrics.invalidations += count
|
|
370
|
+
return count
|
|
371
|
+
|
|
372
|
+
def invalidate_by_tag(self, tag: str) -> int:
|
|
373
|
+
"""INTERFACE-CONTRACT v2 §4: delegate to invalidation engine by tag."""
|
|
374
|
+
return self.invalidate_tag(tag)
|
|
375
|
+
|
|
376
|
+
def invalidate_model(self, model_id: str) -> int:
|
|
377
|
+
return self.invalidate_tag(f"model:{model_id}")
|
|
378
|
+
|
|
379
|
+
def invalidate_tenant(self, tenant_id: str) -> int:
|
|
380
|
+
return self.invalidate_tag(f"tenant:{tenant_id}")
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def metrics(self) -> CacheMetrics:
|
|
384
|
+
return self._metrics
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class _TenantScopedManager:
|
|
388
|
+
"""Thin view over CacheManager with tenant_id pre-filled."""
|
|
389
|
+
|
|
390
|
+
def __init__(self, manager: CacheManager, tenant_id: str) -> None:
|
|
391
|
+
self._m = manager
|
|
392
|
+
self._tenant_id = tenant_id
|
|
393
|
+
|
|
394
|
+
def get_or_call(
|
|
395
|
+
self,
|
|
396
|
+
*,
|
|
397
|
+
model_id: str,
|
|
398
|
+
model_version: str,
|
|
399
|
+
system: str,
|
|
400
|
+
messages: list,
|
|
401
|
+
raw_params: dict,
|
|
402
|
+
upstream_fn: Callable[[], dict],
|
|
403
|
+
http_status: int = 200,
|
|
404
|
+
ttl: int | None = None,
|
|
405
|
+
extra_tags: list | None = None,
|
|
406
|
+
) -> dict:
|
|
407
|
+
return self._m.get_or_call(
|
|
408
|
+
tenant_id=self._tenant_id,
|
|
409
|
+
model_id=model_id,
|
|
410
|
+
model_version=model_version,
|
|
411
|
+
system=system,
|
|
412
|
+
messages=messages,
|
|
413
|
+
raw_params=raw_params,
|
|
414
|
+
upstream_fn=upstream_fn,
|
|
415
|
+
http_status=http_status,
|
|
416
|
+
ttl=ttl,
|
|
417
|
+
extra_tags=extra_tags,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def get(self, key: str) -> bytes | None:
|
|
421
|
+
row = self._m._exact.get(key, self._tenant_id)
|
|
422
|
+
if row is None:
|
|
423
|
+
return None
|
|
424
|
+
import json as _json
|
|
425
|
+
return _json.dumps(row).encode("utf-8")
|
|
426
|
+
|
|
427
|
+
def set(self, key: str, value: bytes) -> None:
|
|
428
|
+
# Adapter-friendly write path: not used in Phase 1.
|
|
429
|
+
import json as _json
|
|
430
|
+
try:
|
|
431
|
+
decoded = _json.loads(value.decode("utf-8"))
|
|
432
|
+
except Exception:
|
|
433
|
+
return
|
|
434
|
+
self._m._exact.set(
|
|
435
|
+
key, self._tenant_id, decoded, [], "", None,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def invalidate_all(self) -> int:
|
|
439
|
+
return self._m.invalidate_tenant(self._tenant_id)
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def metrics(self) -> CacheMetrics:
|
|
443
|
+
return self._m.metrics
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# Helper
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
def json_dumps_bytes(d: dict) -> bytes:
|
|
451
|
+
import json as _json
|
|
452
|
+
return _json.dumps(d, separators=(",", ":"), default=str).encode("utf-8")
|