superlocalmemory 3.5.8 → 3.6.1

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.
Files changed (72) hide show
  1. package/ATTRIBUTION.md +24 -0
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +142 -35
  4. package/package.json +1 -1
  5. package/pyproject.toml +2 -1
  6. package/src/superlocalmemory/__init__.py +1 -1
  7. package/src/superlocalmemory/cli/cache_cmd.py +198 -0
  8. package/src/superlocalmemory/cli/commands.py +80 -2
  9. package/src/superlocalmemory/cli/compress_cmd.py +179 -0
  10. package/src/superlocalmemory/cli/help_cmd.py +197 -0
  11. package/src/superlocalmemory/cli/main.py +122 -0
  12. package/src/superlocalmemory/cli/optimize_cmd.py +178 -0
  13. package/src/superlocalmemory/cli/optimize_constants.py +31 -0
  14. package/src/superlocalmemory/cli/proxy_cmd.py +104 -0
  15. package/src/superlocalmemory/core/config.py +5 -0
  16. package/src/superlocalmemory/core/engine.py +23 -0
  17. package/src/superlocalmemory/core/mcp_embedder_proxy.py +89 -0
  18. package/src/superlocalmemory/llm/backbone.py +10 -4
  19. package/src/superlocalmemory/mcp/server.py +34 -0
  20. package/src/superlocalmemory/mcp/tools_v3.py +6 -2
  21. package/src/superlocalmemory/optimize/NOTICE +11 -0
  22. package/src/superlocalmemory/optimize/__init__.py +0 -0
  23. package/src/superlocalmemory/optimize/adapters/__init__.py +68 -0
  24. package/src/superlocalmemory/optimize/adapters/_agent_registry.py +120 -0
  25. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +115 -0
  26. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +125 -0
  27. package/src/superlocalmemory/optimize/adapters/wrap.py +218 -0
  28. package/src/superlocalmemory/optimize/cache/__init__.py +31 -0
  29. package/src/superlocalmemory/optimize/cache/boundary_store.py +455 -0
  30. package/src/superlocalmemory/optimize/cache/centroid_store.py +158 -0
  31. package/src/superlocalmemory/optimize/cache/context_key.py +67 -0
  32. package/src/superlocalmemory/optimize/cache/exact.py +85 -0
  33. package/src/superlocalmemory/optimize/cache/invalidation.py +36 -0
  34. package/src/superlocalmemory/optimize/cache/key_builder.py +98 -0
  35. package/src/superlocalmemory/optimize/cache/manager.py +452 -0
  36. package/src/superlocalmemory/optimize/cache/semantic.py +568 -0
  37. package/src/superlocalmemory/optimize/cache/stampede.py +50 -0
  38. package/src/superlocalmemory/optimize/compress/__init__.py +17 -0
  39. package/src/superlocalmemory/optimize/compress/align.py +153 -0
  40. package/src/superlocalmemory/optimize/compress/ccr.py +157 -0
  41. package/src/superlocalmemory/optimize/compress/extractive_code.py +311 -0
  42. package/src/superlocalmemory/optimize/compress/extractive_json.py +72 -0
  43. package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +77 -0
  44. package/src/superlocalmemory/optimize/compress/router.py +548 -0
  45. package/src/superlocalmemory/optimize/config/__init__.py +35 -0
  46. package/src/superlocalmemory/optimize/config/defaults.py +48 -0
  47. package/src/superlocalmemory/optimize/config/schema.py +255 -0
  48. package/src/superlocalmemory/optimize/config/store.py +209 -0
  49. package/src/superlocalmemory/optimize/metrics/__init__.py +8 -0
  50. package/src/superlocalmemory/optimize/metrics/counters.py +138 -0
  51. package/src/superlocalmemory/optimize/metrics/estimator.py +90 -0
  52. package/src/superlocalmemory/optimize/metrics/exporters.py +77 -0
  53. package/src/superlocalmemory/optimize/metrics/persistence.py +115 -0
  54. package/src/superlocalmemory/optimize/proxy/__init__.py +28 -0
  55. package/src/superlocalmemory/optimize/proxy/_helpers.py +257 -0
  56. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +171 -0
  57. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +121 -0
  58. package/src/superlocalmemory/optimize/proxy/lifecycle.py +126 -0
  59. package/src/superlocalmemory/optimize/proxy/openai_surface.py +125 -0
  60. package/src/superlocalmemory/optimize/proxy/server.py +151 -0
  61. package/src/superlocalmemory/optimize/storage/__init__.py +0 -0
  62. package/src/superlocalmemory/optimize/storage/db.py +1016 -0
  63. package/src/superlocalmemory/optimize/storage/schema.py +184 -0
  64. package/src/superlocalmemory/server/routes/optimize.py +167 -0
  65. package/src/superlocalmemory/server/routes/v3_api.py +63 -1
  66. package/src/superlocalmemory/server/unified_daemon.py +105 -0
  67. package/src/superlocalmemory/ui/index.html +98 -0
  68. package/src/superlocalmemory/ui/js/ng-shell.js +5 -1
  69. package/src/superlocalmemory/ui/js/optimize.js +173 -0
  70. package/src/superlocalmemory.egg-info/PKG-INFO +144 -36
  71. package/src/superlocalmemory.egg-info/SOURCES.txt +51 -0
  72. 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")