superlocalmemory 3.6.13 → 3.6.14

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 (124) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.md +187 -741
  3. package/package.json +12 -5
  4. package/plugin/.claude-plugin/plugin.json +20 -0
  5. package/plugin/.mcp.json +12 -0
  6. package/plugin/CLAUDE.md +43 -0
  7. package/plugin/_GENERATED.md +6 -0
  8. package/plugin/agents/slm-memory-advisor.md +43 -0
  9. package/plugin/agents/slm-optimize-advisor.md +38 -0
  10. package/plugin/hooks/hooks.json +14 -0
  11. package/plugin/requirements.txt +1 -0
  12. package/plugin/scripts/ensure-venv.bat +122 -0
  13. package/plugin/scripts/ensure-venv.sh +105 -0
  14. package/plugin/scripts/slm-launch +15 -0
  15. package/plugin/scripts/slm-launch.bat +17 -0
  16. package/plugin/settings.json +16 -0
  17. package/plugin/skills/slm-cache/SKILL.md +140 -0
  18. package/plugin/skills/slm-compress/SKILL.md +143 -0
  19. package/plugin/skills/slm-graph/SKILL.md +300 -0
  20. package/plugin/skills/slm-recall/SKILL.md +196 -0
  21. package/plugin/skills/slm-remember/SKILL.md +182 -0
  22. package/plugin/skills/slm-session/SKILL.md +207 -0
  23. package/plugin/skills/slm-status/SKILL.md +149 -0
  24. package/plugin-src/.mcp.json +12 -0
  25. package/plugin-src/agents/slm-memory-advisor.md +43 -0
  26. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  27. package/plugin-src/commands/slm-optimize.md +22 -0
  28. package/plugin-src/commands/slm-recall.md +16 -0
  29. package/plugin-src/commands/slm-remember.md +16 -0
  30. package/plugin-src/commands/slm-status.md +15 -0
  31. package/plugin-src/hooks/.gitkeep +0 -0
  32. package/plugin-src/hooks/hooks.json +14 -0
  33. package/plugin-src/manifest.json +25 -0
  34. package/plugin-src/requirements.txt +1 -0
  35. package/plugin-src/rules/AGENTS.md +90 -0
  36. package/plugin-src/rules/CLAUDE.md.fragment +43 -0
  37. package/plugin-src/scripts/ensure-venv.bat +122 -0
  38. package/plugin-src/scripts/ensure-venv.sh +105 -0
  39. package/plugin-src/scripts/slm-launch +15 -0
  40. package/plugin-src/scripts/slm-launch.bat +17 -0
  41. package/plugin-src/settings.json +16 -0
  42. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  43. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  45. package/plugin-src/skills/slm-recall/SKILL.md +196 -0
  46. package/plugin-src/skills/slm-remember/SKILL.md +182 -0
  47. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  48. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  49. package/pyproject.toml +6 -2
  50. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  51. package/scripts/_savings_math.py +270 -0
  52. package/scripts/build-plugin.js +742 -0
  53. package/scripts/dogfood_savings.py +490 -0
  54. package/scripts/install-skills.ps1 +4 -334
  55. package/scripts/install-skills.sh +4 -435
  56. package/scripts/postinstall-interactive.js +0 -27
  57. package/scripts/postinstall.js +21 -2
  58. package/src/superlocalmemory/__init__.py +1 -1
  59. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  60. package/src/superlocalmemory/cli/commands.py +348 -39
  61. package/src/superlocalmemory/cli/main.py +47 -4
  62. package/src/superlocalmemory/cli/setup_wizard.py +20 -6
  63. package/src/superlocalmemory/core/config.py +79 -9
  64. package/src/superlocalmemory/core/embeddings.py +10 -5
  65. package/src/superlocalmemory/core/engine.py +2 -2
  66. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  67. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  68. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  69. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  70. package/src/superlocalmemory/mcp/server.py +75 -4
  71. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  72. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  73. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  74. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  75. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  76. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  77. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  78. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  79. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  80. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  81. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  82. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  83. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  84. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  85. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  86. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  87. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  88. package/src/superlocalmemory/server/unified_daemon.py +24 -6
  89. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  90. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  91. package/src/superlocalmemory/ui/index.html +2 -2
  92. package/src/superlocalmemory/ui/js/core.js +98 -0
  93. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  94. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  95. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  96. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  97. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  98. package/src/superlocalmemory.egg-info/PKG-INFO +189 -742
  99. package/src/superlocalmemory.egg-info/SOURCES.txt +6 -9
  100. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  101. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  102. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  103. package/ide/skills/slm-recall/SKILL.md +0 -326
  104. package/ide/skills/slm-remember/SKILL.md +0 -194
  105. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  106. package/ide/skills/slm-status/SKILL.md +0 -363
  107. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  108. package/skills/slm-build-graph/SKILL.md +0 -423
  109. package/skills/slm-list-recent/SKILL.md +0 -348
  110. package/skills/slm-optimize/README.md +0 -55
  111. package/skills/slm-optimize/SKILL.md +0 -139
  112. package/skills/slm-recall/SKILL.md +0 -343
  113. package/skills/slm-remember/SKILL.md +0 -194
  114. package/skills/slm-show-patterns/SKILL.md +0 -224
  115. package/skills/slm-status/SKILL.md +0 -363
  116. package/skills/slm-switch-profile/SKILL.md +0 -442
  117. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  118. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  119. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  120. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  121. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  122. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  123. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  124. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +0 -442
@@ -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
 
@@ -0,0 +1,246 @@
1
+ """vertex_surface.py — Vertex AI passthrough proxy surface (WP-11).
2
+
3
+ Transparent passthrough: forward Authorization bearer untouched (AC-2),
4
+ cache by body content never by token (SEC), no SSE — always single JSON.
5
+
6
+ Key design decisions (per LLD §5 STAGE-5 RESOLUTIONS):
7
+ CRIT-1: Route by FastAPI PATH /v1/projects/{vertex_path:path}, NOT hostname.
8
+ Upstream host is reconstructed from /locations/{region}/ in the path.
9
+ CRIT-2: Vertex bodies have no model/messages/system (model in PATH, prompts
10
+ under 'contents'). The provider=='vertex' branch in CacheManager.build_key
11
+ extracts these correctly — preventing all-requests-same-key collision.
12
+ D2: Pin upstream host to https://{location}-aiplatform.googleapis.com from path.
13
+ IGNORE providers.vertex.base_url — honoring it is an SSRF surface.
14
+ SECURITY (AC-3): bearer token structurally excluded from cache key, value,
15
+ logs, and stored ProxyRequest.headers (redacted via _redact_headers).
16
+
17
+ WP-11a (gemini-native fix) lives in _helpers.py:_GEMINI_NATIVE_FORWARD_HEADERS.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import re
25
+
26
+ from fastapi.requests import Request
27
+ from fastapi.responses import Response
28
+
29
+ from superlocalmemory.optimize.proxy._helpers import (
30
+ _VERTEX_FORWARD_HEADERS,
31
+ _derive_tenant_id,
32
+ _fail_open_forward,
33
+ _filter_response_headers,
34
+ _redact_headers,
35
+ _safe_cache_check,
36
+ _safe_cache_hit_callbacks,
37
+ _safe_cache_store,
38
+ capture_passthrough_forward,
39
+ )
40
+ from superlocalmemory.optimize.proxy.capture import capture_enabled
41
+ from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
42
+
43
+ logger = logging.getLogger("slm.optimize.proxy.vertex")
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # SSRF guard — LLD §5
47
+ # ---------------------------------------------------------------------------
48
+
49
+ # Accepts ONLY:
50
+ # {project}/locations/{location}/publishers/google/models/{model}:{method}
51
+ # Rejects:
52
+ # - ../ traversal (no dots allowed in segments via character classes)
53
+ # - unknown methods (countTokens, EXEC, etc.)
54
+ # - overlong segments
55
+ _VERTEX_PATH_RE = re.compile(
56
+ r"^(?P<project>[a-zA-Z0-9_\-]{1,63})"
57
+ r"/locations/(?P<location>[a-z0-9\-]{1,40})"
58
+ r"/publishers/google/models/(?P<model>[a-zA-Z0-9._\-]{1,128})"
59
+ r":(?P<method>generateContent|streamGenerateContent)$"
60
+ )
61
+
62
+
63
+ def _validate_vertex_path(path: str) -> bool:
64
+ """Return True iff path matches the Vertex AI path pattern (SSRF guard).
65
+
66
+ Rejects ../, traversal characters, unknown methods, and overlong segments.
67
+ """
68
+ return bool(_VERTEX_PATH_RE.match(path))
69
+
70
+
71
+ def _parse_vertex_path(
72
+ path: str,
73
+ ) -> tuple[str, str, str, str] | None:
74
+ """Parse a validated Vertex path into (project, location, model, method).
75
+
76
+ Returns None on parse failure (caller should return 400).
77
+ """
78
+ m = _VERTEX_PATH_RE.match(path)
79
+ if m is None:
80
+ return None
81
+ return (
82
+ m.group("project"),
83
+ m.group("location"),
84
+ m.group("model"),
85
+ m.group("method"),
86
+ )
87
+
88
+
89
+ def _build_vertex_upstream_url(location: str, full_path: str) -> str:
90
+ """Build the Vertex upstream URL.
91
+
92
+ D2 (LOCKED): pin host to https://{location}-aiplatform.googleapis.com
93
+ from the path — IGNORE providers.vertex.base_url (honoring it = SSRF).
94
+ """
95
+ host = f"https://{location}-aiplatform.googleapis.com"
96
+ return f"{host}{full_path}"
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Route handler
101
+ # ---------------------------------------------------------------------------
102
+
103
+ async def handle_vertex_generative(
104
+ proxy: object,
105
+ request: Request,
106
+ vertex_path: str,
107
+ ) -> Response:
108
+ """Handle POST /v1/projects/{vertex_path:path}.
109
+
110
+ Full pipeline: validate → parse → cache check → POST upstream → cache store.
111
+ Bearer token is NEVER in the cache key (structurally — build_key reads only body).
112
+ Always returns a single JSON Response — no SSE/StreamingResponse (LLD §1).
113
+ Fail-open on unexpected exceptions.
114
+ """
115
+ request_id = await proxy.next_request_id()
116
+
117
+ # ── SSRF guard — validate path BEFORE any upstream contact (AC-3 / LLD §4) ──
118
+ if not _validate_vertex_path(vertex_path):
119
+ logger.warning(
120
+ "[%s] handle_vertex_generative: rejected invalid path=%r (SSRF guard)",
121
+ request_id, vertex_path,
122
+ )
123
+ return Response(
124
+ content=b'{"error":{"code":400,"message":"Invalid Vertex path",'
125
+ b'"status":"INVALID_ARGUMENT"}}',
126
+ status_code=400,
127
+ media_type="application/json",
128
+ )
129
+
130
+ parsed = _parse_vertex_path(vertex_path)
131
+ if parsed is None:
132
+ # Should not reach here after _validate_vertex_path, but be defensive.
133
+ logger.error("[%s] handle_vertex_generative: parse failed after validation", request_id)
134
+ return Response(
135
+ content=b'{"error":{"code":400,"message":"Path parse error",'
136
+ b'"status":"INVALID_ARGUMENT"}}',
137
+ status_code=400,
138
+ media_type="application/json",
139
+ )
140
+
141
+ _project, location, _model, _method = parsed
142
+ upstream_url = _build_vertex_upstream_url(location, str(request.url.path))
143
+
144
+ try:
145
+ body_bytes = await request.body()
146
+
147
+ try:
148
+ body = json.loads(body_bytes) if body_bytes else {}
149
+ except json.JSONDecodeError:
150
+ body = {}
151
+
152
+ # v3.6.10 shadow-capture: pure passthrough + corpus record.
153
+ if capture_enabled():
154
+ return await capture_passthrough_forward(
155
+ proxy, request,
156
+ provider="vertex",
157
+ upstream_url=upstream_url,
158
+ allowed_headers=_VERTEX_FORWARD_HEADERS,
159
+ request_id=request_id,
160
+ model_hint=_model,
161
+ sse_parser=None,
162
+ is_stream=False,
163
+ )
164
+
165
+ # SECURITY (AC-3): headers stored in ProxyRequest are redacted.
166
+ # Bearer token is structurally excluded from the cache key because
167
+ # build_key reads only body-derived fields (key_builder.py:90-105).
168
+ #
169
+ # SECURITY (WP-D): derive tenant BEFORE _redact_headers strips the
170
+ # bearer token. Vertex uses Authorization bearer.
171
+ _raw_key = request.headers.get("authorization")
172
+ _tenant_id = _derive_tenant_id("vertex", _raw_key)
173
+
174
+ ctx = ProxyRequest(
175
+ provider="vertex",
176
+ method="POST",
177
+ path=str(request.url.path),
178
+ headers=_redact_headers(dict(request.headers)), # token → [REDACTED]
179
+ body=body,
180
+ body_bytes=body_bytes,
181
+ request_id=request_id,
182
+ stream=False,
183
+ has_tools=False,
184
+ )
185
+
186
+ # Build forward headers — Authorization byte-identical (AC-2).
187
+ fwd_headers = {
188
+ k: v for k, v in request.headers.items()
189
+ if k.lower() in _VERTEX_FORWARD_HEADERS
190
+ }
191
+
192
+ # ── Cache check ────────────────────────────────────────────────────
193
+ cache_result = None
194
+ if proxy.hooks.cache:
195
+ cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
196
+ if cache_result and cache_result.hit and cache_result.data:
197
+ logger.debug(
198
+ "[%s] Vertex cache HIT key=%s",
199
+ request_id, cache_result.cache_key,
200
+ )
201
+ await _safe_cache_hit_callbacks(
202
+ proxy.hooks, ctx, cache_result.data, tokens_saved=0
203
+ )
204
+ return Response(
205
+ content=cache_result.data,
206
+ status_code=200,
207
+ media_type="application/json",
208
+ )
209
+
210
+ fwd_headers["content-length"] = str(len(body_bytes))
211
+
212
+ # ── POST upstream (single JSON — no SSE) ──────────────────────────
213
+ upstream_resp = await proxy.http_client.post(
214
+ upstream_url, content=body_bytes, headers=fwd_headers,
215
+ )
216
+ resp_bytes = upstream_resp.content
217
+
218
+ # ── Cache store on 200 only ────────────────────────────────────────
219
+ if (
220
+ upstream_resp.status_code == 200
221
+ and proxy.hooks.cache
222
+ and cache_result is not None
223
+ and cache_result.cache_key
224
+ ):
225
+ prov_resp = ProviderResponse(
226
+ modified=False,
227
+ body={},
228
+ body_bytes=resp_bytes,
229
+ tokens_before=0,
230
+ tokens_after=0,
231
+ strategy="none",
232
+ )
233
+ await _safe_cache_store(proxy.hooks, ctx, prov_resp, tenant_id=_tenant_id)
234
+
235
+ return Response(
236
+ content=resp_bytes,
237
+ status_code=upstream_resp.status_code,
238
+ media_type="application/json",
239
+ headers=_filter_response_headers(dict(upstream_resp.headers)),
240
+ )
241
+
242
+ except Exception as exc:
243
+ logger.error(
244
+ "[%s] handle_vertex_generative exc=%r — fail-open", request_id, exc
245
+ )
246
+ return await _fail_open_forward(proxy, request, upstream_url)