clsplusplus 4.0.0__py3-none-any.whl
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.
- clsplusplus/__init__.py +31 -0
- clsplusplus/api.py +1596 -0
- clsplusplus/auth.py +74 -0
- clsplusplus/cli.py +715 -0
- clsplusplus/client.py +462 -0
- clsplusplus/config.py +116 -0
- clsplusplus/cost_model.py +51 -0
- clsplusplus/demo_llm.py +133 -0
- clsplusplus/demo_llm_calls.py +100 -0
- clsplusplus/demo_local.py +515 -0
- clsplusplus/embeddings.py +52 -0
- clsplusplus/idempotency.py +66 -0
- clsplusplus/integration_service.py +256 -0
- clsplusplus/jwt_utils.py +39 -0
- clsplusplus/local_routes.py +781 -0
- clsplusplus/main.py +21 -0
- clsplusplus/memory_cycle.py +216 -0
- clsplusplus/memory_phase.py +3541 -0
- clsplusplus/memory_service.py +1323 -0
- clsplusplus/metrics.py +184 -0
- clsplusplus/middleware.py +325 -0
- clsplusplus/models.py +430 -0
- clsplusplus/permissions.py +54 -0
- clsplusplus/plasticity.py +148 -0
- clsplusplus/rate_limit.py +53 -0
- clsplusplus/rbac_service.py +86 -0
- clsplusplus/reconsolidation.py +71 -0
- clsplusplus/sleep_cycle.py +109 -0
- clsplusplus/stores/__init__.py +13 -0
- clsplusplus/stores/base.py +43 -0
- clsplusplus/stores/integration_store.py +648 -0
- clsplusplus/stores/l0_working_buffer.py +103 -0
- clsplusplus/stores/l1_indexing_store.py +427 -0
- clsplusplus/stores/l2_schema_graph.py +231 -0
- clsplusplus/stores/l3_deep_recess.py +182 -0
- clsplusplus/stores/l3_postgres.py +183 -0
- clsplusplus/stores/rbac_store.py +327 -0
- clsplusplus/stores/user_store.py +255 -0
- clsplusplus/stripe_service.py +136 -0
- clsplusplus/temporal.py +613 -0
- clsplusplus/test_suite.py +587 -0
- clsplusplus/tiers.py +109 -0
- clsplusplus/tracer.py +226 -0
- clsplusplus/usage.py +130 -0
- clsplusplus/user_embeddings.py +1636 -0
- clsplusplus/user_service.py +256 -0
- clsplusplus/webhook_dispatcher.py +229 -0
- clsplusplus-4.0.0.dist-info/METADATA +262 -0
- clsplusplus-4.0.0.dist-info/RECORD +53 -0
- clsplusplus-4.0.0.dist-info/WHEEL +5 -0
- clsplusplus-4.0.0.dist-info/entry_points.txt +2 -0
- clsplusplus-4.0.0.dist-info/licenses/LICENSE +201 -0
- clsplusplus-4.0.0.dist-info/top_level.txt +1 -0
clsplusplus/api.py
ADDED
|
@@ -0,0 +1,1596 @@
|
|
|
1
|
+
"""CLS++ REST API - FastAPI application."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time as _time
|
|
6
|
+
import uuid as _uuid_mod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional, List
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path as FilePath
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, HTTPException, Path, Query, Request, Body
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
17
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
18
|
+
from fastapi.staticfiles import StaticFiles
|
|
19
|
+
|
|
20
|
+
from clsplusplus.config import Settings
|
|
21
|
+
from clsplusplus.integration_service import IntegrationService
|
|
22
|
+
from clsplusplus.memory_service import MemoryService
|
|
23
|
+
from clsplusplus.user_service import UserService
|
|
24
|
+
from clsplusplus.middleware import AuthMiddleware, QuotaMiddleware, RateLimitMiddleware, RequestIdMiddleware, TracingMiddleware
|
|
25
|
+
from clsplusplus.tracer import tracer
|
|
26
|
+
from clsplusplus.models import (
|
|
27
|
+
AdjudicateRequest,
|
|
28
|
+
ApiKeyCreate,
|
|
29
|
+
DemoChatRequest,
|
|
30
|
+
ForgetRequest,
|
|
31
|
+
HealthResponse,
|
|
32
|
+
IntegrationCreate,
|
|
33
|
+
MemoryCycleRequest,
|
|
34
|
+
ReadRequest,
|
|
35
|
+
ReadResponse,
|
|
36
|
+
TierUpgradeRequest,
|
|
37
|
+
UserLoginRequest,
|
|
38
|
+
UserProfileUpdateRequest,
|
|
39
|
+
UserRegisterRequest,
|
|
40
|
+
WebhookCreate,
|
|
41
|
+
WriteRequest,
|
|
42
|
+
)
|
|
43
|
+
from clsplusplus.models import _validate_item_id as validate_item_id
|
|
44
|
+
from clsplusplus.models import _validate_namespace as validate_namespace
|
|
45
|
+
from clsplusplus.sleep_cycle import SleepOrchestrator
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app(settings: Optional[Settings] = None) -> FastAPI:
|
|
49
|
+
"""Create FastAPI application."""
|
|
50
|
+
settings = settings or Settings()
|
|
51
|
+
memory_service = MemoryService(settings)
|
|
52
|
+
sleep_orchestrator = SleepOrchestrator(settings, engine=memory_service.engine)
|
|
53
|
+
integration_service = IntegrationService(settings)
|
|
54
|
+
user_service = UserService(settings)
|
|
55
|
+
|
|
56
|
+
app = FastAPI(
|
|
57
|
+
title="CLS++ API",
|
|
58
|
+
description="Brain-inspired, model-agnostic persistent memory for LLMs",
|
|
59
|
+
version="1.5.0",
|
|
60
|
+
docs_url="/docs",
|
|
61
|
+
redoc_url="/redoc",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
app.add_middleware(
|
|
65
|
+
CORSMiddleware,
|
|
66
|
+
allow_origins=["*"],
|
|
67
|
+
allow_credentials=True,
|
|
68
|
+
allow_methods=["GET", "POST", "DELETE", "OPTIONS", "HEAD"],
|
|
69
|
+
allow_headers=["*"],
|
|
70
|
+
expose_headers=["*"],
|
|
71
|
+
)
|
|
72
|
+
# Middleware execution order: outermost (added last) runs first.
|
|
73
|
+
# TracingMiddleware → RequestId → RateLimit → Auth → Quota → route handler
|
|
74
|
+
app.add_middleware(QuotaMiddleware, settings=settings)
|
|
75
|
+
app.add_middleware(AuthMiddleware, settings=settings)
|
|
76
|
+
app.add_middleware(RateLimitMiddleware, settings=settings)
|
|
77
|
+
app.add_middleware(RequestIdMiddleware)
|
|
78
|
+
app.add_middleware(TracingMiddleware) # outermost: traces every /v1/* request
|
|
79
|
+
|
|
80
|
+
# Structured error handler (blueprint: error messages that teach)
|
|
81
|
+
@app.exception_handler(HTTPException)
|
|
82
|
+
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
83
|
+
detail = exc.detail
|
|
84
|
+
if isinstance(detail, str):
|
|
85
|
+
content = {
|
|
86
|
+
"error": "request_error",
|
|
87
|
+
"message": detail,
|
|
88
|
+
"status_code": exc.status_code,
|
|
89
|
+
}
|
|
90
|
+
if exc.status_code == 401:
|
|
91
|
+
content["fix"] = "Add Authorization: Bearer <api_key> header"
|
|
92
|
+
content["docs"] = "https://github.com/rajamohan1950/CLSplusplus/wiki/API-Reference"
|
|
93
|
+
elif exc.status_code == 429:
|
|
94
|
+
content["fix"] = f"Retry after {request.headers.get('Retry-After', 60)} seconds or upgrade plan"
|
|
95
|
+
content["docs"] = "https://github.com/rajamohan1950/CLSplusplus/wiki/SaaS-and-Pricing"
|
|
96
|
+
elif exc.status_code == 422:
|
|
97
|
+
content["fix"] = "Check request body against API schema"
|
|
98
|
+
content["docs"] = "/docs"
|
|
99
|
+
else:
|
|
100
|
+
content = {"error": "request_error", "message": str(detail), "status_code": exc.status_code}
|
|
101
|
+
return JSONResponse(status_code=exc.status_code, content=content)
|
|
102
|
+
|
|
103
|
+
def _ns_query(default: str = "default") -> str:
|
|
104
|
+
return Query(default=default, min_length=1, max_length=64)
|
|
105
|
+
|
|
106
|
+
def _trace_id(request: Request) -> str:
|
|
107
|
+
"""Return the trace ID already set by TracingMiddleware, or generate one."""
|
|
108
|
+
return (
|
|
109
|
+
getattr(request.state, "trace_id", None)
|
|
110
|
+
or request.headers.get("x-trace-id")
|
|
111
|
+
or request.headers.get("x-request-id")
|
|
112
|
+
or getattr(request.state, "request_id", None)
|
|
113
|
+
or str(__import__("uuid").uuid4())
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Detect website directory (in Docker: /app/website, local dev: ../website relative to src)
|
|
117
|
+
_website_dir = os.environ.get("CLS_WEBSITE_DIR")
|
|
118
|
+
if not _website_dir:
|
|
119
|
+
_candidate = FilePath(__file__).resolve().parent.parent.parent / "website"
|
|
120
|
+
if _candidate.is_dir():
|
|
121
|
+
_website_dir = str(_candidate)
|
|
122
|
+
|
|
123
|
+
@app.get("/")
|
|
124
|
+
async def root():
|
|
125
|
+
"""Serve index.html if website is bundled, otherwise API info JSON."""
|
|
126
|
+
if _website_dir:
|
|
127
|
+
index = FilePath(_website_dir) / "index.html"
|
|
128
|
+
if index.exists():
|
|
129
|
+
return FileResponse(str(index), media_type="text/html")
|
|
130
|
+
return {
|
|
131
|
+
"name": "CLS++ API",
|
|
132
|
+
"version": "0.1.0",
|
|
133
|
+
"docs": "/docs",
|
|
134
|
+
"health": "/v1/memory/health",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@app.get("/health")
|
|
138
|
+
async def health_check():
|
|
139
|
+
"""Quick health check for Render/load balancers — must return 200 fast."""
|
|
140
|
+
return {"status": "ok", "version": "1.5.0"}
|
|
141
|
+
|
|
142
|
+
@app.get("/v1/health")
|
|
143
|
+
async def v1_health():
|
|
144
|
+
"""Quick liveness probe. chat.js and other clients call this on startup."""
|
|
145
|
+
return {"status": "ok", "version": getattr(settings, "version", "0.7")}
|
|
146
|
+
|
|
147
|
+
# Per-user metrics emitter (shared across all endpoints)
|
|
148
|
+
from clsplusplus.metrics import MetricsEmitter
|
|
149
|
+
_metrics = MetricsEmitter(settings)
|
|
150
|
+
memory_service._metrics = _metrics # Wire metrics into memory service
|
|
151
|
+
|
|
152
|
+
async def _record_usage(operation: str, request: Request):
|
|
153
|
+
"""Fire-and-forget usage tracking. Must never crash a user request."""
|
|
154
|
+
try:
|
|
155
|
+
api_key = getattr(request.state, "api_key", None)
|
|
156
|
+
if api_key and settings.track_usage:
|
|
157
|
+
from clsplusplus.usage import record_usage, record_operation
|
|
158
|
+
await record_usage(api_key, operation, settings)
|
|
159
|
+
await record_operation(api_key, settings)
|
|
160
|
+
# Per-user metrics (emit even without track_usage for admin visibility)
|
|
161
|
+
user_id = getattr(request.state, "user_id", None)
|
|
162
|
+
if user_id:
|
|
163
|
+
await _metrics.emit(user_id, operation)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass # Usage tracking failure must not affect user responses
|
|
166
|
+
|
|
167
|
+
@app.post("/v1/memory/write")
|
|
168
|
+
async def write_memory(req: WriteRequest, request: Request):
|
|
169
|
+
"""Write memory. Flows to L0, promotes to L1 if score warrants."""
|
|
170
|
+
tid = _trace_id(request)
|
|
171
|
+
with tracer.span(tid, "api.write", "api",
|
|
172
|
+
input=req.text[:200],
|
|
173
|
+
namespace=req.namespace, source=req.source) as api_hop:
|
|
174
|
+
item = await memory_service.write(req, trace_id=tid)
|
|
175
|
+
tracer.add_metadata(tid, api_hop,
|
|
176
|
+
output=f"item_id={str(item.id)[:8]}… level={item.store_level.value}")
|
|
177
|
+
await _record_usage("write", request)
|
|
178
|
+
return {"id": item.id, "store_level": item.store_level.value, "text": item.text, "trace_id": tid}
|
|
179
|
+
|
|
180
|
+
@app.post("/v1/memories/encode")
|
|
181
|
+
async def encode_memory(req: WriteRequest, request: Request):
|
|
182
|
+
"""Product alias: POST /memories/encode -> write."""
|
|
183
|
+
tid = _trace_id(request)
|
|
184
|
+
with tracer.span(tid, "api.encode", "api",
|
|
185
|
+
input=req.text[:200],
|
|
186
|
+
namespace=req.namespace) as api_hop:
|
|
187
|
+
item = await memory_service.write(req, trace_id=tid)
|
|
188
|
+
tracer.add_metadata(tid, api_hop,
|
|
189
|
+
output=f"item_id={str(item.id)[:8]}… level={item.store_level.value}")
|
|
190
|
+
await _record_usage("encode", request)
|
|
191
|
+
return {"id": item.id, "store_level": item.store_level.value, "text": item.text, "trace_id": tid}
|
|
192
|
+
|
|
193
|
+
@app.post("/v1/memory/read", response_model=ReadResponse)
|
|
194
|
+
async def read_memory(req: ReadRequest, request: Request):
|
|
195
|
+
"""Read memories by semantic query across all stores."""
|
|
196
|
+
tid = _trace_id(request)
|
|
197
|
+
with tracer.span(tid, "api.read", "api",
|
|
198
|
+
input=req.query[:200],
|
|
199
|
+
namespace=req.namespace, limit=req.limit) as api_hop:
|
|
200
|
+
result = await memory_service.read(req, trace_id=tid)
|
|
201
|
+
items = result.items or []
|
|
202
|
+
preview = items[0].text[:80] if items else "no results"
|
|
203
|
+
tracer.add_metadata(tid, api_hop, output=f"{len(items)} items: {preview}")
|
|
204
|
+
await _record_usage("read", request)
|
|
205
|
+
result.trace_id = tid
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
@app.post("/v1/memories/retrieve", response_model=ReadResponse)
|
|
209
|
+
async def retrieve_memories(req: ReadRequest, request: Request):
|
|
210
|
+
"""Product alias: POST /memories/retrieve -> read."""
|
|
211
|
+
tid = _trace_id(request)
|
|
212
|
+
with tracer.span(tid, "api.retrieve", "api",
|
|
213
|
+
input=req.query[:200],
|
|
214
|
+
namespace=req.namespace) as api_hop:
|
|
215
|
+
result = await memory_service.read(req, trace_id=tid)
|
|
216
|
+
items = result.items or []
|
|
217
|
+
tracer.add_metadata(tid, api_hop, output=f"{len(items)} items")
|
|
218
|
+
await _record_usage("retrieve", request)
|
|
219
|
+
result.trace_id = tid
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
@app.post("/v1/memories/search", response_model=ReadResponse)
|
|
223
|
+
async def search_memories(req: ReadRequest, request: Request):
|
|
224
|
+
"""Resource-oriented: POST /memories/search -> read."""
|
|
225
|
+
tid = _trace_id(request)
|
|
226
|
+
with tracer.span(tid, "api.search", "api",
|
|
227
|
+
input=req.query[:200],
|
|
228
|
+
namespace=req.namespace) as api_hop:
|
|
229
|
+
result = await memory_service.read(req, trace_id=tid)
|
|
230
|
+
items = result.items or []
|
|
231
|
+
tracer.add_metadata(tid, api_hop, output=f"{len(items)} items")
|
|
232
|
+
await _record_usage("retrieve", request)
|
|
233
|
+
result.trace_id = tid
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
@app.get("/v1/memory/item/{item_id}")
|
|
237
|
+
async def get_item(
|
|
238
|
+
item_id: str = Path(..., min_length=1, max_length=64),
|
|
239
|
+
namespace: str = _ns_query(),
|
|
240
|
+
):
|
|
241
|
+
"""Get full item with lineage and versions."""
|
|
242
|
+
try:
|
|
243
|
+
validate_item_id(item_id)
|
|
244
|
+
validate_namespace(namespace)
|
|
245
|
+
except ValueError as e:
|
|
246
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
247
|
+
item = await memory_service.get_item(item_id, namespace)
|
|
248
|
+
if not item:
|
|
249
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
250
|
+
return item.to_dict()
|
|
251
|
+
|
|
252
|
+
@app.post("/v1/memories/prewarm")
|
|
253
|
+
async def prewarm_namespace(request: Request, namespace: str = _ns_query()):
|
|
254
|
+
"""Pre-load a namespace into memory so the first user request is instant.
|
|
255
|
+
|
|
256
|
+
Call this at application startup for active namespaces. Returns immediately
|
|
257
|
+
— loading happens in the background. Idempotent (safe to call repeatedly).
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
validate_namespace(namespace)
|
|
261
|
+
except ValueError as e:
|
|
262
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
263
|
+
await memory_service.prewarm(namespace)
|
|
264
|
+
already = namespace in memory_service._loaded_namespaces
|
|
265
|
+
await _record_usage("prewarm", request)
|
|
266
|
+
return {"status": "loaded" if already else "loading", "namespace": namespace}
|
|
267
|
+
|
|
268
|
+
@app.post("/v1/memory/sleep")
|
|
269
|
+
async def trigger_sleep(request: Request, namespace: str = _ns_query()):
|
|
270
|
+
"""Trigger nightly sleep cycle (admin)."""
|
|
271
|
+
try:
|
|
272
|
+
validate_namespace(namespace)
|
|
273
|
+
except ValueError as e:
|
|
274
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
275
|
+
report = await sleep_orchestrator.run(namespace)
|
|
276
|
+
await _record_usage("consolidation", request)
|
|
277
|
+
return report
|
|
278
|
+
|
|
279
|
+
@app.post("/v1/memories/consolidate")
|
|
280
|
+
async def consolidate_memories(request: Request, namespace: str = _ns_query()):
|
|
281
|
+
"""Product alias: POST /memories/consolidate -> sleep."""
|
|
282
|
+
try:
|
|
283
|
+
validate_namespace(namespace)
|
|
284
|
+
except ValueError as e:
|
|
285
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
286
|
+
report = await sleep_orchestrator.run(namespace)
|
|
287
|
+
await _record_usage("consolidation", request)
|
|
288
|
+
return report
|
|
289
|
+
|
|
290
|
+
@app.delete("/v1/memory/forget")
|
|
291
|
+
async def forget_memory(req: ForgetRequest, request: Request):
|
|
292
|
+
"""Delete a memory by ID (RTBF)."""
|
|
293
|
+
deleted = await memory_service.delete(req.item_id, req.namespace)
|
|
294
|
+
if not deleted:
|
|
295
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
296
|
+
await _record_usage("delete", request)
|
|
297
|
+
return {"deleted": True, "item_id": req.item_id}
|
|
298
|
+
|
|
299
|
+
@app.delete("/v1/memories/forget")
|
|
300
|
+
async def forget_memory_alias(req: ForgetRequest, request: Request):
|
|
301
|
+
"""Product alias: DELETE /memories/forget."""
|
|
302
|
+
deleted = await memory_service.delete(req.item_id, req.namespace)
|
|
303
|
+
if not deleted:
|
|
304
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
305
|
+
await _record_usage("delete", request)
|
|
306
|
+
return {"deleted": True, "item_id": req.item_id}
|
|
307
|
+
|
|
308
|
+
@app.post("/v1/memory/adjudicate_conflict")
|
|
309
|
+
async def adjudicate_conflict(req: AdjudicateRequest, request: Request):
|
|
310
|
+
"""Submit conflicting fact + evidence for reconsolidation gate."""
|
|
311
|
+
result = await memory_service.adjudicate(
|
|
312
|
+
new_text=req.new_fact,
|
|
313
|
+
namespace=req.namespace,
|
|
314
|
+
evidence=req.evidence,
|
|
315
|
+
existing_item_id=req.existing_item_id,
|
|
316
|
+
)
|
|
317
|
+
# If the returned item is the same as the existing one, quorum was not met
|
|
318
|
+
decision = "rejected" if (req.existing_item_id and result.id == req.existing_item_id) else "accepted"
|
|
319
|
+
await _record_usage("adjudication", request)
|
|
320
|
+
return {"decision": decision, "new_id": result.id}
|
|
321
|
+
|
|
322
|
+
@app.get("/v1/demo/status")
|
|
323
|
+
async def demo_status():
|
|
324
|
+
"""Check which LLM keys are configured (for debugging)."""
|
|
325
|
+
return {
|
|
326
|
+
"claude": bool(getattr(settings, "anthropic_api_key", None)),
|
|
327
|
+
"openai": bool(getattr(settings, "openai_api_key", None)),
|
|
328
|
+
"gemini": bool(getattr(settings, "google_api_key", None)),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@app.post("/v1/demo/chat")
|
|
332
|
+
async def demo_chat(req: DemoChatRequest, request: Request):
|
|
333
|
+
"""
|
|
334
|
+
Real LLM demo: Claude, OpenAI, or Gemini with shared CLS++ memory.
|
|
335
|
+
Requires CLS_ANTHROPIC_API_KEY, CLS_OPENAI_API_KEY, CLS_GOOGLE_API_KEY in env.
|
|
336
|
+
"""
|
|
337
|
+
from clsplusplus.demo_llm import chat_with_llm
|
|
338
|
+
|
|
339
|
+
if req.model not in ("claude", "openai", "gemini"):
|
|
340
|
+
raise HTTPException(status_code=400, detail="model must be claude, openai, or gemini")
|
|
341
|
+
|
|
342
|
+
tid = _trace_id(request)
|
|
343
|
+
try:
|
|
344
|
+
with tracer.span(tid, "api.demo_chat", "api",
|
|
345
|
+
input=req.message[:200],
|
|
346
|
+
model=req.model, namespace=req.namespace) as api_hop:
|
|
347
|
+
reply = await chat_with_llm(
|
|
348
|
+
memory_service, settings, req.model, req.message.strip(), req.namespace,
|
|
349
|
+
trace_id=tid,
|
|
350
|
+
)
|
|
351
|
+
tracer.add_metadata(tid, api_hop, output=reply[:200])
|
|
352
|
+
return {"model": req.model, "reply": reply}
|
|
353
|
+
except Exception as e:
|
|
354
|
+
import logging
|
|
355
|
+
logging.getLogger(__name__).error("Demo chat error: %s", e)
|
|
356
|
+
raise HTTPException(
|
|
357
|
+
status_code=500,
|
|
358
|
+
detail="Demo error: An internal error occurred. Check server logs.",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
@app.post("/v1/demo/memory-cycle")
|
|
362
|
+
async def memory_cycle(req: MemoryCycleRequest):
|
|
363
|
+
"""Run full memory lifecycle: encode → retrieve → augment → cross-session.
|
|
364
|
+
|
|
365
|
+
Proves memory persists across models and sessions.
|
|
366
|
+
"""
|
|
367
|
+
for m in req.models:
|
|
368
|
+
if m not in ("claude", "openai", "gemini"):
|
|
369
|
+
raise HTTPException(status_code=400, detail=f"Invalid model: {m}. Use claude, openai, or gemini.")
|
|
370
|
+
|
|
371
|
+
from clsplusplus.memory_cycle import run_memory_cycle
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
result = await run_memory_cycle(
|
|
375
|
+
memory_service, settings,
|
|
376
|
+
statements=req.statements,
|
|
377
|
+
queries=req.queries,
|
|
378
|
+
models=req.models,
|
|
379
|
+
namespace=req.namespace,
|
|
380
|
+
)
|
|
381
|
+
return result
|
|
382
|
+
except Exception as e:
|
|
383
|
+
import logging
|
|
384
|
+
logging.getLogger(__name__).error("Memory cycle error: %s", e)
|
|
385
|
+
raise HTTPException(
|
|
386
|
+
status_code=500,
|
|
387
|
+
detail="Memory cycle error: An internal error occurred. Check server logs.",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
@app.get("/v1/memory/health", response_model=HealthResponse)
|
|
391
|
+
async def health():
|
|
392
|
+
"""Composite health + per-store metrics."""
|
|
393
|
+
h = await memory_service.health()
|
|
394
|
+
stores = dict(h["stores"])
|
|
395
|
+
if h["status"] == "degraded" and "localhost" in str(stores):
|
|
396
|
+
stores["_hint"] = {
|
|
397
|
+
"status": "info",
|
|
398
|
+
"store": "Setup",
|
|
399
|
+
"error": "Add CLS_REDIS_URL and CLS_DATABASE_URL in Render Dashboard.",
|
|
400
|
+
}
|
|
401
|
+
return HealthResponse(status=h["status"], stores=stores)
|
|
402
|
+
|
|
403
|
+
@app.get("/v1/health/score", response_model=HealthResponse)
|
|
404
|
+
async def health_score():
|
|
405
|
+
"""Product alias: GET /health/score -> memory health."""
|
|
406
|
+
return await health()
|
|
407
|
+
|
|
408
|
+
@app.get("/v1/memories/knowledge", response_model=ReadResponse)
|
|
409
|
+
async def query_knowledge(
|
|
410
|
+
request: Request,
|
|
411
|
+
query: str = Query(..., min_length=1, max_length=4096),
|
|
412
|
+
namespace: str = _ns_query(),
|
|
413
|
+
limit: int = Query(default=10, ge=1, le=100),
|
|
414
|
+
):
|
|
415
|
+
"""Product alias: GET /knowledge - query L2/L3 (neocortical) only."""
|
|
416
|
+
try:
|
|
417
|
+
validate_namespace(namespace)
|
|
418
|
+
except ValueError as e:
|
|
419
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
420
|
+
from clsplusplus.models import ReadRequest, StoreLevel
|
|
421
|
+
|
|
422
|
+
req = ReadRequest(
|
|
423
|
+
query=query,
|
|
424
|
+
namespace=namespace,
|
|
425
|
+
limit=limit,
|
|
426
|
+
store_levels=[StoreLevel.L2, StoreLevel.L3],
|
|
427
|
+
)
|
|
428
|
+
result = await memory_service.read(req)
|
|
429
|
+
await _record_usage("knowledge", request)
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
@app.delete("/v1/memories/{item_id}")
|
|
433
|
+
async def forget_memory_by_id(
|
|
434
|
+
request: Request,
|
|
435
|
+
item_id: str = Path(..., min_length=1, max_length=64),
|
|
436
|
+
namespace: str = _ns_query(),
|
|
437
|
+
):
|
|
438
|
+
"""Resource-oriented: DELETE /memories/{id} (forget)."""
|
|
439
|
+
try:
|
|
440
|
+
validate_item_id(item_id)
|
|
441
|
+
validate_namespace(namespace)
|
|
442
|
+
except ValueError as e:
|
|
443
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
444
|
+
deleted = await memory_service.delete(item_id, namespace)
|
|
445
|
+
if not deleted:
|
|
446
|
+
raise HTTPException(status_code=404, detail="Item not found")
|
|
447
|
+
await _record_usage("delete", request)
|
|
448
|
+
return {"deleted": True, "item_id": item_id}
|
|
449
|
+
|
|
450
|
+
@app.get("/v1/usage")
|
|
451
|
+
async def usage_endpoint(request: Request):
|
|
452
|
+
"""Usage metrics for current period with tier info. Requires API key when auth enabled."""
|
|
453
|
+
from clsplusplus.auth import get_api_key_from_request
|
|
454
|
+
api_key = getattr(request.state, "api_key", None) or get_api_key_from_request(request.headers.get("Authorization"))
|
|
455
|
+
if settings.require_api_key and not api_key:
|
|
456
|
+
raise HTTPException(status_code=401, detail="API key required")
|
|
457
|
+
from clsplusplus.tiers import get_tier, get_quota_status
|
|
458
|
+
tier = get_tier(settings)
|
|
459
|
+
return await get_quota_status(api_key or "anonymous", tier, settings)
|
|
460
|
+
|
|
461
|
+
@app.get("/v1/billing/usage")
|
|
462
|
+
async def billing_usage(request: Request):
|
|
463
|
+
"""Billing API: usage for current period (alias for /v1/usage)."""
|
|
464
|
+
return await usage_endpoint(request)
|
|
465
|
+
|
|
466
|
+
# =========================================================================
|
|
467
|
+
# User Auth API — Registration, login, Google OAuth, profile
|
|
468
|
+
# =========================================================================
|
|
469
|
+
|
|
470
|
+
def _set_session_cookie(response: JSONResponse, token: str) -> JSONResponse:
|
|
471
|
+
response.set_cookie(
|
|
472
|
+
key="cls_session",
|
|
473
|
+
value=token,
|
|
474
|
+
httponly=True,
|
|
475
|
+
secure=False, # Set True in production (HTTPS)
|
|
476
|
+
samesite="lax",
|
|
477
|
+
max_age=7 * 86400, # 7 days
|
|
478
|
+
path="/",
|
|
479
|
+
)
|
|
480
|
+
return response
|
|
481
|
+
|
|
482
|
+
@app.post("/v1/auth/register")
|
|
483
|
+
async def register_user(req: UserRegisterRequest):
|
|
484
|
+
"""Register a new user with email and password."""
|
|
485
|
+
try:
|
|
486
|
+
user, token = await user_service.register(req.email, req.password, req.name)
|
|
487
|
+
except ValueError as e:
|
|
488
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
489
|
+
except Exception as e:
|
|
490
|
+
raise HTTPException(status_code=500, detail=f"Registration failed: {type(e).__name__}: {str(e)[:200]}")
|
|
491
|
+
response = JSONResponse(content=user)
|
|
492
|
+
return _set_session_cookie(response, token)
|
|
493
|
+
|
|
494
|
+
@app.post("/v1/auth/login")
|
|
495
|
+
async def login_user(req: UserLoginRequest):
|
|
496
|
+
"""Login with email and password."""
|
|
497
|
+
try:
|
|
498
|
+
user, token = await user_service.login(req.email, req.password)
|
|
499
|
+
except ValueError as e:
|
|
500
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
501
|
+
except Exception as e:
|
|
502
|
+
raise HTTPException(status_code=500, detail="Login service unavailable")
|
|
503
|
+
response = JSONResponse(content=user)
|
|
504
|
+
return _set_session_cookie(response, token)
|
|
505
|
+
|
|
506
|
+
@app.post("/v1/auth/logout")
|
|
507
|
+
async def logout_user():
|
|
508
|
+
"""Clear session cookie."""
|
|
509
|
+
response = JSONResponse(content={"detail": "Logged out"})
|
|
510
|
+
response.delete_cookie("cls_session", path="/")
|
|
511
|
+
return response
|
|
512
|
+
|
|
513
|
+
@app.get("/v1/auth/me")
|
|
514
|
+
async def get_current_user(request: Request):
|
|
515
|
+
"""Get current authenticated user from JWT cookie."""
|
|
516
|
+
user_id = getattr(request.state, "user_id", None)
|
|
517
|
+
if not user_id:
|
|
518
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
519
|
+
try:
|
|
520
|
+
user = await user_service.get_user(user_id)
|
|
521
|
+
except Exception:
|
|
522
|
+
raise HTTPException(status_code=500, detail="User service unavailable")
|
|
523
|
+
if not user:
|
|
524
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
525
|
+
return user
|
|
526
|
+
|
|
527
|
+
@app.get("/v1/auth/google")
|
|
528
|
+
async def google_auth_redirect(request: Request, redirect: str = "/dashboard.html"):
|
|
529
|
+
"""Redirect to Google OAuth consent screen."""
|
|
530
|
+
from urllib.parse import urlencode
|
|
531
|
+
if not settings.google_client_id:
|
|
532
|
+
raise HTTPException(status_code=501, detail="Google OAuth not configured")
|
|
533
|
+
callback_url = str(request.base_url).rstrip("/") + "/v1/auth/google/callback"
|
|
534
|
+
# Force HTTPS when behind reverse proxy (Render, Cloudflare, etc.)
|
|
535
|
+
if request.headers.get("x-forwarded-proto") == "https" or "onrender.com" in callback_url:
|
|
536
|
+
callback_url = callback_url.replace("http://", "https://", 1)
|
|
537
|
+
params = urlencode({
|
|
538
|
+
"client_id": settings.google_client_id,
|
|
539
|
+
"redirect_uri": callback_url,
|
|
540
|
+
"response_type": "code",
|
|
541
|
+
"scope": "openid email profile",
|
|
542
|
+
"access_type": "offline",
|
|
543
|
+
"state": redirect,
|
|
544
|
+
})
|
|
545
|
+
from starlette.responses import RedirectResponse
|
|
546
|
+
return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
|
|
547
|
+
|
|
548
|
+
@app.get("/v1/auth/google/callback")
|
|
549
|
+
async def google_auth_callback(request: Request, code: str = "", state: str = "/dashboard.html"):
|
|
550
|
+
"""Handle Google OAuth callback — exchange code, create/login user, redirect."""
|
|
551
|
+
if not code:
|
|
552
|
+
raise HTTPException(status_code=400, detail="Missing authorization code")
|
|
553
|
+
callback_url = str(request.base_url).rstrip("/") + "/v1/auth/google/callback"
|
|
554
|
+
# Force HTTPS when behind reverse proxy (Render, Cloudflare, etc.)
|
|
555
|
+
if request.headers.get("x-forwarded-proto") == "https" or "onrender.com" in callback_url:
|
|
556
|
+
callback_url = callback_url.replace("http://", "https://", 1)
|
|
557
|
+
try:
|
|
558
|
+
user, token = await user_service.google_auth(code, callback_url)
|
|
559
|
+
except ValueError as e:
|
|
560
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
561
|
+
except Exception:
|
|
562
|
+
raise HTTPException(status_code=500, detail="Google auth service unavailable")
|
|
563
|
+
from starlette.responses import RedirectResponse
|
|
564
|
+
response = RedirectResponse(state or "/dashboard.html")
|
|
565
|
+
_set_session_cookie(response, token)
|
|
566
|
+
return response
|
|
567
|
+
|
|
568
|
+
# =========================================================================
|
|
569
|
+
# User Dashboard API — Per-user usage and tier management
|
|
570
|
+
# =========================================================================
|
|
571
|
+
|
|
572
|
+
@app.get("/v1/user/usage")
|
|
573
|
+
async def user_usage(request: Request):
|
|
574
|
+
"""Usage metrics for the authenticated user."""
|
|
575
|
+
user_id = getattr(request.state, "user_id", None)
|
|
576
|
+
if not user_id:
|
|
577
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
578
|
+
user = await user_service.get_user(user_id)
|
|
579
|
+
if not user:
|
|
580
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
581
|
+
from clsplusplus.tiers import Tier, get_quota_status
|
|
582
|
+
tier = Tier(user["tier"])
|
|
583
|
+
namespace = f"user-{user_id[:8]}"
|
|
584
|
+
return await get_quota_status(namespace, tier, settings)
|
|
585
|
+
|
|
586
|
+
@app.post("/v1/user/upgrade")
|
|
587
|
+
async def upgrade_tier(req: TierUpgradeRequest, request: Request):
|
|
588
|
+
"""Change user tier (upgrade or downgrade)."""
|
|
589
|
+
user_id = getattr(request.state, "user_id", None)
|
|
590
|
+
if not user_id:
|
|
591
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
592
|
+
try:
|
|
593
|
+
user = await user_service.update_tier(user_id, req.tier)
|
|
594
|
+
except ValueError as e:
|
|
595
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
596
|
+
except Exception:
|
|
597
|
+
raise HTTPException(status_code=500, detail="Upgrade service unavailable")
|
|
598
|
+
return user
|
|
599
|
+
|
|
600
|
+
@app.get("/v1/user/usage/history")
|
|
601
|
+
async def user_usage_history(request: Request):
|
|
602
|
+
"""Usage history for the last 6 months for the authenticated user."""
|
|
603
|
+
user_id = getattr(request.state, "user_id", None)
|
|
604
|
+
if not user_id:
|
|
605
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
606
|
+
namespace = f"user-{user_id[:8]}"
|
|
607
|
+
from clsplusplus.usage import get_usage_history
|
|
608
|
+
try:
|
|
609
|
+
return await get_usage_history(namespace, months=6, settings=settings)
|
|
610
|
+
except Exception:
|
|
611
|
+
return []
|
|
612
|
+
|
|
613
|
+
@app.get("/v1/user/integrations")
|
|
614
|
+
async def user_integrations(request: Request):
|
|
615
|
+
"""List integrations for the authenticated user's namespace."""
|
|
616
|
+
user_id = getattr(request.state, "user_id", None)
|
|
617
|
+
if not user_id:
|
|
618
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
619
|
+
namespace = f"user-{user_id[:8]}"
|
|
620
|
+
try:
|
|
621
|
+
integrations = await integration_service.list_all(namespace)
|
|
622
|
+
return {"integrations": [i.model_dump(mode="json") if hasattr(i, "model_dump") else i for i in integrations]}
|
|
623
|
+
except Exception:
|
|
624
|
+
return {"integrations": []}
|
|
625
|
+
|
|
626
|
+
@app.patch("/v1/user/profile")
|
|
627
|
+
async def update_profile(req: UserProfileUpdateRequest, request: Request):
|
|
628
|
+
"""Update user profile (name, email, password)."""
|
|
629
|
+
user_id = getattr(request.state, "user_id", None)
|
|
630
|
+
if not user_id:
|
|
631
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
632
|
+
try:
|
|
633
|
+
user = await user_service.update_profile(
|
|
634
|
+
user_id=user_id,
|
|
635
|
+
name=req.name,
|
|
636
|
+
email=req.email,
|
|
637
|
+
password=req.password,
|
|
638
|
+
current_password=req.current_password,
|
|
639
|
+
)
|
|
640
|
+
except ValueError as e:
|
|
641
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
642
|
+
except Exception:
|
|
643
|
+
raise HTTPException(status_code=500, detail="Profile update failed")
|
|
644
|
+
return user
|
|
645
|
+
|
|
646
|
+
# =========================================================================
|
|
647
|
+
# Billing — Stripe Checkout & Customer Portal
|
|
648
|
+
# =========================================================================
|
|
649
|
+
|
|
650
|
+
@app.post("/v1/billing/checkout")
|
|
651
|
+
async def billing_checkout(req: TierUpgradeRequest, request: Request):
|
|
652
|
+
"""Create a Stripe Checkout session for tier upgrade."""
|
|
653
|
+
user_id = getattr(request.state, "user_id", None)
|
|
654
|
+
if not user_id:
|
|
655
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
656
|
+
try:
|
|
657
|
+
from clsplusplus.stripe_service import create_checkout_session
|
|
658
|
+
session_url = await create_checkout_session(
|
|
659
|
+
user_id=user_id,
|
|
660
|
+
tier=req.tier,
|
|
661
|
+
settings=settings,
|
|
662
|
+
)
|
|
663
|
+
return {"url": session_url}
|
|
664
|
+
except ValueError as e:
|
|
665
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
666
|
+
except Exception as e:
|
|
667
|
+
logger.error("Stripe checkout error: %s", e)
|
|
668
|
+
raise HTTPException(status_code=500, detail="Billing service unavailable")
|
|
669
|
+
|
|
670
|
+
@app.get("/v1/billing/portal")
|
|
671
|
+
async def billing_portal(request: Request):
|
|
672
|
+
"""Create a Stripe Customer Portal session for subscription management."""
|
|
673
|
+
user_id = getattr(request.state, "user_id", None)
|
|
674
|
+
if not user_id:
|
|
675
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
676
|
+
try:
|
|
677
|
+
from clsplusplus.stripe_service import create_portal_session
|
|
678
|
+
portal_url = await create_portal_session(
|
|
679
|
+
user_id=user_id,
|
|
680
|
+
settings=settings,
|
|
681
|
+
)
|
|
682
|
+
return {"url": portal_url}
|
|
683
|
+
except ValueError as e:
|
|
684
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
685
|
+
except Exception as e:
|
|
686
|
+
logger.error("Stripe portal error: %s", e)
|
|
687
|
+
raise HTTPException(status_code=500, detail="Billing service unavailable")
|
|
688
|
+
|
|
689
|
+
@app.post("/v1/billing/webhook")
|
|
690
|
+
async def billing_webhook(request: Request):
|
|
691
|
+
"""Handle Stripe webhook events."""
|
|
692
|
+
payload = await request.body()
|
|
693
|
+
sig = request.headers.get("stripe-signature", "")
|
|
694
|
+
try:
|
|
695
|
+
from clsplusplus.stripe_service import handle_webhook
|
|
696
|
+
await handle_webhook(
|
|
697
|
+
payload=payload,
|
|
698
|
+
sig=sig,
|
|
699
|
+
settings=settings,
|
|
700
|
+
user_service=user_service,
|
|
701
|
+
)
|
|
702
|
+
return {"status": "ok"}
|
|
703
|
+
except ValueError as e:
|
|
704
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
705
|
+
except Exception as e:
|
|
706
|
+
logger.error("Stripe webhook error: %s", e)
|
|
707
|
+
raise HTTPException(status_code=500, detail="Webhook processing failed")
|
|
708
|
+
|
|
709
|
+
# =========================================================================
|
|
710
|
+
# Admin Dashboard API — Protected by is_admin flag in JWT
|
|
711
|
+
# =========================================================================
|
|
712
|
+
|
|
713
|
+
@app.get("/admin/metrics/summary")
|
|
714
|
+
async def admin_summary(request: Request):
|
|
715
|
+
"""Top-bar KPIs: Total Users, Revenue, Cost, Margin %."""
|
|
716
|
+
_require_admin(request)
|
|
717
|
+
try:
|
|
718
|
+
from clsplusplus.tiers import Tier, TIER_PRICES
|
|
719
|
+
from clsplusplus.cost_model import compute_cost
|
|
720
|
+
|
|
721
|
+
tier_counts = await user_service.store.count_users_by_tier()
|
|
722
|
+
total_users = sum(tier_counts.values())
|
|
723
|
+
paying_users = total_users - tier_counts.get("free", 0)
|
|
724
|
+
|
|
725
|
+
monthly_revenue = sum(
|
|
726
|
+
tier_counts.get(t.value, 0) * TIER_PRICES[t]
|
|
727
|
+
for t in Tier
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
aggregate = await _metrics.get_aggregate_metrics()
|
|
731
|
+
monthly_cost = compute_cost(aggregate)
|
|
732
|
+
margin = ((monthly_revenue - monthly_cost) / monthly_revenue * 100) if monthly_revenue > 0 else 0
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
"total_users": total_users,
|
|
736
|
+
"paying_users": paying_users,
|
|
737
|
+
"free_users": tier_counts.get("free", 0),
|
|
738
|
+
"monthly_revenue": round(monthly_revenue, 2),
|
|
739
|
+
"monthly_cost": round(monthly_cost, 4),
|
|
740
|
+
"margin_percent": round(margin, 1),
|
|
741
|
+
"tier_counts": tier_counts,
|
|
742
|
+
}
|
|
743
|
+
except Exception as e:
|
|
744
|
+
raise HTTPException(status_code=500, detail=f"Metrics unavailable: {str(e)[:200]}")
|
|
745
|
+
|
|
746
|
+
@app.get("/admin/metrics/signups")
|
|
747
|
+
async def admin_signups(request: Request):
|
|
748
|
+
"""Daily signup counts for the last 90 days."""
|
|
749
|
+
_require_admin(request)
|
|
750
|
+
try:
|
|
751
|
+
signups = await user_service.store.daily_signups(days=90)
|
|
752
|
+
return {"signups": signups}
|
|
753
|
+
except Exception as e:
|
|
754
|
+
raise HTTPException(status_code=500, detail=f"Signup data unavailable: {str(e)[:200]}")
|
|
755
|
+
|
|
756
|
+
@app.get("/admin/metrics/revenue")
|
|
757
|
+
async def admin_revenue(request: Request):
|
|
758
|
+
"""MRR, ARR, and simple linear forecast."""
|
|
759
|
+
_require_admin(request)
|
|
760
|
+
try:
|
|
761
|
+
from clsplusplus.tiers import Tier, TIER_PRICES
|
|
762
|
+
tier_counts = await user_service.store.count_users_by_tier()
|
|
763
|
+
|
|
764
|
+
mrr = sum(
|
|
765
|
+
tier_counts.get(t.value, 0) * TIER_PRICES[t]
|
|
766
|
+
for t in Tier
|
|
767
|
+
)
|
|
768
|
+
arr = mrr * 12
|
|
769
|
+
|
|
770
|
+
# Simple forecast: assume current MRR for remaining FY months
|
|
771
|
+
now = datetime.now()
|
|
772
|
+
months_remaining = 12 - now.month
|
|
773
|
+
fy_projected = arr # Simplified: current run rate
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
"mrr": round(mrr, 2),
|
|
777
|
+
"arr": round(arr, 2),
|
|
778
|
+
"fy_projected": round(fy_projected, 2),
|
|
779
|
+
"months_remaining": months_remaining,
|
|
780
|
+
"tier_counts": tier_counts,
|
|
781
|
+
}
|
|
782
|
+
except Exception as e:
|
|
783
|
+
raise HTTPException(status_code=500, detail=f"Revenue data unavailable: {str(e)[:200]}")
|
|
784
|
+
|
|
785
|
+
@app.get("/admin/metrics/operations")
|
|
786
|
+
async def admin_operations(request: Request):
|
|
787
|
+
"""Aggregate metering points across all users for current period."""
|
|
788
|
+
_require_admin(request)
|
|
789
|
+
try:
|
|
790
|
+
aggregate = await _metrics.get_aggregate_metrics()
|
|
791
|
+
from clsplusplus.cost_model import compute_cost
|
|
792
|
+
total_cost = compute_cost(aggregate)
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
"period": datetime.utcnow().strftime("%Y-%m"),
|
|
796
|
+
"metrics": aggregate,
|
|
797
|
+
"total_cost": round(total_cost, 4),
|
|
798
|
+
}
|
|
799
|
+
except Exception as e:
|
|
800
|
+
raise HTTPException(status_code=500, detail=f"Operations data unavailable: {str(e)[:200]}")
|
|
801
|
+
|
|
802
|
+
@app.get("/admin/metrics/users")
|
|
803
|
+
async def admin_users(request: Request):
|
|
804
|
+
"""Per-user breakdown: tier, operations, cost, revenue."""
|
|
805
|
+
_require_admin(request)
|
|
806
|
+
try:
|
|
807
|
+
from clsplusplus.tiers import Tier, TIER_PRICES
|
|
808
|
+
from clsplusplus.cost_model import compute_cost
|
|
809
|
+
|
|
810
|
+
users = await user_service.list_users(limit=500)
|
|
811
|
+
result = []
|
|
812
|
+
for u in users:
|
|
813
|
+
user_metrics = await _metrics.get_user_metrics(u["id"])
|
|
814
|
+
user_cost = compute_cost(user_metrics)
|
|
815
|
+
user_revenue = TIER_PRICES.get(Tier(u["tier"]), 0)
|
|
816
|
+
ops = sum(user_metrics.values())
|
|
817
|
+
result.append({
|
|
818
|
+
"id": u["id"],
|
|
819
|
+
"email": u["email"],
|
|
820
|
+
"name": u["name"],
|
|
821
|
+
"tier": u["tier"],
|
|
822
|
+
"operations": ops,
|
|
823
|
+
"cost": round(user_cost, 4),
|
|
824
|
+
"revenue": round(user_revenue, 2),
|
|
825
|
+
"created_at": u["created_at"],
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
return {"users": result}
|
|
829
|
+
except Exception as e:
|
|
830
|
+
raise HTTPException(status_code=500, detail=f"User data unavailable: {str(e)[:200]}")
|
|
831
|
+
|
|
832
|
+
def _require_admin(request: Request):
|
|
833
|
+
"""Helper to enforce admin access on endpoints."""
|
|
834
|
+
if not getattr(request.state, "is_admin", False):
|
|
835
|
+
raise HTTPException(status_code=403, detail="Admin access required")
|
|
836
|
+
|
|
837
|
+
@app.get("/admin/metrics/user/{user_id}")
|
|
838
|
+
async def admin_user_detail(request: Request, user_id: str = Path(...)):
|
|
839
|
+
"""Get detailed metrics for a specific user (admin only)."""
|
|
840
|
+
_require_admin(request)
|
|
841
|
+
try:
|
|
842
|
+
user = await user_service.get_user(user_id)
|
|
843
|
+
if not user:
|
|
844
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
845
|
+
user_metrics = await _metrics.get_user_metrics(user_id)
|
|
846
|
+
from clsplusplus.cost_model import compute_cost
|
|
847
|
+
from clsplusplus.tiers import Tier, TIER_PRICES
|
|
848
|
+
user_cost = compute_cost(user_metrics)
|
|
849
|
+
user_revenue = TIER_PRICES.get(Tier(user["tier"]), 0)
|
|
850
|
+
return {
|
|
851
|
+
"user": user,
|
|
852
|
+
"metrics": user_metrics,
|
|
853
|
+
"cost": round(user_cost, 4),
|
|
854
|
+
"revenue": round(user_revenue, 2),
|
|
855
|
+
}
|
|
856
|
+
except HTTPException:
|
|
857
|
+
raise
|
|
858
|
+
except Exception as e:
|
|
859
|
+
raise HTTPException(status_code=500, detail=f"User metrics unavailable: {str(e)[:200]}")
|
|
860
|
+
|
|
861
|
+
@app.get("/admin/metrics/extension")
|
|
862
|
+
async def admin_extension(request: Request):
|
|
863
|
+
"""Browser extension analytics: installs, DAU/WAU/MAU, site usage."""
|
|
864
|
+
_require_admin(request)
|
|
865
|
+
try:
|
|
866
|
+
return await _metrics.get_extension_analytics()
|
|
867
|
+
except Exception as e:
|
|
868
|
+
raise HTTPException(status_code=500, detail=f"Extension analytics unavailable: {str(e)[:200]}")
|
|
869
|
+
|
|
870
|
+
@app.get("/v1/stats/extension")
|
|
871
|
+
async def public_extension_stats():
|
|
872
|
+
"""Public endpoint — returns extension install/active counts for social proof."""
|
|
873
|
+
try:
|
|
874
|
+
data = await _metrics.get_extension_analytics()
|
|
875
|
+
return {"installs": data.get("installs_this_month", 0), "dau": data.get("dau", 0), "mau": data.get("mau", 0)}
|
|
876
|
+
except Exception:
|
|
877
|
+
return {"installs": 0, "dau": 0, "mau": 0}
|
|
878
|
+
|
|
879
|
+
@app.get("/admin/metrics/storage")
|
|
880
|
+
async def admin_storage(request: Request):
|
|
881
|
+
"""Storage metering: item counts across L0/L1/L2, namespaces."""
|
|
882
|
+
_require_admin(request)
|
|
883
|
+
try:
|
|
884
|
+
l0_items = sum(len(items) for items in memory_service.engine._items.values())
|
|
885
|
+
l0_namespaces = len(memory_service.engine._items)
|
|
886
|
+
loaded_namespaces = len(memory_service._loaded_namespaces)
|
|
887
|
+
|
|
888
|
+
# L1 count (if DB available)
|
|
889
|
+
l1_count = 0
|
|
890
|
+
l1_namespaces = 0
|
|
891
|
+
try:
|
|
892
|
+
ns_list = await memory_service.l1.list_namespaces()
|
|
893
|
+
l1_namespaces = len(ns_list)
|
|
894
|
+
for ns in ns_list[:50]: # Cap to avoid slow query
|
|
895
|
+
l1_count += await memory_service.l1.count(ns)
|
|
896
|
+
except Exception:
|
|
897
|
+
pass
|
|
898
|
+
|
|
899
|
+
return {
|
|
900
|
+
"l0_items": l0_items,
|
|
901
|
+
"l0_namespaces": l0_namespaces,
|
|
902
|
+
"l1_items": l1_count,
|
|
903
|
+
"l1_namespaces": l1_namespaces,
|
|
904
|
+
"loaded_namespaces": loaded_namespaces,
|
|
905
|
+
}
|
|
906
|
+
except Exception as e:
|
|
907
|
+
raise HTTPException(status_code=500, detail=f"Storage metrics unavailable: {str(e)[:200]}")
|
|
908
|
+
|
|
909
|
+
# =========================================================================
|
|
910
|
+
# RBAC Admin API — Roles, Groups, Users, Permissions
|
|
911
|
+
# =========================================================================
|
|
912
|
+
|
|
913
|
+
from clsplusplus.rbac_service import RBACService, ALL_SCOPES
|
|
914
|
+
_rbac = RBACService(settings)
|
|
915
|
+
|
|
916
|
+
@app.get("/admin/rbac/scopes")
|
|
917
|
+
async def list_scopes(request: Request):
|
|
918
|
+
_require_admin(request)
|
|
919
|
+
return {"scopes": sorted(ALL_SCOPES)}
|
|
920
|
+
|
|
921
|
+
@app.get("/admin/rbac/roles")
|
|
922
|
+
async def list_roles(request: Request):
|
|
923
|
+
_require_admin(request)
|
|
924
|
+
return {"roles": await _rbac.store.list_roles()}
|
|
925
|
+
|
|
926
|
+
@app.post("/admin/rbac/roles")
|
|
927
|
+
async def create_role(request: Request):
|
|
928
|
+
_require_admin(request)
|
|
929
|
+
body = await request.json()
|
|
930
|
+
role = await _rbac.store.create_role(body["name"], body.get("description", ""), body.get("scopes", []))
|
|
931
|
+
return role
|
|
932
|
+
|
|
933
|
+
@app.put("/admin/rbac/roles/{role_id}")
|
|
934
|
+
async def update_role(request: Request, role_id: str = Path(...)):
|
|
935
|
+
_require_admin(request)
|
|
936
|
+
body = await request.json()
|
|
937
|
+
ok = await _rbac.store.update_role(role_id, body.get("description"), body.get("scopes"))
|
|
938
|
+
if not ok:
|
|
939
|
+
raise HTTPException(status_code=404, detail="Role not found or is a system role")
|
|
940
|
+
return {"ok": True}
|
|
941
|
+
|
|
942
|
+
@app.delete("/admin/rbac/roles/{role_id}")
|
|
943
|
+
async def delete_role(request: Request, role_id: str = Path(...)):
|
|
944
|
+
_require_admin(request)
|
|
945
|
+
ok = await _rbac.store.delete_role(role_id)
|
|
946
|
+
if not ok:
|
|
947
|
+
raise HTTPException(status_code=400, detail="Cannot delete system role")
|
|
948
|
+
return {"ok": True}
|
|
949
|
+
|
|
950
|
+
@app.get("/admin/rbac/groups")
|
|
951
|
+
async def list_groups(request: Request):
|
|
952
|
+
_require_admin(request)
|
|
953
|
+
return {"groups": await _rbac.store.list_groups()}
|
|
954
|
+
|
|
955
|
+
@app.post("/admin/rbac/groups")
|
|
956
|
+
async def create_group(request: Request):
|
|
957
|
+
_require_admin(request)
|
|
958
|
+
body = await request.json()
|
|
959
|
+
group = await _rbac.store.create_group(body["name"], body.get("description", ""))
|
|
960
|
+
return group
|
|
961
|
+
|
|
962
|
+
@app.put("/admin/rbac/groups/{group_id}")
|
|
963
|
+
async def update_group(request: Request, group_id: str = Path(...)):
|
|
964
|
+
_require_admin(request)
|
|
965
|
+
body = await request.json()
|
|
966
|
+
ok = await _rbac.store.update_group(group_id, body.get("name"), body.get("description"))
|
|
967
|
+
if not ok:
|
|
968
|
+
raise HTTPException(status_code=404, detail="Group not found")
|
|
969
|
+
return {"ok": True}
|
|
970
|
+
|
|
971
|
+
@app.delete("/admin/rbac/groups/{group_id}")
|
|
972
|
+
async def delete_group(request: Request, group_id: str = Path(...)):
|
|
973
|
+
_require_admin(request)
|
|
974
|
+
ok = await _rbac.store.delete_group(group_id)
|
|
975
|
+
if not ok:
|
|
976
|
+
raise HTTPException(status_code=404, detail="Group not found")
|
|
977
|
+
return {"ok": True}
|
|
978
|
+
|
|
979
|
+
@app.get("/admin/rbac/groups/{group_id}/roles")
|
|
980
|
+
async def get_group_roles(request: Request, group_id: str = Path(...)):
|
|
981
|
+
_require_admin(request)
|
|
982
|
+
return {"roles": await _rbac.store.get_group_roles(group_id)}
|
|
983
|
+
|
|
984
|
+
@app.post("/admin/rbac/groups/{group_id}/roles")
|
|
985
|
+
async def add_group_role(request: Request, group_id: str = Path(...)):
|
|
986
|
+
_require_admin(request)
|
|
987
|
+
body = await request.json()
|
|
988
|
+
await _rbac.store.add_group_role(group_id, body["role_id"])
|
|
989
|
+
await _rbac.invalidate_group_cache(group_id)
|
|
990
|
+
return {"ok": True}
|
|
991
|
+
|
|
992
|
+
@app.delete("/admin/rbac/groups/{group_id}/roles/{role_id}")
|
|
993
|
+
async def remove_group_role(request: Request, group_id: str = Path(...), role_id: str = Path(...)):
|
|
994
|
+
_require_admin(request)
|
|
995
|
+
await _rbac.store.remove_group_role(group_id, role_id)
|
|
996
|
+
await _rbac.invalidate_group_cache(group_id)
|
|
997
|
+
return {"ok": True}
|
|
998
|
+
|
|
999
|
+
@app.get("/admin/rbac/groups/{group_id}/members")
|
|
1000
|
+
async def get_group_members(request: Request, group_id: str = Path(...)):
|
|
1001
|
+
_require_admin(request)
|
|
1002
|
+
return {"members": await _rbac.store.get_group_members(group_id)}
|
|
1003
|
+
|
|
1004
|
+
@app.post("/admin/rbac/groups/{group_id}/members")
|
|
1005
|
+
async def add_group_member(request: Request, group_id: str = Path(...)):
|
|
1006
|
+
_require_admin(request)
|
|
1007
|
+
body = await request.json()
|
|
1008
|
+
await _rbac.store.add_group_member(group_id, body["user_id"])
|
|
1009
|
+
await _rbac.invalidate_cache(body["user_id"])
|
|
1010
|
+
return {"ok": True}
|
|
1011
|
+
|
|
1012
|
+
@app.delete("/admin/rbac/groups/{group_id}/members/{user_id}")
|
|
1013
|
+
async def remove_group_member(request: Request, group_id: str = Path(...), user_id: str = Path(...)):
|
|
1014
|
+
_require_admin(request)
|
|
1015
|
+
await _rbac.store.remove_group_member(group_id, user_id)
|
|
1016
|
+
await _rbac.invalidate_cache(user_id)
|
|
1017
|
+
return {"ok": True}
|
|
1018
|
+
|
|
1019
|
+
@app.get("/admin/rbac/users/{user_id}/roles")
|
|
1020
|
+
async def get_user_roles(request: Request, user_id: str = Path(...)):
|
|
1021
|
+
_require_admin(request)
|
|
1022
|
+
return {"roles": await _rbac.store.get_user_roles(user_id)}
|
|
1023
|
+
|
|
1024
|
+
@app.post("/admin/rbac/users/{user_id}/roles")
|
|
1025
|
+
async def add_user_role(request: Request, user_id: str = Path(...)):
|
|
1026
|
+
_require_admin(request)
|
|
1027
|
+
body = await request.json()
|
|
1028
|
+
await _rbac.store.add_user_role(user_id, body["role_id"])
|
|
1029
|
+
await _rbac.invalidate_cache(user_id)
|
|
1030
|
+
return {"ok": True}
|
|
1031
|
+
|
|
1032
|
+
@app.delete("/admin/rbac/users/{user_id}/roles/{role_id}")
|
|
1033
|
+
async def remove_user_role(request: Request, user_id: str = Path(...), role_id: str = Path(...)):
|
|
1034
|
+
_require_admin(request)
|
|
1035
|
+
await _rbac.store.remove_user_role(user_id, role_id)
|
|
1036
|
+
await _rbac.invalidate_cache(user_id)
|
|
1037
|
+
return {"ok": True}
|
|
1038
|
+
|
|
1039
|
+
@app.get("/admin/rbac/users/{user_id}/permissions")
|
|
1040
|
+
async def get_user_permissions(request: Request, user_id: str = Path(...)):
|
|
1041
|
+
_require_admin(request)
|
|
1042
|
+
return {"permissions": await _rbac.store.get_user_permissions(user_id)}
|
|
1043
|
+
|
|
1044
|
+
@app.post("/admin/rbac/users/{user_id}/permissions")
|
|
1045
|
+
async def set_user_permission(request: Request, user_id: str = Path(...)):
|
|
1046
|
+
_require_admin(request)
|
|
1047
|
+
body = await request.json()
|
|
1048
|
+
perm = await _rbac.store.set_user_permission(user_id, body["scope"], body.get("granted", True))
|
|
1049
|
+
await _rbac.invalidate_cache(user_id)
|
|
1050
|
+
return perm
|
|
1051
|
+
|
|
1052
|
+
@app.delete("/admin/rbac/users/{user_id}/permissions/{permission_id}")
|
|
1053
|
+
async def remove_user_permission(request: Request, user_id: str = Path(...), permission_id: str = Path(...)):
|
|
1054
|
+
_require_admin(request)
|
|
1055
|
+
await _rbac.store.remove_user_permission(user_id, permission_id)
|
|
1056
|
+
await _rbac.invalidate_cache(user_id)
|
|
1057
|
+
return {"ok": True}
|
|
1058
|
+
|
|
1059
|
+
@app.get("/admin/rbac/users/{user_id}/effective")
|
|
1060
|
+
async def get_effective_scopes(request: Request, user_id: str = Path(...)):
|
|
1061
|
+
_require_admin(request)
|
|
1062
|
+
scopes = await _rbac.get_effective_scopes(user_id)
|
|
1063
|
+
groups = await _rbac.store.get_user_groups(user_id)
|
|
1064
|
+
roles = await _rbac.store.get_user_roles(user_id)
|
|
1065
|
+
overrides = await _rbac.store.get_user_permissions(user_id)
|
|
1066
|
+
return {
|
|
1067
|
+
"scopes": sorted(scopes),
|
|
1068
|
+
"groups": groups,
|
|
1069
|
+
"direct_roles": roles,
|
|
1070
|
+
"overrides": overrides,
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
# =========================================================================
|
|
1074
|
+
# Integration Management API — Self-service integration endpoints
|
|
1075
|
+
# =========================================================================
|
|
1076
|
+
|
|
1077
|
+
@app.post("/v1/integrations")
|
|
1078
|
+
async def create_integration(req: IntegrationCreate):
|
|
1079
|
+
"""Register a new integration. Returns integration + first API key."""
|
|
1080
|
+
integration, api_key = await integration_service.register(req)
|
|
1081
|
+
return {
|
|
1082
|
+
"integration": integration.model_dump(mode="json"),
|
|
1083
|
+
"api_key": api_key.model_dump(mode="json"),
|
|
1084
|
+
"_hint": "Save your API key now — it won't be shown again.",
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
@app.get("/v1/integrations")
|
|
1088
|
+
async def list_integrations(namespace: str = _ns_query()):
|
|
1089
|
+
"""List all integrations for a namespace."""
|
|
1090
|
+
try:
|
|
1091
|
+
validate_namespace(namespace)
|
|
1092
|
+
except ValueError as e:
|
|
1093
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
1094
|
+
items = await integration_service.list_all(namespace)
|
|
1095
|
+
return {"integrations": [i.model_dump(mode="json") for i in items]}
|
|
1096
|
+
|
|
1097
|
+
@app.get("/v1/integrations/{integration_id}")
|
|
1098
|
+
async def get_integration(
|
|
1099
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1100
|
+
):
|
|
1101
|
+
"""Get integration details."""
|
|
1102
|
+
result = await integration_service.get(integration_id)
|
|
1103
|
+
if not result:
|
|
1104
|
+
raise HTTPException(status_code=404, detail="Integration not found")
|
|
1105
|
+
return result.model_dump(mode="json")
|
|
1106
|
+
|
|
1107
|
+
@app.delete("/v1/integrations/{integration_id}")
|
|
1108
|
+
async def delete_integration(
|
|
1109
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1110
|
+
):
|
|
1111
|
+
"""Deactivate an integration (revokes all keys, disables webhooks)."""
|
|
1112
|
+
deleted = await integration_service.delete(integration_id)
|
|
1113
|
+
if not deleted:
|
|
1114
|
+
raise HTTPException(status_code=404, detail="Integration not found")
|
|
1115
|
+
return {"deleted": True, "integration_id": integration_id}
|
|
1116
|
+
|
|
1117
|
+
# --- API Keys ---
|
|
1118
|
+
|
|
1119
|
+
@app.post("/v1/integrations/{integration_id}/keys")
|
|
1120
|
+
async def create_api_key(
|
|
1121
|
+
req: ApiKeyCreate,
|
|
1122
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1123
|
+
):
|
|
1124
|
+
"""Create a new scoped API key. Key is shown only once."""
|
|
1125
|
+
try:
|
|
1126
|
+
result = await integration_service.create_key(integration_id, req)
|
|
1127
|
+
except ValueError as e:
|
|
1128
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
1129
|
+
if not result:
|
|
1130
|
+
raise HTTPException(status_code=404, detail="Integration not found")
|
|
1131
|
+
return {
|
|
1132
|
+
"api_key": result.model_dump(mode="json"),
|
|
1133
|
+
"_hint": "Save your API key now — it won't be shown again.",
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
@app.get("/v1/integrations/{integration_id}/keys")
|
|
1137
|
+
async def list_api_keys(
|
|
1138
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1139
|
+
):
|
|
1140
|
+
"""List API keys for an integration (keys are masked)."""
|
|
1141
|
+
keys = await integration_service.list_keys(integration_id)
|
|
1142
|
+
return {"keys": [k.model_dump(mode="json") for k in keys]}
|
|
1143
|
+
|
|
1144
|
+
@app.post("/v1/integrations/{integration_id}/keys/{key_id}/rotate")
|
|
1145
|
+
async def rotate_api_key(
|
|
1146
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1147
|
+
key_id: str = Path(..., min_length=1, max_length=64),
|
|
1148
|
+
):
|
|
1149
|
+
"""Rotate an API key. Old key has 24h grace period."""
|
|
1150
|
+
result = await integration_service.rotate_key(key_id)
|
|
1151
|
+
if not result:
|
|
1152
|
+
raise HTTPException(status_code=404, detail="API key not found or already revoked")
|
|
1153
|
+
return {
|
|
1154
|
+
"new_key": result.model_dump(mode="json"),
|
|
1155
|
+
"_hint": "Old key is valid for 24 more hours. Save the new key now.",
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
@app.delete("/v1/integrations/{integration_id}/keys/{key_id}")
|
|
1159
|
+
async def revoke_api_key(
|
|
1160
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1161
|
+
key_id: str = Path(..., min_length=1, max_length=64),
|
|
1162
|
+
):
|
|
1163
|
+
"""Revoke an API key immediately."""
|
|
1164
|
+
revoked = await integration_service.revoke_key(key_id)
|
|
1165
|
+
if not revoked:
|
|
1166
|
+
raise HTTPException(status_code=404, detail="API key not found or already revoked")
|
|
1167
|
+
return {"revoked": True, "key_id": key_id}
|
|
1168
|
+
|
|
1169
|
+
# --- Webhooks ---
|
|
1170
|
+
|
|
1171
|
+
@app.post("/v1/integrations/{integration_id}/webhooks")
|
|
1172
|
+
async def create_webhook(
|
|
1173
|
+
req: WebhookCreate,
|
|
1174
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1175
|
+
):
|
|
1176
|
+
"""Subscribe to webhook events. Signing secret shown only once."""
|
|
1177
|
+
try:
|
|
1178
|
+
result = await integration_service.subscribe_webhook(integration_id, req)
|
|
1179
|
+
except ValueError as e:
|
|
1180
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
1181
|
+
if not result:
|
|
1182
|
+
raise HTTPException(status_code=404, detail="Integration not found")
|
|
1183
|
+
return {
|
|
1184
|
+
"webhook": result.model_dump(mode="json"),
|
|
1185
|
+
"_hint": "Save your webhook signing secret now — it won't be shown again.",
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
@app.get("/v1/integrations/{integration_id}/webhooks")
|
|
1189
|
+
async def list_webhooks(
|
|
1190
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1191
|
+
):
|
|
1192
|
+
"""List webhook subscriptions for an integration."""
|
|
1193
|
+
webhooks = await integration_service.list_webhooks(integration_id)
|
|
1194
|
+
return {"webhooks": [w.model_dump(mode="json") for w in webhooks]}
|
|
1195
|
+
|
|
1196
|
+
@app.delete("/v1/integrations/{integration_id}/webhooks/{webhook_id}")
|
|
1197
|
+
async def delete_webhook(
|
|
1198
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1199
|
+
webhook_id: str = Path(..., min_length=1, max_length=64),
|
|
1200
|
+
):
|
|
1201
|
+
"""Unsubscribe from webhook events."""
|
|
1202
|
+
deleted = await integration_service.unsubscribe_webhook(webhook_id)
|
|
1203
|
+
if not deleted:
|
|
1204
|
+
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
1205
|
+
return {"deleted": True, "webhook_id": webhook_id}
|
|
1206
|
+
|
|
1207
|
+
# --- Audit Events ---
|
|
1208
|
+
|
|
1209
|
+
@app.get("/v1/integrations/{integration_id}/events")
|
|
1210
|
+
async def list_integration_events(
|
|
1211
|
+
integration_id: str = Path(..., min_length=1, max_length=64),
|
|
1212
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
1213
|
+
):
|
|
1214
|
+
"""Get audit log for an integration."""
|
|
1215
|
+
events = await integration_service.get_events(integration_id, limit)
|
|
1216
|
+
return {"events": [e.model_dump(mode="json") for e in events]}
|
|
1217
|
+
|
|
1218
|
+
# =========================================================================
|
|
1219
|
+
# Chat Session API — /v1/chat/sessions
|
|
1220
|
+
# =========================================================================
|
|
1221
|
+
|
|
1222
|
+
@dataclass
|
|
1223
|
+
class _ChatMsg:
|
|
1224
|
+
role: str # "user" | "assistant"
|
|
1225
|
+
content: str
|
|
1226
|
+
memory_used: bool = False
|
|
1227
|
+
memory_count: int = 0
|
|
1228
|
+
|
|
1229
|
+
@dataclass
|
|
1230
|
+
class _ChatSession:
|
|
1231
|
+
session_id: str
|
|
1232
|
+
name: str
|
|
1233
|
+
namespace: str
|
|
1234
|
+
messages: list = field(default_factory=list)
|
|
1235
|
+
created_at: float = field(default_factory=_time.time)
|
|
1236
|
+
|
|
1237
|
+
# In-memory session store — persists until server restart
|
|
1238
|
+
_sessions: dict = {}
|
|
1239
|
+
_session_counter: list = [0] # mutable counter via list trick
|
|
1240
|
+
|
|
1241
|
+
def _pick_model() -> str:
|
|
1242
|
+
"""Return first available LLM model based on configured API keys."""
|
|
1243
|
+
if getattr(settings, "anthropic_api_key", None):
|
|
1244
|
+
return "claude"
|
|
1245
|
+
if getattr(settings, "openai_api_key", None):
|
|
1246
|
+
return "openai"
|
|
1247
|
+
if getattr(settings, "google_api_key", None):
|
|
1248
|
+
return "gemini"
|
|
1249
|
+
return "claude"
|
|
1250
|
+
|
|
1251
|
+
def _phase_dynamics(namespace: str) -> dict:
|
|
1252
|
+
"""Build the thermodynamic debug snapshot for a namespace."""
|
|
1253
|
+
raw_items = memory_service.engine._items.get(namespace, [])
|
|
1254
|
+
floor = memory_service.engine.STRENGTH_FLOOR
|
|
1255
|
+
tau_c1 = memory_service.engine.TAU_C1
|
|
1256
|
+
event_counter = memory_service.engine._event_counter
|
|
1257
|
+
|
|
1258
|
+
total_F = 0.0
|
|
1259
|
+
liquid_count = 0
|
|
1260
|
+
gas_count = 0
|
|
1261
|
+
item_list = []
|
|
1262
|
+
|
|
1263
|
+
for item in raw_items:
|
|
1264
|
+
d = item.to_debug_dict(strength_floor=floor)
|
|
1265
|
+
total_F += d["free_energy"]
|
|
1266
|
+
if d["phase"] in ("liquid", "solid", "glass"):
|
|
1267
|
+
liquid_count += 1
|
|
1268
|
+
else:
|
|
1269
|
+
gas_count += 1
|
|
1270
|
+
item_list.append(d)
|
|
1271
|
+
|
|
1272
|
+
# Most relevant first (highest consolidation_strength)
|
|
1273
|
+
item_list.sort(key=lambda x: x["consolidation_strength"], reverse=True)
|
|
1274
|
+
n = len(raw_items)
|
|
1275
|
+
rho = n / max(n + tau_c1, 1e-9)
|
|
1276
|
+
|
|
1277
|
+
return {
|
|
1278
|
+
"memory_density_rho": round(rho, 6),
|
|
1279
|
+
"global_event_counter": event_counter,
|
|
1280
|
+
"total_free_energy": round(total_F, 4),
|
|
1281
|
+
"tau_c1": tau_c1,
|
|
1282
|
+
"liquid_count": liquid_count,
|
|
1283
|
+
"gas_count": gas_count,
|
|
1284
|
+
"items": item_list[:20], # top 20 by strength
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
class _ChatSessionCreateReq(BaseModel):
|
|
1288
|
+
# namespace = the user's persistent brain.
|
|
1289
|
+
# All sessions for the same user must pass the same namespace so memory
|
|
1290
|
+
# flows across conversations and models. Defaults to "default" so
|
|
1291
|
+
# direct API callers work without extra plumbing.
|
|
1292
|
+
namespace: str = "default"
|
|
1293
|
+
|
|
1294
|
+
class _ChatMsgReq(BaseModel):
|
|
1295
|
+
message: str
|
|
1296
|
+
|
|
1297
|
+
@app.post("/v1/chat/sessions")
|
|
1298
|
+
async def create_chat_session(req: _ChatSessionCreateReq):
|
|
1299
|
+
"""Create a new chat session bound to the caller's persistent namespace.
|
|
1300
|
+
|
|
1301
|
+
The namespace is the user's identity boundary — all sessions for the same
|
|
1302
|
+
user must use the same namespace so memory accumulates across conversations
|
|
1303
|
+
and across LLM models.
|
|
1304
|
+
"""
|
|
1305
|
+
try:
|
|
1306
|
+
validate_namespace(req.namespace)
|
|
1307
|
+
except ValueError as e:
|
|
1308
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
1309
|
+
sid = str(_uuid_mod.uuid4())
|
|
1310
|
+
_session_counter[0] += 1
|
|
1311
|
+
name = f"Chat {_session_counter[0]}"
|
|
1312
|
+
_sessions[sid] = _ChatSession(session_id=sid, name=name, namespace=req.namespace)
|
|
1313
|
+
return {"session_id": sid, "name": name, "namespace": req.namespace}
|
|
1314
|
+
|
|
1315
|
+
@app.get("/v1/chat/sessions/{session_id}")
|
|
1316
|
+
async def get_chat_session(session_id: str = Path(..., min_length=1, max_length=64)):
|
|
1317
|
+
"""Return a session with its full message history."""
|
|
1318
|
+
sess = _sessions.get(session_id)
|
|
1319
|
+
if not sess:
|
|
1320
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
1321
|
+
return {
|
|
1322
|
+
"session_id": sess.session_id,
|
|
1323
|
+
"name": sess.name,
|
|
1324
|
+
"namespace": sess.namespace,
|
|
1325
|
+
"messages": [
|
|
1326
|
+
{"role": m.role, "content": m.content,
|
|
1327
|
+
"memory_used": m.memory_used, "memory_count": m.memory_count}
|
|
1328
|
+
for m in sess.messages
|
|
1329
|
+
],
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
@app.delete("/v1/chat/sessions/{session_id}")
|
|
1333
|
+
async def delete_chat_session(session_id: str = Path(..., min_length=1, max_length=64)):
|
|
1334
|
+
"""Delete a session (removes history but not memory)."""
|
|
1335
|
+
if session_id not in _sessions:
|
|
1336
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
1337
|
+
del _sessions[session_id]
|
|
1338
|
+
return {"deleted": True, "session_id": session_id}
|
|
1339
|
+
|
|
1340
|
+
@app.post("/v1/chat/sessions/{session_id}/message")
|
|
1341
|
+
async def chat_session_message(
|
|
1342
|
+
req: _ChatMsgReq,
|
|
1343
|
+
request: Request,
|
|
1344
|
+
session_id: str = Path(..., min_length=1, max_length=64),
|
|
1345
|
+
):
|
|
1346
|
+
"""Send a user message. Returns LLM reply + full debug snapshot."""
|
|
1347
|
+
from clsplusplus.demo_llm_calls import call_claude, call_openai, call_gemini
|
|
1348
|
+
|
|
1349
|
+
sess = _sessions.get(session_id)
|
|
1350
|
+
if not sess:
|
|
1351
|
+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
|
1352
|
+
|
|
1353
|
+
tid = _trace_id(request)
|
|
1354
|
+
user_text = req.message.strip()
|
|
1355
|
+
if not user_text:
|
|
1356
|
+
raise HTTPException(status_code=400, detail="message must not be empty")
|
|
1357
|
+
|
|
1358
|
+
ns = sess.namespace
|
|
1359
|
+
model = _pick_model()
|
|
1360
|
+
|
|
1361
|
+
# 1. Store user message to memory (skip pure questions)
|
|
1362
|
+
def _is_question(t: str) -> bool:
|
|
1363
|
+
t = t.strip().lower()
|
|
1364
|
+
return "?" in t or any(t.startswith(w) for w in
|
|
1365
|
+
("what", "who", "where", "when", "how", "which", "is my", "do you", "can you", "tell me"))
|
|
1366
|
+
|
|
1367
|
+
if not _is_question(user_text):
|
|
1368
|
+
try:
|
|
1369
|
+
from clsplusplus.models import WriteRequest as _WriteReq
|
|
1370
|
+
await memory_service.write(
|
|
1371
|
+
_WriteReq(text=user_text, namespace=ns, source="user", salience=0.8),
|
|
1372
|
+
trace_id=tid,
|
|
1373
|
+
)
|
|
1374
|
+
except Exception:
|
|
1375
|
+
pass
|
|
1376
|
+
|
|
1377
|
+
# 2. Search memory for relevant context
|
|
1378
|
+
memory_hits = []
|
|
1379
|
+
try:
|
|
1380
|
+
from clsplusplus.models import ReadRequest as _ReadReq
|
|
1381
|
+
read_resp = await memory_service.read(
|
|
1382
|
+
_ReadReq(query=user_text, namespace=ns, limit=8),
|
|
1383
|
+
trace_id=tid,
|
|
1384
|
+
)
|
|
1385
|
+
memory_hits = [i.text for i in (read_resp.items or [])]
|
|
1386
|
+
except Exception:
|
|
1387
|
+
pass
|
|
1388
|
+
|
|
1389
|
+
memory_used = len(memory_hits) > 0
|
|
1390
|
+
memory_count = len(memory_hits)
|
|
1391
|
+
|
|
1392
|
+
# 3. Build conversation history (last 6 turns)
|
|
1393
|
+
history_lines = []
|
|
1394
|
+
for m in sess.messages[-6:]:
|
|
1395
|
+
prefix = "User" if m.role == "user" else "Assistant"
|
|
1396
|
+
history_lines.append(f"{prefix}: {m.content}")
|
|
1397
|
+
conv_block = "\n".join(history_lines)
|
|
1398
|
+
history_line_count = len(history_lines)
|
|
1399
|
+
|
|
1400
|
+
# 4. Build augmented system prompt
|
|
1401
|
+
mem_block = ("\n".join(f"- {t}" for t in memory_hits)
|
|
1402
|
+
if memory_hits else "No prior memory context.")
|
|
1403
|
+
augmented_prompt = (
|
|
1404
|
+
"You are a helpful, friendly assistant with persistent memory.\n\n"
|
|
1405
|
+
f"Relevant memory context:\n{mem_block}\n\n"
|
|
1406
|
+
+ (f"Conversation so far:\n{conv_block}\n\n" if conv_block else "")
|
|
1407
|
+
+ "Answer naturally. Use memory context only when relevant."
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
# 5. Call LLM
|
|
1411
|
+
reply = "No LLM configured."
|
|
1412
|
+
try:
|
|
1413
|
+
if model == "claude":
|
|
1414
|
+
reply = await call_claude(settings, augmented_prompt, user_text)
|
|
1415
|
+
elif model == "openai":
|
|
1416
|
+
reply = await call_openai(settings, augmented_prompt, user_text)
|
|
1417
|
+
elif model == "gemini":
|
|
1418
|
+
reply = await call_gemini(settings, augmented_prompt, user_text)
|
|
1419
|
+
except Exception as exc:
|
|
1420
|
+
reply = f"LLM error: {exc}"
|
|
1421
|
+
|
|
1422
|
+
# 6. Store assistant reply to memory
|
|
1423
|
+
try:
|
|
1424
|
+
from clsplusplus.models import WriteRequest as _WriteReq
|
|
1425
|
+
await memory_service.write(
|
|
1426
|
+
_WriteReq(text=f"Assistant replied: {reply[:400]}",
|
|
1427
|
+
namespace=ns, source=f"assistant.{model}", salience=0.6),
|
|
1428
|
+
trace_id=tid,
|
|
1429
|
+
)
|
|
1430
|
+
except Exception:
|
|
1431
|
+
pass
|
|
1432
|
+
|
|
1433
|
+
# 7. Persist messages in session
|
|
1434
|
+
sess.messages.append(_ChatMsg(role="user", content=user_text))
|
|
1435
|
+
sess.messages.append(_ChatMsg(
|
|
1436
|
+
role="assistant", content=reply,
|
|
1437
|
+
memory_used=memory_used, memory_count=memory_count,
|
|
1438
|
+
))
|
|
1439
|
+
|
|
1440
|
+
# 8. Build debug snapshot
|
|
1441
|
+
debug_info = {
|
|
1442
|
+
"model_used": model,
|
|
1443
|
+
"user_message": user_text,
|
|
1444
|
+
"memory_searched": memory_hits,
|
|
1445
|
+
"conversation_history_lines": history_line_count,
|
|
1446
|
+
"augmented_prompt": augmented_prompt,
|
|
1447
|
+
"phase_dynamics": _phase_dynamics(ns),
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
await _record_usage("chat_message", request)
|
|
1451
|
+
|
|
1452
|
+
return {
|
|
1453
|
+
"reply": reply,
|
|
1454
|
+
"memory_used": memory_used,
|
|
1455
|
+
"memory_count": memory_count,
|
|
1456
|
+
"debug": debug_info,
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
# =========================================================================
|
|
1460
|
+
# Trace / Call Graph API
|
|
1461
|
+
# =========================================================================
|
|
1462
|
+
|
|
1463
|
+
@app.get("/v1/memory/traces")
|
|
1464
|
+
async def memory_traces(
|
|
1465
|
+
trace_id: Optional[str] = Query(default=None, description="If set, return full call graph for this id"),
|
|
1466
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
1467
|
+
):
|
|
1468
|
+
"""Single endpoint: list recent traces, or one trace tree when trace_id is set."""
|
|
1469
|
+
if trace_id and trace_id.strip():
|
|
1470
|
+
trace = tracer.get(trace_id.strip())
|
|
1471
|
+
if not trace:
|
|
1472
|
+
raise HTTPException(
|
|
1473
|
+
status_code=404,
|
|
1474
|
+
detail=f"Trace {trace_id} not found (max {tracer.MAX_TRACES} traces kept in memory)",
|
|
1475
|
+
)
|
|
1476
|
+
return trace.to_dict()
|
|
1477
|
+
return {"traces": tracer.list_recent(limit)}
|
|
1478
|
+
|
|
1479
|
+
@app.get("/v1/memory/namespaces")
|
|
1480
|
+
async def list_namespaces():
|
|
1481
|
+
"""Return all namespaces that have items in the PhaseMemoryEngine."""
|
|
1482
|
+
ns_list = []
|
|
1483
|
+
for ns, items in memory_service.engine._items.items():
|
|
1484
|
+
if not items:
|
|
1485
|
+
continue
|
|
1486
|
+
floor = memory_service.engine.STRENGTH_FLOOR
|
|
1487
|
+
phases = {"gas": 0, "liquid": 0, "solid": 0, "glass": 0}
|
|
1488
|
+
for item in items:
|
|
1489
|
+
d = item.to_debug_dict(strength_floor=floor)
|
|
1490
|
+
phases[d["phase"]] += 1
|
|
1491
|
+
# most-recently written item
|
|
1492
|
+
latest = max(items, key=lambda i: i.birth_order)
|
|
1493
|
+
ns_list.append({
|
|
1494
|
+
"namespace": ns,
|
|
1495
|
+
"total": len(items),
|
|
1496
|
+
"phases": phases,
|
|
1497
|
+
"latest_text": (latest.fact.raw_text or "")[:60],
|
|
1498
|
+
"latest_birth_order": latest.birth_order,
|
|
1499
|
+
})
|
|
1500
|
+
# Sort by most recently active first
|
|
1501
|
+
ns_list.sort(key=lambda x: x["latest_birth_order"], reverse=True)
|
|
1502
|
+
return {"namespaces": ns_list}
|
|
1503
|
+
|
|
1504
|
+
@app.get("/v1/memory/phases")
|
|
1505
|
+
async def memory_phases(namespace: str = Query(default="default")):
|
|
1506
|
+
"""Return items grouped by thermodynamic phase (gas/liquid/solid/glass).
|
|
1507
|
+
|
|
1508
|
+
Used by the live Memory Phase visualiser in the Trace UI.
|
|
1509
|
+
Returns the 30 most recent items per phase, sorted by birth_order desc.
|
|
1510
|
+
"""
|
|
1511
|
+
validate_namespace(namespace)
|
|
1512
|
+
raw_items = memory_service.engine._items.get(namespace, [])
|
|
1513
|
+
floor = memory_service.engine.STRENGTH_FLOOR
|
|
1514
|
+
|
|
1515
|
+
phases: dict[str, list] = {"gas": [], "liquid": [], "solid": [], "glass": []}
|
|
1516
|
+
for item in raw_items:
|
|
1517
|
+
d = item.to_debug_dict(strength_floor=floor)
|
|
1518
|
+
# Attach indexed tokens (first 12, human-readable)
|
|
1519
|
+
d["tokens"] = item.indexed_tokens[:12]
|
|
1520
|
+
# Attach schema absorption count if solid/glass
|
|
1521
|
+
if item.schema_meta is not None:
|
|
1522
|
+
d["absorbed_count"] = len(item.schema_meta.H_history)
|
|
1523
|
+
else:
|
|
1524
|
+
d["absorbed_count"] = 0
|
|
1525
|
+
phases[d["phase"]].append(d)
|
|
1526
|
+
|
|
1527
|
+
# Sort each phase by birth_order descending (most recent first)
|
|
1528
|
+
max_birth = max((x["birth_order"] for v in phases.values() for x in v), default=0)
|
|
1529
|
+
for phase_items in phases.values():
|
|
1530
|
+
phase_items.sort(key=lambda x: x["birth_order"], reverse=True)
|
|
1531
|
+
|
|
1532
|
+
return {
|
|
1533
|
+
"namespace": namespace,
|
|
1534
|
+
"max_birth_order": max_birth,
|
|
1535
|
+
"total": len(raw_items),
|
|
1536
|
+
"phases": {
|
|
1537
|
+
p: {"items": items[:30], "total": len(items)}
|
|
1538
|
+
for p, items in phases.items()
|
|
1539
|
+
},
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
_api_logger = logging.getLogger(__name__)
|
|
1543
|
+
|
|
1544
|
+
@app.on_event("startup")
|
|
1545
|
+
async def startup():
|
|
1546
|
+
"""Boot sequence:
|
|
1547
|
+
1. Preload every known namespace from L1 into PhaseMemoryEngine so the
|
|
1548
|
+
first request for any user is instant (no cold-start lag).
|
|
1549
|
+
2. Re-tune IVFFlat lists for the current row count (1M-ready).
|
|
1550
|
+
3. Start the periodic hippocampal replay loop (every 5 minutes).
|
|
1551
|
+
"""
|
|
1552
|
+
# ── 1. Warm up all persisted namespaces from L1 ────────────────────
|
|
1553
|
+
await memory_service.startup_preload()
|
|
1554
|
+
|
|
1555
|
+
# ── 2. Periodic hippocampal replay (consolidation / sleep cycle) ───
|
|
1556
|
+
async def _periodic_replay():
|
|
1557
|
+
while True:
|
|
1558
|
+
await asyncio.sleep(300) # 5 minutes
|
|
1559
|
+
for ns in list(memory_service.engine._items.keys()):
|
|
1560
|
+
try:
|
|
1561
|
+
rehearsed = memory_service.engine.recall_long_tail(ns, batch_size=50)
|
|
1562
|
+
if rehearsed:
|
|
1563
|
+
_api_logger.debug(
|
|
1564
|
+
"Periodic recall_long_tail ns=%s rehearsed=%d", ns, rehearsed
|
|
1565
|
+
)
|
|
1566
|
+
except Exception:
|
|
1567
|
+
pass
|
|
1568
|
+
|
|
1569
|
+
asyncio.create_task(_periodic_replay())
|
|
1570
|
+
|
|
1571
|
+
@app.on_event("shutdown")
|
|
1572
|
+
async def shutdown():
|
|
1573
|
+
"""Cleanly close all connection pools on shutdown."""
|
|
1574
|
+
for store in [memory_service.l1, memory_service.l2]:
|
|
1575
|
+
if hasattr(store, "close"):
|
|
1576
|
+
try:
|
|
1577
|
+
await store.close()
|
|
1578
|
+
except Exception:
|
|
1579
|
+
pass
|
|
1580
|
+
try:
|
|
1581
|
+
await integration_service.close()
|
|
1582
|
+
except Exception:
|
|
1583
|
+
pass
|
|
1584
|
+
|
|
1585
|
+
# Register local/daemon routes (memory viewer, LLM proxies, WebSocket, installer)
|
|
1586
|
+
from clsplusplus.local_routes import create_local_router
|
|
1587
|
+
app.include_router(create_local_router(memory_service, settings, metrics_emitter=_metrics))
|
|
1588
|
+
|
|
1589
|
+
# Serve website static files if the directory exists
|
|
1590
|
+
if _website_dir and FilePath(_website_dir).is_dir():
|
|
1591
|
+
app.mount("/", StaticFiles(directory=_website_dir, html=True), name="website")
|
|
1592
|
+
|
|
1593
|
+
return app
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
app = create_app()
|