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,184 @@
|
|
|
1
|
+
"""DDL for llmcache.db — the Optimize module's dedicated SQLite database.
|
|
2
|
+
|
|
3
|
+
ISOLATION GUARANTEE: This schema MUST NEVER reference memory.db tables.
|
|
4
|
+
No FKs to profiles, atomic_facts, memories, or any SLM core table.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
CACHE_SCHEMA_VERSION: Final[int] = 1
|
|
13
|
+
|
|
14
|
+
LLMCACHE_DB_FILENAME: Final[str] = "llmcache.db"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_DDL_STATEMENTS: tuple[str, ...] = (
|
|
18
|
+
"""
|
|
19
|
+
CREATE TABLE IF NOT EXISTS llmcache_schema_version (
|
|
20
|
+
version INTEGER NOT NULL,
|
|
21
|
+
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
22
|
+
description TEXT NOT NULL DEFAULT ''
|
|
23
|
+
)
|
|
24
|
+
""",
|
|
25
|
+
"""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS llmcache_entries (
|
|
27
|
+
entry_id TEXT PRIMARY KEY,
|
|
28
|
+
cache_key TEXT NOT NULL,
|
|
29
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
30
|
+
model TEXT NOT NULL DEFAULT '',
|
|
31
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
32
|
+
value_blob BLOB NOT NULL,
|
|
33
|
+
compressed INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
35
|
+
last_hit_at TEXT,
|
|
36
|
+
ttl_expires REAL,
|
|
37
|
+
hit_count INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
byte_size INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
tag_json TEXT NOT NULL DEFAULT '[]',
|
|
40
|
+
cache_tier TEXT NOT NULL DEFAULT 'exact'
|
|
41
|
+
CHECK (cache_tier IN ('exact', 'semantic'))
|
|
42
|
+
)
|
|
43
|
+
""",
|
|
44
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_llmcache_key_tenant ON llmcache_entries (cache_key, tenant_id)",
|
|
45
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_ttl ON llmcache_entries (ttl_expires) WHERE ttl_expires IS NOT NULL",
|
|
46
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_tenant ON llmcache_entries (tenant_id)",
|
|
47
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_provider ON llmcache_entries (tenant_id, provider)",
|
|
48
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_lru ON llmcache_entries (last_hit_at)",
|
|
49
|
+
"""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS llmcache_semantic_vectors (
|
|
51
|
+
entry_id TEXT PRIMARY KEY,
|
|
52
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
53
|
+
vector_blob BLOB NOT NULL,
|
|
54
|
+
vector_dim INTEGER NOT NULL DEFAULT 768,
|
|
55
|
+
model_name TEXT NOT NULL DEFAULT 'nomic-ai/nomic-embed-text-v1.5',
|
|
56
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
57
|
+
)
|
|
58
|
+
""",
|
|
59
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_vec_tenant ON llmcache_semantic_vectors (tenant_id)",
|
|
60
|
+
"""
|
|
61
|
+
CREATE TABLE IF NOT EXISTS llmcache_ccr_originals (
|
|
62
|
+
ccr_id TEXT PRIMARY KEY,
|
|
63
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
64
|
+
original_blob BLOB NOT NULL,
|
|
65
|
+
compressed_hash TEXT NOT NULL,
|
|
66
|
+
byte_size_orig INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
byte_size_comp INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
model TEXT NOT NULL DEFAULT '',
|
|
69
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
70
|
+
ttl_expires REAL
|
|
71
|
+
)
|
|
72
|
+
""",
|
|
73
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_ccr_hash ON llmcache_ccr_originals (compressed_hash)",
|
|
74
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_ccr_tenant ON llmcache_ccr_originals (tenant_id)",
|
|
75
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_ccr_ttl ON llmcache_ccr_originals (ttl_expires) WHERE ttl_expires IS NOT NULL",
|
|
76
|
+
"""
|
|
77
|
+
CREATE TABLE IF NOT EXISTS llmcache_tags (
|
|
78
|
+
tag TEXT NOT NULL,
|
|
79
|
+
cache_key TEXT NOT NULL,
|
|
80
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
81
|
+
PRIMARY KEY (tag, cache_key, tenant_id)
|
|
82
|
+
)
|
|
83
|
+
""",
|
|
84
|
+
"CREATE INDEX IF NOT EXISTS idx_llmcache_tags_tag ON llmcache_tags (tag)",
|
|
85
|
+
"""
|
|
86
|
+
CREATE TABLE IF NOT EXISTS llmcache_boundaries (
|
|
87
|
+
entry_id TEXT PRIMARY KEY,
|
|
88
|
+
logistic_t REAL NOT NULL DEFAULT 0.95,
|
|
89
|
+
logistic_gamma REAL NOT NULL DEFAULT 10.0,
|
|
90
|
+
sample_count INTEGER NOT NULL DEFAULT 0,
|
|
91
|
+
updated_at REAL NOT NULL DEFAULT 0
|
|
92
|
+
)
|
|
93
|
+
""",
|
|
94
|
+
"""
|
|
95
|
+
CREATE TABLE IF NOT EXISTS llmcache_centroids (
|
|
96
|
+
tenant_id TEXT PRIMARY KEY,
|
|
97
|
+
centroid_blob BLOB NOT NULL,
|
|
98
|
+
n INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
100
|
+
)
|
|
101
|
+
""",
|
|
102
|
+
"""
|
|
103
|
+
CREATE TABLE IF NOT EXISTS llmcache_metrics (
|
|
104
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
105
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
106
|
+
misses INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
calls_skipped INTEGER NOT NULL DEFAULT 0,
|
|
108
|
+
tokens_saved_input INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
tokens_saved_output INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
tokens_saved_compress INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
evictions INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
latency_overhead_ms_sum REAL NOT NULL DEFAULT 0,
|
|
113
|
+
latency_samples INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
compress_runs INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
compress_bytes_original INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
compress_bytes_after INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
cache_size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
cache_entry_count INTEGER NOT NULL DEFAULT 0,
|
|
119
|
+
updated_at REAL NOT NULL DEFAULT 0
|
|
120
|
+
)
|
|
121
|
+
""",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_all_tables(conn: sqlite3.Connection) -> None:
|
|
126
|
+
"""Create all llmcache_* tables, indexes, and seed schema_version.
|
|
127
|
+
|
|
128
|
+
Safe to call repeatedly — all DDL uses IF NOT EXISTS.
|
|
129
|
+
Also seeds the single llmcache_metrics row (id=1) via INSERT OR IGNORE.
|
|
130
|
+
"""
|
|
131
|
+
for stmt in _DDL_STATEMENTS:
|
|
132
|
+
conn.execute(stmt)
|
|
133
|
+
conn.execute("INSERT OR IGNORE INTO llmcache_metrics(id) VALUES (1)")
|
|
134
|
+
row = conn.execute(
|
|
135
|
+
"SELECT 1 FROM llmcache_schema_version WHERE version = ?",
|
|
136
|
+
(CACHE_SCHEMA_VERSION,),
|
|
137
|
+
).fetchone()
|
|
138
|
+
if row is None:
|
|
139
|
+
conn.execute(
|
|
140
|
+
"INSERT INTO llmcache_schema_version (version, description) VALUES (?, ?)",
|
|
141
|
+
(CACHE_SCHEMA_VERSION, ""),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def drop_all_tables(conn: sqlite3.Connection) -> None:
|
|
146
|
+
"""Drop all llmcache_* tables. Testing only."""
|
|
147
|
+
for name in get_table_names():
|
|
148
|
+
conn.execute(f"DROP TABLE IF EXISTS {name}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_table_names() -> tuple[str, ...]:
|
|
152
|
+
"""Return all table names in creation order."""
|
|
153
|
+
return (
|
|
154
|
+
"llmcache_schema_version",
|
|
155
|
+
"llmcache_entries",
|
|
156
|
+
"llmcache_semantic_vectors",
|
|
157
|
+
"llmcache_ccr_originals",
|
|
158
|
+
"llmcache_tags",
|
|
159
|
+
"llmcache_boundaries",
|
|
160
|
+
"llmcache_centroids",
|
|
161
|
+
"llmcache_metrics",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def assert_no_memory_db_tables(conn: sqlite3.Connection) -> None:
|
|
166
|
+
"""Assert that no memory.db tables are present. Raises RuntimeError on violation.
|
|
167
|
+
|
|
168
|
+
Use this after opening a connection to confirm we are NOT talking to memory.db.
|
|
169
|
+
"""
|
|
170
|
+
rows = conn.execute(
|
|
171
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
172
|
+
).fetchall()
|
|
173
|
+
table_names = {r[0] for r in rows}
|
|
174
|
+
forbidden = {
|
|
175
|
+
"memories", "atomic_facts", "profiles", "canonical_entities",
|
|
176
|
+
"entity_aliases", "consolidation_log", "trust_scores", "bm25_tokens",
|
|
177
|
+
"fact_retention", "core_memory_blocks", "ccq_consolidated_blocks",
|
|
178
|
+
}
|
|
179
|
+
found = forbidden & table_names
|
|
180
|
+
if found:
|
|
181
|
+
raise RuntimeError(
|
|
182
|
+
f"ISOLATION VIOLATION: llmcache.db contains memory.db tables: {sorted(found)}. "
|
|
183
|
+
"This means the wrong database file was opened. Aborting."
|
|
184
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Optimize API routes — single canonical route file (INTERFACE-CONTRACT v2 §5 R2-01).
|
|
6
|
+
|
|
7
|
+
All optimize endpoints live here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("superlocalmemory.server.routes.optimize")
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/api/optimize", tags=["optimize"])
|
|
21
|
+
|
|
22
|
+
# F-03 fix: module-level singleton — constructed once, shared by /savings + CLI
|
|
23
|
+
from superlocalmemory.optimize.metrics.estimator import SavingsEstimator as _SavingsEstimator # noqa: E402
|
|
24
|
+
_savings_estimator = _SavingsEstimator()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigUpdateRequest(BaseModel):
|
|
28
|
+
"""Partial config update — only provided fields are changed."""
|
|
29
|
+
enabled: bool | None = None
|
|
30
|
+
cache_enabled: bool | None = None
|
|
31
|
+
semantic_enabled: bool | None = None
|
|
32
|
+
compress_enabled: bool | None = None
|
|
33
|
+
compress_mode: Literal['safe', 'aggressive'] | None = None
|
|
34
|
+
compress_code: bool | None = None
|
|
35
|
+
compress_prose: bool | None = None
|
|
36
|
+
compress_ccr: bool | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/config")
|
|
40
|
+
async def get_config() -> dict[str, Any]:
|
|
41
|
+
"""Return current optimize config as JSON."""
|
|
42
|
+
try:
|
|
43
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
44
|
+
cfg = ConfigStore().get()
|
|
45
|
+
return cfg.as_dict()
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
logger.warning("GET /api/optimize/config failed: %s", exc)
|
|
48
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.put("/config")
|
|
52
|
+
async def put_config(body: ConfigUpdateRequest) -> dict[str, Any]:
|
|
53
|
+
"""Update optimize config (partial). Daemon hot-reloads within 2s."""
|
|
54
|
+
try:
|
|
55
|
+
import dataclasses
|
|
56
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
57
|
+
store = ConfigStore()
|
|
58
|
+
cfg = store.get()
|
|
59
|
+
updates: dict[str, Any] = {}
|
|
60
|
+
for field_name in ConfigUpdateRequest.model_fields:
|
|
61
|
+
val = getattr(body, field_name, None)
|
|
62
|
+
if val is not None:
|
|
63
|
+
updates[field_name] = val
|
|
64
|
+
if updates:
|
|
65
|
+
cfg = dataclasses.replace(cfg, **updates)
|
|
66
|
+
store.save(cfg)
|
|
67
|
+
return {"status": "ok", "updated": list(updates.keys())}
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.warning("PUT /api/optimize/config failed: %s", exc)
|
|
72
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.get("/savings")
|
|
76
|
+
async def get_savings() -> dict[str, Any]:
|
|
77
|
+
"""Return savings estimates from live metrics + CacheDB.
|
|
78
|
+
|
|
79
|
+
Field names match INTERFACE-CONTRACT §5 exactly.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
83
|
+
from superlocalmemory.optimize.metrics.counters import get_metrics
|
|
84
|
+
from superlocalmemory.optimize.storage.db import CacheDB
|
|
85
|
+
|
|
86
|
+
cfg = ConfigStore().get()
|
|
87
|
+
collector = get_metrics()
|
|
88
|
+
db = CacheDB()
|
|
89
|
+
snap = collector.snapshot(
|
|
90
|
+
cache_size_bytes=db.db_size_bytes(),
|
|
91
|
+
cache_entry_count=db.entry_count(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# F-03 fix: use module-level singleton instead of per-request instantiation
|
|
95
|
+
active_model = getattr(cfg, "active_model", "anthropic")
|
|
96
|
+
# Determine provider from model
|
|
97
|
+
provider = "anthropic"
|
|
98
|
+
for key in ("anthropic", "openai", "gemini"):
|
|
99
|
+
if key in active_model.lower():
|
|
100
|
+
provider = key
|
|
101
|
+
break
|
|
102
|
+
est = _savings_estimator.estimate(snap, provider=provider)
|
|
103
|
+
|
|
104
|
+
# compress_ratio from snap (byte-based, survives restart)
|
|
105
|
+
if snap.compress_bytes_original > 0:
|
|
106
|
+
compress_ratio = snap.compress_bytes_after / snap.compress_bytes_original
|
|
107
|
+
else:
|
|
108
|
+
compress_ratio = None
|
|
109
|
+
|
|
110
|
+
total = snap.hits + snap.misses
|
|
111
|
+
hit_rate = snap.hits / total if total > 0 else 0.0
|
|
112
|
+
|
|
113
|
+
# F-02 fix: response keys match INTERFACE-CONTRACT §5 exactly
|
|
114
|
+
return {
|
|
115
|
+
"tokens_saved_input": snap.tokens_saved_input,
|
|
116
|
+
"tokens_saved_output": snap.tokens_saved_output,
|
|
117
|
+
"calls_skipped": snap.calls_skipped,
|
|
118
|
+
"compress_ratio": round(compress_ratio, 4) if compress_ratio is not None else None,
|
|
119
|
+
"cost_saved": {"usd": est["usd"], "inr": est["inr"]},
|
|
120
|
+
"hit_rate": round(hit_rate, 4),
|
|
121
|
+
"cache_bytes": snap.cache_size_bytes,
|
|
122
|
+
"entries": snap.cache_entry_count,
|
|
123
|
+
# diagnostic fields — non-conflicting extras
|
|
124
|
+
"hits": snap.hits,
|
|
125
|
+
"misses": snap.misses,
|
|
126
|
+
"tokens_saved_compress": snap.tokens_saved_compress,
|
|
127
|
+
"is_stale": est["is_stale"],
|
|
128
|
+
"pricing_date": est["pricing_date"],
|
|
129
|
+
}
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.warning("GET /api/optimize/savings failed: %s", exc)
|
|
132
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.get("/stats")
|
|
136
|
+
async def get_stats() -> dict[str, Any]:
|
|
137
|
+
"""Return raw metrics snapshot as JSON."""
|
|
138
|
+
try:
|
|
139
|
+
from superlocalmemory.optimize.metrics.counters import get_metrics
|
|
140
|
+
from superlocalmemory.optimize.storage.db import CacheDB
|
|
141
|
+
|
|
142
|
+
collector = get_metrics()
|
|
143
|
+
db = CacheDB()
|
|
144
|
+
snap = collector.snapshot(
|
|
145
|
+
cache_size_bytes=db.db_size_bytes(),
|
|
146
|
+
cache_entry_count=db.entry_count(),
|
|
147
|
+
)
|
|
148
|
+
# Return all 16 fields as dict
|
|
149
|
+
import dataclasses
|
|
150
|
+
return dataclasses.asdict(snap)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.warning("GET /api/optimize/stats failed: %s", exc)
|
|
153
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.delete("/cache/clear")
|
|
157
|
+
async def delete_cache_clear(tenant: str = "default") -> dict[str, Any]:
|
|
158
|
+
"""Delete all cache entries for a tenant."""
|
|
159
|
+
try:
|
|
160
|
+
from superlocalmemory.optimize.storage.db import CacheDB
|
|
161
|
+
db = CacheDB()
|
|
162
|
+
deleted = db.clear_tenant(tenant)
|
|
163
|
+
return {"success": True, "deleted": deleted}
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
logger.warning("DELETE /api/optimize/cache/clear failed: %s", exc)
|
|
166
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
@@ -365,6 +365,51 @@ async def test_embedding_endpoint(request: Request):
|
|
|
365
365
|
return {"success": False, "error": type(e).__name__}
|
|
366
366
|
|
|
367
367
|
|
|
368
|
+
@router.get("/embed/ping")
|
|
369
|
+
async def embed_ping():
|
|
370
|
+
"""V3.5.9: Liveness probe for McpEmbedderProxy. Returns 200 when daemon
|
|
371
|
+
embedder is ready so the proxy knows the daemon is reachable."""
|
|
372
|
+
try:
|
|
373
|
+
from .helpers import get_engine_lazy
|
|
374
|
+
# We just need to confirm the route is alive — engine check is optional
|
|
375
|
+
return {"ok": True}
|
|
376
|
+
except Exception as e:
|
|
377
|
+
return JSONResponse({"ok": False, "error": str(e)}, status_code=503)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@router.post("/embed")
|
|
381
|
+
async def embed_texts(request: Request):
|
|
382
|
+
"""V3.5.9: Embed texts via daemon's FULL engine for McpEmbedderProxy.
|
|
383
|
+
|
|
384
|
+
MCP processes run LIGHT engines (no ONNX worker). This endpoint lets them
|
|
385
|
+
delegate embed_batch() to the daemon's single real embedder — one ONNX
|
|
386
|
+
worker total across all sessions (fixes PR #30 NULL embedder bug).
|
|
387
|
+
"""
|
|
388
|
+
import asyncio
|
|
389
|
+
try:
|
|
390
|
+
body = await request.json()
|
|
391
|
+
texts = body.get("texts", [])
|
|
392
|
+
if not texts:
|
|
393
|
+
return {"embeddings": []}
|
|
394
|
+
|
|
395
|
+
from .helpers import get_engine_lazy
|
|
396
|
+
engine = get_engine_lazy(request.app.state)
|
|
397
|
+
if engine is None or engine._embedder is None:
|
|
398
|
+
return JSONResponse(
|
|
399
|
+
{"error": "Embedder not available in daemon"},
|
|
400
|
+
status_code=503,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
loop = asyncio.get_event_loop()
|
|
404
|
+
embeddings = await loop.run_in_executor(
|
|
405
|
+
None,
|
|
406
|
+
lambda: engine._embedder.embed_batch(texts),
|
|
407
|
+
)
|
|
408
|
+
return {"embeddings": embeddings}
|
|
409
|
+
except Exception as e:
|
|
410
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
411
|
+
|
|
412
|
+
|
|
368
413
|
@router.post("/provider/test")
|
|
369
414
|
async def test_provider(request: Request):
|
|
370
415
|
"""Test connectivity to an LLM provider."""
|
|
@@ -400,8 +445,25 @@ async def test_provider(request: Request):
|
|
|
400
445
|
return {"success": True, "message": "OpenRouter connected, key valid"}
|
|
401
446
|
|
|
402
447
|
if provider == "openai":
|
|
448
|
+
# V3.5.9: custom/local endpoint — api_key is optional (llama.cpp, LM Studio etc.)
|
|
449
|
+
custom_endpoint = body.get("base_url", "").strip() or body.get("endpoint", "").strip()
|
|
450
|
+
if custom_endpoint:
|
|
451
|
+
headers_test = {"Content-Type": "application/json"}
|
|
452
|
+
if api_key:
|
|
453
|
+
headers_test["Authorization"] = f"Bearer {api_key}"
|
|
454
|
+
base = custom_endpoint.rstrip("/")
|
|
455
|
+
if not base.endswith("chat/completions"):
|
|
456
|
+
base = f"{base}/chat/completions"
|
|
457
|
+
with httpx.Client(timeout=httpx.Timeout(10.0)) as c:
|
|
458
|
+
# Probe with a minimal chat request (models list not universal on local servers)
|
|
459
|
+
probe = {"model": body.get("model", "test"), "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1}
|
|
460
|
+
resp = c.post(base, headers=headers_test, json=probe)
|
|
461
|
+
if resp.status_code in (200, 400, 422):
|
|
462
|
+
return {"success": True, "message": f"Custom endpoint reachable (HTTP {resp.status_code})"}
|
|
463
|
+
resp.raise_for_status()
|
|
464
|
+
return {"success": True, "message": "Custom OpenAI-compatible endpoint connected"}
|
|
403
465
|
if not api_key:
|
|
404
|
-
return {"success": False, "error": "API key required"}
|
|
466
|
+
return {"success": False, "error": "API key required for official OpenAI endpoint"}
|
|
405
467
|
with httpx.Client(timeout=httpx.Timeout(10.0)) as c:
|
|
406
468
|
resp = c.get("https://api.openai.com/v1/models", headers={"Authorization": f"Bearer {api_key}"})
|
|
407
469
|
resp.raise_for_status()
|
|
@@ -824,8 +824,76 @@ async def lifespan(application: FastAPI):
|
|
|
824
824
|
)
|
|
825
825
|
logger.info(_ready_msg)
|
|
826
826
|
|
|
827
|
+
# Start optimize proxy httpx client if mounted
|
|
828
|
+
try:
|
|
829
|
+
_opt_proxy = getattr(application.state, "optimize_proxy", None)
|
|
830
|
+
if _opt_proxy is not None:
|
|
831
|
+
await _opt_proxy.startup()
|
|
832
|
+
except Exception as _exc: # pragma: no cover — defensive
|
|
833
|
+
logger.warning("optimize_proxy startup failed (non-fatal): %s", _exc)
|
|
834
|
+
|
|
835
|
+
# V3.6: Mount optimize API routes + restore persisted metrics + start flush loop
|
|
836
|
+
try:
|
|
837
|
+
from superlocalmemory.server.routes.optimize import router as optimize_router
|
|
838
|
+
from superlocalmemory.optimize.metrics.counters import MetricsCollector
|
|
839
|
+
from superlocalmemory.optimize.metrics.persistence import MetricsPersistence
|
|
840
|
+
from superlocalmemory.optimize.storage.db import CacheDB
|
|
841
|
+
application.include_router(optimize_router)
|
|
842
|
+
|
|
843
|
+
# Restore persisted metrics counters on startup
|
|
844
|
+
MetricsPersistence().load(MetricsCollector.get_instance(), CacheDB())
|
|
845
|
+
|
|
846
|
+
# Periodic flush — every 60s (OPT-005: guard + task ref for shutdown)
|
|
847
|
+
_metrics_flush_task = getattr(application.state, "_optimize_flush_task", None)
|
|
848
|
+
if _metrics_flush_task is None or _metrics_flush_task.done():
|
|
849
|
+
async def _metrics_flush_loop():
|
|
850
|
+
while True:
|
|
851
|
+
await asyncio.sleep(60)
|
|
852
|
+
try:
|
|
853
|
+
MetricsPersistence().flush(
|
|
854
|
+
MetricsCollector.get_instance(), CacheDB()
|
|
855
|
+
)
|
|
856
|
+
except Exception as e:
|
|
857
|
+
logger.warning("metrics flush error: %s", e)
|
|
858
|
+
application.state._optimize_flush_task = asyncio.create_task(
|
|
859
|
+
_metrics_flush_loop()
|
|
860
|
+
)
|
|
861
|
+
except Exception as e:
|
|
862
|
+
logger.warning("optimize module not available: %s", e)
|
|
863
|
+
|
|
827
864
|
yield
|
|
828
865
|
|
|
866
|
+
# Cancel optimize metrics flush loop + run final flush before shutdown
|
|
867
|
+
try:
|
|
868
|
+
_flush_task = getattr(application.state, "_optimize_flush_task", None)
|
|
869
|
+
if _flush_task is not None and not _flush_task.done():
|
|
870
|
+
_flush_task.cancel()
|
|
871
|
+
try:
|
|
872
|
+
await _flush_task
|
|
873
|
+
except asyncio.CancelledError:
|
|
874
|
+
pass
|
|
875
|
+
# Final flush to persist the last window (H-04: use singleton)
|
|
876
|
+
try:
|
|
877
|
+
from superlocalmemory.optimize.metrics.persistence import MetricsPersistence
|
|
878
|
+
from superlocalmemory.optimize.metrics.counters import MetricsCollector
|
|
879
|
+
from superlocalmemory.optimize.storage.db import CacheDB as _FinalCacheDB
|
|
880
|
+
MetricsPersistence().flush(
|
|
881
|
+
MetricsCollector.get_instance(),
|
|
882
|
+
_FinalCacheDB.get_default(),
|
|
883
|
+
)
|
|
884
|
+
except Exception:
|
|
885
|
+
pass
|
|
886
|
+
except Exception: # pragma: no cover — defensive
|
|
887
|
+
pass
|
|
888
|
+
|
|
889
|
+
# Shutdown optimize proxy httpx client (symmetric with startup)
|
|
890
|
+
try:
|
|
891
|
+
_opt_proxy = getattr(application.state, "optimize_proxy", None)
|
|
892
|
+
if _opt_proxy is not None:
|
|
893
|
+
await _opt_proxy.shutdown()
|
|
894
|
+
except Exception as _exc: # pragma: no cover — defensive
|
|
895
|
+
logger.warning("optimize_proxy shutdown failed (non-fatal): %s", _exc)
|
|
896
|
+
|
|
829
897
|
# S9-W4 C2: symmetric shutdown. Prior version only flushed the
|
|
830
898
|
# observe-buffer + signal_worker + engine. The following long-lived
|
|
831
899
|
# subsystems lived on ``application.state`` but were never
|
|
@@ -1062,6 +1130,33 @@ def create_app() -> FastAPI:
|
|
|
1062
1130
|
except ImportError as exc: # pragma: no cover — defensive wiring
|
|
1063
1131
|
logger.warning("token router not wired: %s", exc)
|
|
1064
1132
|
|
|
1133
|
+
# ── Optimize proxy (optional, fail-open) ──────────────────────────────
|
|
1134
|
+
# Mounts on the existing daemon port 8765. Proxy routes carry provider
|
|
1135
|
+
# API keys (x-api-key, Authorization), NOT the SLM API key. Auth-exempt
|
|
1136
|
+
# path prefixes are configured below in the auth_middleware block.
|
|
1137
|
+
try:
|
|
1138
|
+
from superlocalmemory.optimize.config.store import ConfigStore
|
|
1139
|
+
from superlocalmemory.optimize.proxy.server import ProxyApp, build_proxy_router
|
|
1140
|
+
|
|
1141
|
+
_opt_cfg = ConfigStore().get()
|
|
1142
|
+
if _opt_cfg.proxy_enabled:
|
|
1143
|
+
_proxy = ProxyApp(config=_opt_cfg)
|
|
1144
|
+
application.state.optimize_proxy = _proxy
|
|
1145
|
+
_proxy_router = build_proxy_router(_proxy)
|
|
1146
|
+
# prefix="" — proxy claims /v1/*, /v1beta/* directly.
|
|
1147
|
+
application.include_router(_proxy_router, prefix="")
|
|
1148
|
+
logger.info(
|
|
1149
|
+
"optimize.proxy mounted on /v1/*, /v1beta/* port=8765"
|
|
1150
|
+
)
|
|
1151
|
+
else:
|
|
1152
|
+
application.state.optimize_proxy = None
|
|
1153
|
+
except ImportError:
|
|
1154
|
+
application.state.optimize_proxy = None
|
|
1155
|
+
logger.debug("optimize.proxy not installed — skipping")
|
|
1156
|
+
except Exception as _exc: # pragma: no cover — defensive
|
|
1157
|
+
application.state.optimize_proxy = None
|
|
1158
|
+
logger.warning("optimize.proxy mount failed (non-fatal): %s", _exc)
|
|
1159
|
+
|
|
1065
1160
|
# -- Daemon-specific routes --
|
|
1066
1161
|
_register_daemon_routes(application)
|
|
1067
1162
|
|
|
@@ -1113,8 +1208,18 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1113
1208
|
try:
|
|
1114
1209
|
from superlocalmemory.infra.auth_middleware import check_api_key
|
|
1115
1210
|
|
|
1211
|
+
# Auth-exempt path prefixes — proxy routes carry provider API keys
|
|
1212
|
+
# (x-api-key for Anthropic, Authorization: Bearer for OpenAI, x-goog-api-key
|
|
1213
|
+
# for Gemini), never X-SLM-API-Key. Verified: auth_middleware.py:50-82
|
|
1214
|
+
# returns False for POST when api_key file exists and X-SLM-API-Key
|
|
1215
|
+
# is absent.
|
|
1216
|
+
_AUTH_EXEMPT_PREFIXES = ("/v1/", "/v1beta/")
|
|
1217
|
+
|
|
1116
1218
|
@application.middleware("http")
|
|
1117
1219
|
async def auth_middleware(request, call_next):
|
|
1220
|
+
# Exempt proxy paths — LLM clients carry provider keys, not SLM keys.
|
|
1221
|
+
if request.url.path.startswith(_AUTH_EXEMPT_PREFIXES):
|
|
1222
|
+
return await call_next(request)
|
|
1118
1223
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
1119
1224
|
headers = dict(request.headers)
|
|
1120
1225
|
if not check_api_key(headers, is_write=is_write):
|
|
@@ -1233,6 +1233,77 @@
|
|
|
1233
1233
|
</div>
|
|
1234
1234
|
</div>
|
|
1235
1235
|
</div>
|
|
1236
|
+
|
|
1237
|
+
<!-- Optimize Tab -->
|
|
1238
|
+
<div class="tab-pane fade" id="optimize-pane">
|
|
1239
|
+
<div class="card mb-3">
|
|
1240
|
+
<div class="card-header bg-success text-white">
|
|
1241
|
+
<h6 class="mb-0"><i class="bi bi-speedometer"></i> Optimize Module</h6>
|
|
1242
|
+
</div>
|
|
1243
|
+
<div class="card-body">
|
|
1244
|
+
<div class="row g-3">
|
|
1245
|
+
<div class="col-md-6">
|
|
1246
|
+
<h6>Controls</h6>
|
|
1247
|
+
<div class="form-check form-switch mb-2">
|
|
1248
|
+
<input class="form-check-input" type="checkbox" id="opt-enabled">
|
|
1249
|
+
<label class="form-check-label" for="opt-enabled">Optimize Enabled</label>
|
|
1250
|
+
</div>
|
|
1251
|
+
<div class="form-check form-switch mb-2">
|
|
1252
|
+
<input class="form-check-input" type="checkbox" id="opt-cache-enabled">
|
|
1253
|
+
<label class="form-check-label" for="opt-cache-enabled">Cache Enabled</label>
|
|
1254
|
+
</div>
|
|
1255
|
+
<div class="form-check form-switch mb-2">
|
|
1256
|
+
<input class="form-check-input" type="checkbox" id="opt-semantic-enabled">
|
|
1257
|
+
<label class="form-check-label" for="opt-semantic-enabled">Semantic Cache</label>
|
|
1258
|
+
</div>
|
|
1259
|
+
<div class="form-check form-switch mb-2">
|
|
1260
|
+
<input class="form-check-input" type="checkbox" id="opt-compress-enabled">
|
|
1261
|
+
<label class="form-check-label" for="opt-compress-enabled">Compression</label>
|
|
1262
|
+
</div>
|
|
1263
|
+
<div class="mb-2">
|
|
1264
|
+
<label for="opt-compress-mode" class="form-label">Compression Mode</label>
|
|
1265
|
+
<select class="form-select form-select-sm" id="opt-compress-mode">
|
|
1266
|
+
<option value="safe">Safe</option>
|
|
1267
|
+
<option value="aggressive">Aggressive</option>
|
|
1268
|
+
</select>
|
|
1269
|
+
</div>
|
|
1270
|
+
<div class="form-check form-switch mb-2">
|
|
1271
|
+
<input class="form-check-input" type="checkbox" id="opt-compress-code">
|
|
1272
|
+
<label class="form-check-label" for="opt-compress-code">Code Compression</label>
|
|
1273
|
+
</div>
|
|
1274
|
+
<div class="form-check form-switch mb-2">
|
|
1275
|
+
<input class="form-check-input" type="checkbox" id="opt-compress-prose">
|
|
1276
|
+
<label class="form-check-label" for="opt-compress-prose">Prose Compression</label>
|
|
1277
|
+
</div>
|
|
1278
|
+
<div class="form-check form-switch mb-2">
|
|
1279
|
+
<input class="form-check-input" type="checkbox" id="opt-compress-ccr">
|
|
1280
|
+
<label class="form-check-label" for="opt-compress-ccr">CCR</label>
|
|
1281
|
+
</div>
|
|
1282
|
+
<div class="form-check form-switch mb-2">
|
|
1283
|
+
<input class="form-check-input" type="checkbox" id="opt-compress-align">
|
|
1284
|
+
<label class="form-check-label" for="opt-compress-align">Alignment Compression</label>
|
|
1285
|
+
</div>
|
|
1286
|
+
</div>
|
|
1287
|
+
<div class="col-md-6">
|
|
1288
|
+
<h6>Savings</h6>
|
|
1289
|
+
<table class="table table-sm">
|
|
1290
|
+
<tr><td>Tokens Saved</td><td id="opt-tokens-saved">-</td></tr>
|
|
1291
|
+
<tr><td>USD Saved</td><td id="opt-usd-saved">-</td></tr>
|
|
1292
|
+
<tr><td>INR Saved</td><td id="opt-inr-saved">-</td></tr>
|
|
1293
|
+
<tr><td>Hit Rate</td><td id="opt-hit-rate">-</td></tr>
|
|
1294
|
+
<tr><td>Cache Entries</td><td id="opt-cache-entries">-</td></tr>
|
|
1295
|
+
<tr><td>Cache Size</td><td id="opt-cache-size">-</td></tr>
|
|
1296
|
+
<tr><td>Compression Ratio</td><td id="opt-compression-ratio">-</td></tr>
|
|
1297
|
+
<tr><td>Pricing Date</td><td id="opt-pricing-date">-</td></tr>
|
|
1298
|
+
</table>
|
|
1299
|
+
<div class="small text-muted">Config version: <span id="opt-config-version">-</span></div>
|
|
1300
|
+
<div id="opt-stale-warning" class="text-warning small mt-1"></div>
|
|
1301
|
+
<button class="btn btn-sm btn-outline-primary mt-2" id="opt-copy-url">Copy URL</button>
|
|
1302
|
+
</div>
|
|
1303
|
+
</div>
|
|
1304
|
+
</div>
|
|
1305
|
+
</div>
|
|
1306
|
+
</div>
|
|
1236
1307
|
</div>
|
|
1237
1308
|
</div>
|
|
1238
1309
|
|
|
@@ -1329,6 +1400,33 @@
|
|
|
1329
1400
|
<script src="static/js/ng-skills.js?v=3411"></script>
|
|
1330
1401
|
<script src="static/js/ng-mesh.js?v=345"></script>
|
|
1331
1402
|
<script src="static/js/ng-shell.js?v=34421g"></script>
|
|
1403
|
+
<!-- Optimize tab (v3.6.0) — runtime config for cache + compress + proxy -->
|
|
1404
|
+
<script src="static/js/optimize.js?v=360"></script>
|
|
1405
|
+
|
|
1406
|
+
<!-- Aggressive compression warning modal (v3.6.0 M-03) -->
|
|
1407
|
+
<div class="modal fade" id="optimizeAggressiveModal" tabindex="-1" aria-labelledby="optimizeAggressiveModalLabel" aria-hidden="true">
|
|
1408
|
+
<div class="modal-dialog modal-dialog-centered">
|
|
1409
|
+
<div class="modal-content" style="background:var(--ng-bg-elevated);border:1px solid var(--ng-status-warning-bg);">
|
|
1410
|
+
<div class="modal-header" style="border-bottom:1px solid var(--ng-border-subtle);">
|
|
1411
|
+
<h5 class="modal-title" id="optimizeAggressiveModalLabel" style="color:var(--ng-status-warning);">
|
|
1412
|
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>Aggressive Compression Warning
|
|
1413
|
+
</h5>
|
|
1414
|
+
</div>
|
|
1415
|
+
<div class="modal-body" style="color:var(--ng-text-secondary);">
|
|
1416
|
+
<p><strong style="color:var(--ng-text-primary);">Aggressive mode may reduce output fidelity.</strong></p>
|
|
1417
|
+
<ul>
|
|
1418
|
+
<li>Do NOT use for: code generation, legal text, exact-output tasks, math.</li>
|
|
1419
|
+
<li>Safe for: summarization, brainstorming, open-ended chat.</li>
|
|
1420
|
+
</ul>
|
|
1421
|
+
<p class="mb-0">Switch to Aggressive mode anyway?</p>
|
|
1422
|
+
</div>
|
|
1423
|
+
<div class="modal-footer" style="border-top:1px solid var(--ng-border-subtle);">
|
|
1424
|
+
<button type="button" class="btn btn-outline-secondary" id="optimize-aggressive-cancel">Keep Safe Mode</button>
|
|
1425
|
+
<button type="button" class="btn btn-warning" id="optimize-aggressive-confirm">I Understand — Use Aggressive</button>
|
|
1426
|
+
</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
</div>
|
|
1332
1430
|
|
|
1333
1431
|
<footer style="text-align:center; padding:24px; border-top:1px solid var(--bs-border-color); font-size:0.8125rem;">
|
|
1334
1432
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; margin-bottom:8px;">
|