superlocalmemory 3.6.13 → 3.6.15

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 (147) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +189 -740
  4. package/package.json +12 -5
  5. package/plugin/.claude-plugin/plugin.json +20 -0
  6. package/plugin/.mcp.json +12 -0
  7. package/plugin/CLAUDE.md +44 -0
  8. package/plugin/_GENERATED.md +6 -0
  9. package/plugin/agents/slm-memory-advisor.md +44 -0
  10. package/plugin/agents/slm-optimize-advisor.md +38 -0
  11. package/plugin/hooks/hooks.json +14 -0
  12. package/plugin/requirements.txt +1 -0
  13. package/plugin/scripts/ensure-venv.bat +122 -0
  14. package/plugin/scripts/ensure-venv.sh +105 -0
  15. package/plugin/scripts/slm-launch +15 -0
  16. package/plugin/scripts/slm-launch.bat +17 -0
  17. package/plugin/settings.json +16 -0
  18. package/plugin/skills/slm-cache/SKILL.md +140 -0
  19. package/plugin/skills/slm-compress/SKILL.md +143 -0
  20. package/plugin/skills/slm-graph/SKILL.md +300 -0
  21. package/plugin/skills/slm-recall/SKILL.md +204 -0
  22. package/plugin/skills/slm-remember/SKILL.md +194 -0
  23. package/plugin/skills/slm-session/SKILL.md +207 -0
  24. package/plugin/skills/slm-status/SKILL.md +149 -0
  25. package/plugin-src/.mcp.json +12 -0
  26. package/plugin-src/agents/slm-memory-advisor.md +44 -0
  27. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  28. package/plugin-src/commands/slm-optimize.md +22 -0
  29. package/plugin-src/commands/slm-recall.md +16 -0
  30. package/plugin-src/commands/slm-remember.md +16 -0
  31. package/plugin-src/commands/slm-status.md +15 -0
  32. package/plugin-src/hooks/.gitkeep +0 -0
  33. package/plugin-src/hooks/hooks.json +14 -0
  34. package/plugin-src/manifest.json +25 -0
  35. package/plugin-src/requirements.txt +1 -0
  36. package/plugin-src/rules/AGENTS.md +91 -0
  37. package/plugin-src/rules/CLAUDE.md.fragment +44 -0
  38. package/plugin-src/scripts/ensure-venv.bat +122 -0
  39. package/plugin-src/scripts/ensure-venv.sh +105 -0
  40. package/plugin-src/scripts/slm-launch +15 -0
  41. package/plugin-src/scripts/slm-launch.bat +17 -0
  42. package/plugin-src/settings.json +16 -0
  43. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  44. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  45. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  46. package/plugin-src/skills/slm-recall/SKILL.md +204 -0
  47. package/plugin-src/skills/slm-remember/SKILL.md +194 -0
  48. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  49. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  50. package/pyproject.toml +6 -2
  51. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  52. package/scripts/_savings_math.py +270 -0
  53. package/scripts/build-plugin.js +742 -0
  54. package/scripts/dogfood_savings.py +490 -0
  55. package/scripts/install-skills.ps1 +4 -334
  56. package/scripts/install-skills.sh +4 -435
  57. package/scripts/postinstall-interactive.js +0 -27
  58. package/scripts/postinstall.js +21 -2
  59. package/src/superlocalmemory/__init__.py +1 -1
  60. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  61. package/src/superlocalmemory/cli/commands.py +439 -41
  62. package/src/superlocalmemory/cli/main.py +92 -4
  63. package/src/superlocalmemory/cli/setup_wizard.py +47 -6
  64. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  65. package/src/superlocalmemory/core/config.py +194 -9
  66. package/src/superlocalmemory/core/embeddings.py +10 -5
  67. package/src/superlocalmemory/core/engine.py +76 -5
  68. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  69. package/src/superlocalmemory/core/platform_utils.py +8 -0
  70. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  71. package/src/superlocalmemory/core/recall_worker.py +7 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  73. package/src/superlocalmemory/core/worker_pool.py +14 -2
  74. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  75. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  76. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  77. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  78. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  79. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  80. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  81. package/src/superlocalmemory/mcp/server.py +75 -4
  82. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  83. package/src/superlocalmemory/mcp/tools_core.py +37 -4
  84. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  85. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  86. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  87. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  88. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  89. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  90. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  91. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  92. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  93. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  94. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  95. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  96. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  97. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  98. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  99. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  100. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  101. package/src/superlocalmemory/retrieval/engine.py +36 -3
  102. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  103. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  105. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  106. package/src/superlocalmemory/server/unified_daemon.py +156 -16
  107. package/src/superlocalmemory/storage/database.py +215 -43
  108. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  109. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  110. package/src/superlocalmemory/storage/models.py +10 -0
  111. package/src/superlocalmemory/storage/schema.py +15 -10
  112. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  113. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  114. package/src/superlocalmemory/ui/index.html +2 -2
  115. package/src/superlocalmemory/ui/js/core.js +98 -0
  116. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  117. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  118. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  119. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  120. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  121. package/src/superlocalmemory.egg-info/PKG-INFO +191 -741
  122. package/src/superlocalmemory.egg-info/SOURCES.txt +7 -9
  123. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  124. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  125. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  126. package/ide/skills/slm-recall/SKILL.md +0 -326
  127. package/ide/skills/slm-remember/SKILL.md +0 -194
  128. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  129. package/ide/skills/slm-status/SKILL.md +0 -363
  130. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  131. package/skills/slm-build-graph/SKILL.md +0 -423
  132. package/skills/slm-list-recent/SKILL.md +0 -348
  133. package/skills/slm-optimize/README.md +0 -55
  134. package/skills/slm-optimize/SKILL.md +0 -139
  135. package/skills/slm-recall/SKILL.md +0 -343
  136. package/skills/slm-remember/SKILL.md +0 -194
  137. package/skills/slm-show-patterns/SKILL.md +0 -224
  138. package/skills/slm-status/SKILL.md +0 -363
  139. package/skills/slm-switch-profile/SKILL.md +0 -442
  140. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  141. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  142. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  143. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  144. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  145. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  146. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  147. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +0 -442
@@ -87,6 +87,8 @@ class OptimizeConfig:
87
87
  semantic_verifier_model: str = ""
88
88
  semantic_pad_latency_ms: float = 0.0
89
89
  semantic_centroid_min_similarity: float = 0.85
90
+ semantic_max_index_entries: int = 10000
91
+ semantic_max_tenants: int = 10000
90
92
 
91
93
  # Compress
92
94
  compress_enabled: bool = False
@@ -177,6 +179,8 @@ class OptimizeConfig:
177
179
  semantic_centroid_min_similarity=float(
178
180
  d.get("semantic_centroid_min_similarity", 0.85)
179
181
  ),
182
+ semantic_max_index_entries=int(d.get("semantic_max_index_entries", 10000)),
183
+ semantic_max_tenants=int(d.get("semantic_max_tenants", 10000)),
180
184
  compress_enabled=bool(d.get("compress_enabled", False)),
181
185
  compress_mode=str(d.get("compress_mode", "safe")),
182
186
  compress_prose=bool(d.get("compress_prose", False)),
@@ -216,6 +220,8 @@ class OptimizeConfig:
216
220
  "semantic_verifier_model": self.semantic_verifier_model,
217
221
  "semantic_pad_latency_ms": self.semantic_pad_latency_ms,
218
222
  "semantic_centroid_min_similarity": self.semantic_centroid_min_similarity,
223
+ "semantic_max_index_entries": self.semantic_max_index_entries,
224
+ "semantic_max_tenants": self.semantic_max_tenants,
219
225
  "compress_enabled": self.compress_enabled,
220
226
  "compress_mode": self.compress_mode,
221
227
  "compress_prose": self.compress_prose,
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import inspect
6
7
  import json
7
8
  import logging
8
9
  from typing import Any, AsyncIterator, Callable
@@ -22,6 +23,39 @@ _get_running_loop = asyncio.get_running_loop
22
23
 
23
24
  logger = logging.getLogger("slm.optimize.proxy.helpers")
24
25
 
26
+ # Per-callable cache of "does this hook method accept a tenant_id kwarg?".
27
+ # Keyed by id() of the bound method's __func__ so it is stable per hook class.
28
+ _HOOK_TENANT_SUPPORT: dict[int, bool] = {}
29
+
30
+
31
+ def _accepts_tenant_id(fn: Callable) -> bool:
32
+ """Return True if a hook method accepts a ``tenant_id`` keyword argument.
33
+
34
+ SECURITY (Stage-9 R1): the original WP-D shim used ``except TypeError`` to
35
+ detect legacy hooks, but that clause is structurally unable to tell a
36
+ signature mismatch from a TypeError raised *inside* a tenant-aware hook —
37
+ so an internal bug silently downgraded an authenticated request onto the
38
+ shared (tenant-less) path, re-opening cross-tenant disclosure. We instead
39
+ probe the signature ONCE (cached): a hook is treated as tenant-aware if it
40
+ has an explicit ``tenant_id`` parameter or accepts ``**kwargs``. A
41
+ tenant-aware hook is NEVER retried without the tenant_id; any error it
42
+ raises fails open to a cache MISS, never the shared namespace.
43
+ """
44
+ target = getattr(fn, "__func__", fn)
45
+ key = id(target)
46
+ cached = _HOOK_TENANT_SUPPORT.get(key)
47
+ if cached is None:
48
+ try:
49
+ params = inspect.signature(fn).parameters
50
+ cached = "tenant_id" in params or any(
51
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
52
+ )
53
+ except (ValueError, TypeError):
54
+ # Builtins / C callables without a signature — assume legacy.
55
+ cached = False
56
+ _HOOK_TENANT_SUPPORT[key] = cached
57
+ return cached
58
+
25
59
  # SEC-M-02 (CWE-400): reject oversized bodies to prevent compression-bomb DoS.
26
60
  _MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024 # 10 MB
27
61
 
@@ -76,6 +110,16 @@ _OPENAI_FORWARD_HEADERS = frozenset([
76
110
  _GEMINI_NATIVE_FORWARD_HEADERS = frozenset([
77
111
  "x-goog-api-key",
78
112
  "content-type",
113
+ "authorization", # WP-11a: Antigravity ADC/OAuth bearer was dropped; add it back
114
+ ])
115
+
116
+ # WP-11: Vertex AI forward headers — Authorization passed untouched (AC-2).
117
+ # x-goog-user-project required for quota attribution on Vertex calls.
118
+ _VERTEX_FORWARD_HEADERS = frozenset([
119
+ "authorization",
120
+ "content-type",
121
+ "x-goog-api-key",
122
+ "x-goog-user-project",
79
123
  ])
80
124
 
81
125
  _GEMINI_OPENAI_COMPAT_FORWARD_HEADERS = frozenset([
@@ -98,6 +142,28 @@ def _redact_headers(headers: dict) -> dict:
98
142
  }
99
143
 
100
144
 
145
+ def _derive_tenant_id(provider: str, raw_credential: "str | None") -> "str | None":
146
+ """Derive a per-tenant isolation key from the raw (un-redacted) credential.
147
+
148
+ SECURITY (WP-D): Called at the surface-handler layer BEFORE _redact_headers
149
+ strips the credential from ProxyRequest.headers. The derived id is threaded
150
+ into CacheManager.check() / .store() so that two users sharing the same
151
+ prompt but using different API keys receive independent cache namespaces.
152
+
153
+ Returns None when no credential is present — callers must SKIP caching
154
+ (never collapse to the default tenant) to prevent cross-tenant disclosure.
155
+
156
+ Output: 64-char lowercase hex SHA-256 of ``f"{provider}:{raw_credential}"``.
157
+ Provider is folded in so anthropic:K and openai:K are distinct tenants even
158
+ if the literal key string coincidentally matches.
159
+ """
160
+ import hashlib as _hashlib
161
+
162
+ if not raw_credential:
163
+ return None
164
+ return _hashlib.sha256(f"{provider}:{raw_credential}".encode()).hexdigest()
165
+
166
+
101
167
  def _body_has_tools(body: dict) -> bool:
102
168
  tools = body.get("tools")
103
169
  return isinstance(tools, list) and len(tools) > 0
@@ -545,25 +611,62 @@ async def capture_passthrough_forward(
545
611
  )
546
612
 
547
613
 
548
- async def _safe_cache_check(hooks: HookChain, ctx: ProxyRequest) -> CachedResponse:
614
+ async def _safe_cache_check(
615
+ hooks: HookChain,
616
+ ctx: ProxyRequest,
617
+ tenant_id: "str | None" = None,
618
+ ) -> CachedResponse:
619
+ """Invoke cache.check(); fail-open on error.
620
+
621
+ SECURITY (WP-D): tenant_id is forwarded to the CacheHook so that the
622
+ credential-derived namespace is used. None signals "skip caching" — the
623
+ cache hook must not collapse to a default tenant for unauthenticated
624
+ requests. When tenant_id is None, return an empty miss immediately.
625
+
626
+ Backward compat: a genuinely legacy hook (no tenant_id parameter and no
627
+ **kwargs) is called without the kwarg. A tenant-aware hook is NEVER
628
+ downgraded to the tenant-less path — see _accepts_tenant_id (Stage-9 R1).
629
+ """
630
+ miss = CachedResponse(hit=False, data=None, cache_key="", ttl_seconds=0)
631
+ if tenant_id is None:
632
+ return miss
549
633
  try:
550
- result = hooks.cache.check(ctx)
551
- return result if result is not None else CachedResponse(
552
- hit=False, data=None, cache_key="", ttl_seconds=0
553
- )
634
+ if _accepts_tenant_id(hooks.cache.check):
635
+ result = hooks.cache.check(ctx, tenant_id=tenant_id)
636
+ else:
637
+ # Genuine legacy hook — no tenant_id support at all.
638
+ result = hooks.cache.check(ctx)
639
+ return result if result is not None else miss
554
640
  except Exception as exc:
555
- logger.warning("cache.check failed (fail-open): %s", exc)
556
- return CachedResponse(hit=False, data=None, cache_key="", ttl_seconds=0)
641
+ # SECURITY: a tenant-aware hook that errors must fail-open to a MISS,
642
+ # never be retried on the shared (tenant-less) namespace.
643
+ logger.warning("cache.check failed (fail-open miss): %s", exc)
644
+ return miss
557
645
 
558
646
 
559
647
  async def _safe_cache_store(
560
648
  hooks: HookChain,
561
649
  ctx: ProxyRequest,
562
650
  resp: ProviderResponse,
651
+ tenant_id: "str | None" = None,
563
652
  ) -> None:
653
+ """Invoke cache.store(); fail-open on error.
654
+
655
+ SECURITY (WP-D): tenant_id is forwarded. None means skip (unauthenticated).
656
+
657
+ Backward compat: a genuinely legacy hook (no tenant_id parameter and no
658
+ **kwargs) is called without the kwarg. A tenant-aware hook is NEVER
659
+ downgraded to the tenant-less path — see _accepts_tenant_id (Stage-9 R1).
660
+ """
661
+ if tenant_id is None:
662
+ return
564
663
  try:
565
- hooks.cache.store(ctx, resp)
664
+ if _accepts_tenant_id(hooks.cache.store):
665
+ hooks.cache.store(ctx, resp, tenant_id=tenant_id)
666
+ else:
667
+ hooks.cache.store(ctx, resp)
566
668
  except Exception as exc:
669
+ # SECURITY: never retry a tenant-aware hook without tenant_id.
567
670
  logger.warning("cache.store failed (fail-open): %s", exc)
568
671
 
569
672
 
@@ -15,6 +15,7 @@ from superlocalmemory.optimize.proxy._helpers import (
15
15
  _MAX_REQUEST_BODY_BYTES,
16
16
  _body_has_tools,
17
17
  _build_forward_headers,
18
+ _derive_tenant_id,
18
19
  _fail_open_forward,
19
20
  _filter_response_headers,
20
21
  _parse_sse_to_json,
@@ -204,6 +205,14 @@ async def handle_messages(proxy: object, request: Request) -> Response:
204
205
  sse_parser=_parse_sse_to_json, is_stream=stream,
205
206
  )
206
207
 
208
+ # SECURITY (WP-D): derive tenant BEFORE _redact_headers strips the key.
209
+ # x-api-key is the primary Anthropic credential; fall back to authorization.
210
+ _raw_key = (
211
+ request.headers.get("x-api-key")
212
+ or request.headers.get("authorization")
213
+ )
214
+ _tenant_id = _derive_tenant_id("anthropic", _raw_key)
215
+
207
216
  ctx = ProxyRequest(
208
217
  provider="anthropic",
209
218
  method="POST",
@@ -225,7 +234,7 @@ async def handle_messages(proxy: object, request: Request) -> Response:
225
234
  # accumulated and stored, return it as a proper SSE stream rather
226
235
  # than forwarding to Anthropic at all.
227
236
  if proxy.hooks.cache:
228
- cache_result = await _safe_cache_check(proxy.hooks, ctx)
237
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
229
238
  if cache_result and cache_result.hit and cache_result.data:
230
239
  logger.debug(
231
240
  "[%s] streaming cache HIT key=%s",
@@ -261,6 +270,7 @@ async def handle_messages(proxy: object, request: Request) -> Response:
261
270
  if proxy.hooks.cache:
262
271
  _hooks = proxy.hooks
263
272
  _ctx = ctx
273
+ _tid = _tenant_id
264
274
  async def _store_from_sse(sse_bytes: bytes) -> None:
265
275
  parsed = _parse_sse_to_json(sse_bytes)
266
276
  if parsed is None:
@@ -269,7 +279,7 @@ async def handle_messages(proxy: object, request: Request) -> Response:
269
279
  modified=False, body={}, body_bytes=parsed,
270
280
  tokens_before=0, tokens_after=0, strategy="none",
271
281
  )
272
- await _safe_cache_store(_hooks, _ctx, prov)
282
+ await _safe_cache_store(_hooks, _ctx, prov, tenant_id=_tid)
273
283
  store_callback = _store_from_sse
274
284
 
275
285
  return await _stream_and_cache_forward(
@@ -279,7 +289,7 @@ async def handle_messages(proxy: object, request: Request) -> Response:
279
289
 
280
290
  cache_result = None
281
291
  if proxy.hooks.cache:
282
- cache_result = await _safe_cache_check(proxy.hooks, ctx)
292
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
283
293
  if cache_result.hit and cache_result.data:
284
294
  logger.debug("[%s] cache HIT key=%s", request_id, cache_result.cache_key)
285
295
  await _safe_cache_hit_callbacks(
@@ -315,7 +325,7 @@ async def handle_messages(proxy: object, request: Request) -> Response:
315
325
  modified=False, body={}, body_bytes=resp_bytes,
316
326
  tokens_before=0, tokens_after=0, strategy="none",
317
327
  )
318
- await _safe_cache_store(proxy.hooks, ctx, _prov_resp)
328
+ await _safe_cache_store(proxy.hooks, ctx, _prov_resp, tenant_id=_tenant_id)
319
329
 
320
330
  return Response(
321
331
  content=resp_bytes,
@@ -38,6 +38,7 @@ from superlocalmemory.optimize.proxy._helpers import (
38
38
  _GEMINI_NATIVE_FORWARD_HEADERS,
39
39
  _GEMINI_OPENAI_COMPAT_FORWARD_HEADERS,
40
40
  _body_has_tools,
41
+ _derive_tenant_id,
41
42
  _fail_open_forward,
42
43
  _filter_response_headers,
43
44
  _redact_headers,
@@ -259,6 +260,14 @@ async def handle_gemini_native(
259
260
  sse_parser=_parse_gemini_sse_to_json, is_stream=stream,
260
261
  )
261
262
 
263
+ # SECURITY (WP-D): derive tenant BEFORE _redact_headers strips the key.
264
+ # Gemini uses x-goog-api-key; OAuth uses authorization bearer.
265
+ _raw_key = (
266
+ request.headers.get("x-goog-api-key")
267
+ or request.headers.get("authorization")
268
+ )
269
+ _tenant_id = _derive_tenant_id("gemini", _raw_key)
270
+
262
271
  ctx = ProxyRequest(
263
272
  provider="gemini",
264
273
  method="POST",
@@ -279,7 +288,7 @@ async def handle_gemini_native(
279
288
  if stream:
280
289
  # ── 1. Cache check ──────────────────────────────────────────────
281
290
  if proxy.hooks.cache:
282
- cache_result = await _safe_cache_check(proxy.hooks, ctx)
291
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
283
292
  if cache_result and cache_result.hit and cache_result.data:
284
293
  logger.debug(
285
294
  "[%s] Gemini native streaming cache HIT key=%s",
@@ -314,6 +323,7 @@ async def handle_gemini_native(
314
323
  if proxy.hooks.cache:
315
324
  _hooks = proxy.hooks
316
325
  _ctx = ctx
326
+ _tid = _tenant_id
317
327
 
318
328
  async def _store_from_gemini_sse(sse_bytes: bytes) -> None:
319
329
  parsed = _parse_gemini_sse_to_json(sse_bytes)
@@ -323,7 +333,7 @@ async def handle_gemini_native(
323
333
  modified=False, body={}, body_bytes=parsed,
324
334
  tokens_before=0, tokens_after=0, strategy="none",
325
335
  )
326
- await _safe_cache_store(_hooks, _ctx, prov)
336
+ await _safe_cache_store(_hooks, _ctx, prov, tenant_id=_tid)
327
337
 
328
338
  store_callback = _store_from_gemini_sse
329
339
 
@@ -335,7 +345,7 @@ async def handle_gemini_native(
335
345
  # ── Non-streaming path ──────────────────────────────────────────────
336
346
  cache_result = None
337
347
  if proxy.hooks.cache:
338
- cache_result = await _safe_cache_check(proxy.hooks, ctx)
348
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
339
349
  if cache_result.hit and cache_result.data:
340
350
  await _safe_cache_hit_callbacks(
341
351
  proxy.hooks, ctx, cache_result.data, tokens_saved=0
@@ -369,7 +379,7 @@ async def handle_gemini_native(
369
379
  modified=False, body={}, body_bytes=resp_bytes,
370
380
  tokens_before=0, tokens_after=0, strategy="none",
371
381
  )
372
- await _safe_cache_store(proxy.hooks, ctx, prov_resp)
382
+ await _safe_cache_store(proxy.hooks, ctx, prov_resp, tenant_id=_tenant_id)
373
383
 
374
384
  return Response(
375
385
  content=resp_bytes,
@@ -438,6 +448,13 @@ async def handle_gemini_openai_compat(proxy: object, request: Request) -> Respon
438
448
  sse_parser=_parse_openai_sse_to_json, is_stream=stream,
439
449
  )
440
450
 
451
+ # SECURITY (WP-D): derive tenant BEFORE _redact_headers strips the key.
452
+ _raw_key_compat = (
453
+ request.headers.get("x-goog-api-key")
454
+ or request.headers.get("authorization")
455
+ )
456
+ _tenant_id_compat = _derive_tenant_id("gemini-openai-compat", _raw_key_compat)
457
+
441
458
  ctx = ProxyRequest(
442
459
  provider="gemini-openai-compat",
443
460
  method="POST",
@@ -453,7 +470,7 @@ async def handle_gemini_openai_compat(proxy: object, request: Request) -> Respon
453
470
  # Cache check
454
471
  cache_result = None
455
472
  if proxy.hooks.cache:
456
- cache_result = await _safe_cache_check(proxy.hooks, ctx)
473
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id_compat)
457
474
  if cache_result.hit and cache_result.data:
458
475
  await _safe_cache_hit_callbacks(
459
476
  proxy.hooks, ctx, cache_result.data, tokens_saved=0
@@ -495,7 +512,7 @@ async def handle_gemini_openai_compat(proxy: object, request: Request) -> Respon
495
512
  modified=False, body={}, body_bytes=resp_bytes,
496
513
  tokens_before=0, tokens_after=0, strategy="none",
497
514
  )
498
- await _safe_cache_store(proxy.hooks, ctx, prov_resp)
515
+ await _safe_cache_store(proxy.hooks, ctx, prov_resp, tenant_id=_tenant_id_compat)
499
516
 
500
517
  return Response(
501
518
  content=resp_bytes,
@@ -24,6 +24,7 @@ from superlocalmemory.optimize.proxy._helpers import (
24
24
  _OPENAI_FORWARD_HEADERS,
25
25
  _body_has_tools,
26
26
  _build_forward_headers,
27
+ _derive_tenant_id,
27
28
  _fail_open_forward,
28
29
  _filter_response_headers,
29
30
  _redact_headers,
@@ -328,6 +329,10 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
328
329
  sse_parser=_parse_openai_sse_to_json, is_stream=stream,
329
330
  )
330
331
 
332
+ # SECURITY (WP-D): derive tenant BEFORE _redact_headers strips the key.
333
+ _raw_key = request.headers.get("authorization")
334
+ _tenant_id = _derive_tenant_id("openai", _raw_key)
335
+
331
336
  ctx = ProxyRequest(
332
337
  provider="openai", method="POST", path="/v1/chat/completions",
333
338
  headers=_redact_headers(dict(request.headers)),
@@ -343,7 +348,7 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
343
348
 
344
349
  # 1. Cache check
345
350
  if proxy.hooks.cache:
346
- cache_result = await _safe_cache_check(proxy.hooks, ctx)
351
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
347
352
  if cache_result and cache_result.hit and cache_result.data:
348
353
  logger.debug(
349
354
  "[%s] OpenAI streaming cache HIT key=%s",
@@ -371,6 +376,7 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
371
376
  if proxy.hooks.cache:
372
377
  _hooks = proxy.hooks
373
378
  _ctx = ctx
379
+ _tid = _tenant_id
374
380
  async def _store_from_openai_sse(sse_bytes: bytes) -> None:
375
381
  parsed = _parse_openai_sse_to_json(sse_bytes)
376
382
  if parsed is None:
@@ -379,7 +385,7 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
379
385
  modified=False, body={}, body_bytes=parsed,
380
386
  tokens_before=0, tokens_after=0, strategy="none",
381
387
  )
382
- await _safe_cache_store(_hooks, _ctx, prov)
388
+ await _safe_cache_store(_hooks, _ctx, prov, tenant_id=_tid)
383
389
  store_callback = _store_from_openai_sse
384
390
 
385
391
  return await _stream_and_cache_forward(
@@ -390,7 +396,7 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
390
396
  # --- Non-streaming path ---
391
397
  cache_result = None
392
398
  if proxy.hooks.cache:
393
- cache_result = await _safe_cache_check(proxy.hooks, ctx)
399
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
394
400
  if cache_result.hit and cache_result.data:
395
401
  await _safe_cache_hit_callbacks(
396
402
  proxy.hooks, ctx, cache_result.data, 0
@@ -425,7 +431,7 @@ async def handle_chat_completions(proxy: object, request: Request) -> Response:
425
431
  modified=False, body={}, body_bytes=resp_bytes,
426
432
  tokens_before=0, tokens_after=0, strategy="none",
427
433
  )
428
- await _safe_cache_store(proxy.hooks, ctx, _prov_resp)
434
+ await _safe_cache_store(proxy.hooks, ctx, _prov_resp, tenant_id=_tenant_id)
429
435
 
430
436
  return Response(
431
437
  content=resp_bytes,
@@ -109,6 +109,9 @@ def build_proxy_router(proxy: ProxyApp) -> APIRouter:
109
109
  handle_chat_completions,
110
110
  handle_embeddings,
111
111
  )
112
+ from superlocalmemory.optimize.proxy.vertex_surface import (
113
+ handle_vertex_generative,
114
+ )
112
115
 
113
116
  router = APIRouter(tags=["slm-optimize-proxy"])
114
117
 
@@ -146,6 +149,14 @@ def build_proxy_router(proxy: ProxyApp) -> APIRouter:
146
149
  async def gemini_openai_models_route(request: Request) -> Response:
147
150
  return await handle_gemini_openai_compat(proxy, request)
148
151
 
152
+ # WP-11: Vertex AI passthrough — must be registered AFTER exact /v1/* routes
153
+ # to avoid shadowing /v1/messages, /v1/chat/completions, /v1/embeddings, etc.
154
+ # FastAPI resolves routes in registration order; the exact routes above are
155
+ # declared before this catch-path, so there is no shadowing.
156
+ @router.post("/v1/projects/{vertex_path:path}")
157
+ async def vertex_route(request: Request, vertex_path: str) -> Response:
158
+ return await handle_vertex_generative(proxy, request, vertex_path)
159
+
149
160
  return router
150
161
 
151
162