Spaces:
Sleeping
Sleeping
Cyber Catalyst Team commited on
Commit ·
d946914
1
Parent(s): 8d7c6d3
Implement compliant SessionStore API endpoints, completions concurrency limit, mirror_error alert, and nightly DB retention policy
Browse files- backend.py +195 -72
backend.py
CHANGED
|
@@ -23,7 +23,8 @@ import time
|
|
| 23 |
import re
|
| 24 |
import collections
|
| 25 |
from pathlib import Path
|
| 26 |
-
from typing import AsyncIterator, Optional
|
|
|
|
| 27 |
|
| 28 |
from fastapi import FastAPI, Request, Header, HTTPException
|
| 29 |
from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
|
|
@@ -364,16 +365,16 @@ async def init_db():
|
|
| 364 |
)
|
| 365 |
async with db_pool.acquire() as conn:
|
| 366 |
await conn.execute("""
|
| 367 |
-
CREATE TABLE IF NOT EXISTS
|
| 368 |
-
id
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
created_at TIMESTAMPTZ DEFAULT NOW()
|
| 375 |
);
|
| 376 |
-
CREATE INDEX IF NOT EXISTS
|
|
|
|
| 377 |
""")
|
| 378 |
except Exception as e:
|
| 379 |
print(f"[DB] Warning: Could not initialize database: {e}")
|
|
@@ -382,42 +383,43 @@ async def init_db():
|
|
| 382 |
|
| 383 |
async def save_message(session_id: str, role: str, content: str = None,
|
| 384 |
tool_calls: list = None, tool_call_id: str = None):
|
| 385 |
-
"""Save a message to the session store."""
|
| 386 |
if not db_pool:
|
| 387 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
try:
|
| 389 |
async with db_pool.acquire() as conn:
|
| 390 |
await conn.execute(
|
| 391 |
-
"INSERT INTO
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
|
|
|
| 395 |
)
|
| 396 |
except Exception as e:
|
| 397 |
print(f"[DB] Warning: Could not save message: {e}")
|
| 398 |
|
| 399 |
|
| 400 |
async def load_session(session_id: str) -> list:
|
| 401 |
-
"""Load conversation history from the session store."""
|
| 402 |
if not db_pool:
|
| 403 |
return []
|
| 404 |
try:
|
| 405 |
async with db_pool.acquire() as conn:
|
| 406 |
rows = await conn.fetch(
|
| 407 |
-
"SELECT
|
|
|
|
| 408 |
session_id,
|
|
|
|
| 409 |
)
|
| 410 |
-
|
| 411 |
-
for row in rows:
|
| 412 |
-
msg = {"role": row["role"]}
|
| 413 |
-
if row["content"]:
|
| 414 |
-
msg["content"] = row["content"]
|
| 415 |
-
if row["tool_calls"]:
|
| 416 |
-
msg["tool_calls"] = json.loads(row["tool_calls"])
|
| 417 |
-
if row["tool_call_id"]:
|
| 418 |
-
msg["tool_call_id"] = row["tool_call_id"]
|
| 419 |
-
messages.append(msg)
|
| 420 |
-
return messages
|
| 421 |
except Exception as e:
|
| 422 |
print(f"[DB] Warning: Could not load session: {e}")
|
| 423 |
return []
|
|
@@ -621,7 +623,8 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
|
|
| 621 |
if is_agentic:
|
| 622 |
kwargs["tools"] = TOOLS
|
| 623 |
kwargs["tool_choice"] = "auto"
|
| 624 |
-
|
|
|
|
| 625 |
content = response.choices[0].message.content or ""
|
| 626 |
await save_message(session_id, "assistant", content)
|
| 627 |
ACTIVE_SESSIONS.discard(session_id)
|
|
@@ -640,50 +643,51 @@ async def chat_completions(request: Request, authorization: str = Header(None)):
|
|
| 640 |
# Streaming + agentic loop
|
| 641 |
async def generate() -> AsyncIterator[str]:
|
| 642 |
nonlocal final_messages
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
async for chunk in await nim_client.chat.completions.create(**kwargs):
|
| 655 |
-
choice = chunk.choices[0] if chunk.choices else None
|
| 656 |
-
if not choice:
|
| 657 |
-
continue
|
| 658 |
-
delta = choice.delta
|
| 659 |
-
|
| 660 |
-
# Stream text content to client
|
| 661 |
-
if delta and delta.content:
|
| 662 |
-
full_content += delta.content
|
| 663 |
-
yield make_chunk(request_id, requested_model, delta.content)
|
| 664 |
-
|
| 665 |
-
# Collect tool calls
|
| 666 |
-
if delta and delta.tool_calls:
|
| 667 |
-
for tc in delta.tool_calls:
|
| 668 |
-
idx = tc.index
|
| 669 |
-
if idx not in tool_calls_raw:
|
| 670 |
-
tool_calls_raw[idx] = {
|
| 671 |
-
"id": tc.id or f"call_{uuid.uuid4().hex[:8]}",
|
| 672 |
-
"name": tc.function.name if tc.function and tc.function.name else "",
|
| 673 |
-
"arguments": ""
|
| 674 |
-
}
|
| 675 |
-
if tc.function and tc.function.name:
|
| 676 |
-
tool_calls_raw[idx]["name"] = tc.function.name
|
| 677 |
-
if tc.id:
|
| 678 |
-
tool_calls_raw[idx]["id"] = tc.id
|
| 679 |
-
if tc.function and tc.function.arguments:
|
| 680 |
-
tool_calls_raw[idx]["arguments"] += tc.function.arguments
|
| 681 |
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
|
| 688 |
# If no tool calls, we're done
|
| 689 |
if not tool_calls_raw:
|
|
@@ -1176,6 +1180,123 @@ Select a file from the sidebar explorer on the left to read its code contents in
|
|
| 1176 |
</html>
|
| 1177 |
"""
|
| 1178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1179 |
@app.get("/", response_class=HTMLResponse)
|
| 1180 |
async def dashboard():
|
| 1181 |
return HTMLResponse(content=DASHBOARD_HTML)
|
|
@@ -1346,6 +1467,8 @@ async def startup_event():
|
|
| 1346 |
threading.Thread(target=run_watchdog, daemon=True).start()
|
| 1347 |
# Start the db keep-alive loop on FastAPI event loop
|
| 1348 |
asyncio.create_task(db_heartbeat_loop())
|
|
|
|
|
|
|
| 1349 |
|
| 1350 |
|
| 1351 |
# ---------------------------------------------------------------------------
|
|
|
|
| 23 |
import re
|
| 24 |
import collections
|
| 25 |
from pathlib import Path
|
| 26 |
+
from typing import AsyncIterator, Optional, List, Dict, Any
|
| 27 |
+
from pydantic import BaseModel
|
| 28 |
|
| 29 |
from fastapi import FastAPI, Request, Header, HTTPException
|
| 30 |
from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
|
|
|
|
| 365 |
)
|
| 366 |
async with db_pool.acquire() as conn:
|
| 367 |
await conn.execute("""
|
| 368 |
+
CREATE TABLE IF NOT EXISTS agent_session_entries (
|
| 369 |
+
id BIGSERIAL PRIMARY KEY,
|
| 370 |
+
project_key TEXT NOT NULL,
|
| 371 |
+
session_id TEXT NOT NULL,
|
| 372 |
+
subpath TEXT,
|
| 373 |
+
entry JSONB NOT NULL,
|
| 374 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
|
|
| 375 |
);
|
| 376 |
+
CREATE INDEX IF NOT EXISTS idx_session_key ON agent_session_entries (project_key, session_id, subpath, id);
|
| 377 |
+
CREATE INDEX IF NOT EXISTS idx_project_session ON agent_session_entries (project_key, session_id);
|
| 378 |
""")
|
| 379 |
except Exception as e:
|
| 380 |
print(f"[DB] Warning: Could not initialize database: {e}")
|
|
|
|
| 383 |
|
| 384 |
async def save_message(session_id: str, role: str, content: str = None,
|
| 385 |
tool_calls: list = None, tool_call_id: str = None):
|
| 386 |
+
"""Save a message to the session store using the unified schema."""
|
| 387 |
if not db_pool:
|
| 388 |
return
|
| 389 |
+
msg = {"role": role}
|
| 390 |
+
if content is not None:
|
| 391 |
+
msg["content"] = content
|
| 392 |
+
if tool_calls:
|
| 393 |
+
msg["tool_calls"] = tool_calls
|
| 394 |
+
if tool_call_id:
|
| 395 |
+
msg["tool_call_id"] = tool_call_id
|
| 396 |
+
|
| 397 |
try:
|
| 398 |
async with db_pool.acquire() as conn:
|
| 399 |
await conn.execute(
|
| 400 |
+
"INSERT INTO agent_session_entries (project_key, session_id, subpath, entry) VALUES ($1, $2, $3, $4)",
|
| 401 |
+
"fastapi-completions",
|
| 402 |
+
session_id,
|
| 403 |
+
None,
|
| 404 |
+
json.dumps(msg)
|
| 405 |
)
|
| 406 |
except Exception as e:
|
| 407 |
print(f"[DB] Warning: Could not save message: {e}")
|
| 408 |
|
| 409 |
|
| 410 |
async def load_session(session_id: str) -> list:
|
| 411 |
+
"""Load conversation history from the session store using the unified schema."""
|
| 412 |
if not db_pool:
|
| 413 |
return []
|
| 414 |
try:
|
| 415 |
async with db_pool.acquire() as conn:
|
| 416 |
rows = await conn.fetch(
|
| 417 |
+
"SELECT entry FROM agent_session_entries WHERE project_key = $1 AND session_id = $2 AND subpath IS NOT DISTINCT FROM $3 ORDER BY id",
|
| 418 |
+
"fastapi-completions",
|
| 419 |
session_id,
|
| 420 |
+
None
|
| 421 |
)
|
| 422 |
+
return [json.loads(row["entry"]) for row in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
except Exception as e:
|
| 424 |
print(f"[DB] Warning: Could not load session: {e}")
|
| 425 |
return []
|
|
|
|
| 623 |
if is_agentic:
|
| 624 |
kwargs["tools"] = TOOLS
|
| 625 |
kwargs["tool_choice"] = "auto"
|
| 626 |
+
async with completions_semaphore:
|
| 627 |
+
response = await nim_client.chat.completions.create(**kwargs)
|
| 628 |
content = response.choices[0].message.content or ""
|
| 629 |
await save_message(session_id, "assistant", content)
|
| 630 |
ACTIVE_SESSIONS.discard(session_id)
|
|
|
|
| 643 |
# Streaming + agentic loop
|
| 644 |
async def generate() -> AsyncIterator[str]:
|
| 645 |
nonlocal final_messages
|
| 646 |
+
async with completions_semaphore:
|
| 647 |
+
try:
|
| 648 |
+
for round_num in range(MAX_TOOL_ROUNDS + 1):
|
| 649 |
+
kwargs = {"model": requested_model, "messages": final_messages, "stream": True}
|
| 650 |
+
if is_agentic:
|
| 651 |
+
kwargs["tools"] = TOOLS
|
| 652 |
+
kwargs["tool_choice"] = "auto"
|
| 653 |
+
|
| 654 |
+
# Collect streamed response
|
| 655 |
+
full_content = ""
|
| 656 |
+
tool_calls_raw = {} # index -> {id, name, arguments_str}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
|
| 658 |
+
async for chunk in await nim_client.chat.completions.create(**kwargs):
|
| 659 |
+
choice = chunk.choices[0] if chunk.choices else None
|
| 660 |
+
if not choice:
|
| 661 |
+
continue
|
| 662 |
+
delta = choice.delta
|
| 663 |
+
|
| 664 |
+
# Stream text content to client
|
| 665 |
+
if delta and delta.content:
|
| 666 |
+
full_content += delta.content
|
| 667 |
+
yield make_chunk(request_id, requested_model, delta.content)
|
| 668 |
+
|
| 669 |
+
# Collect tool calls
|
| 670 |
+
if delta and delta.tool_calls:
|
| 671 |
+
for tc in delta.tool_calls:
|
| 672 |
+
idx = tc.index
|
| 673 |
+
if idx not in tool_calls_raw:
|
| 674 |
+
tool_calls_raw[idx] = {
|
| 675 |
+
"id": tc.id or f"call_{uuid.uuid4().hex[:8]}",
|
| 676 |
+
"name": tc.function.name if tc.function and tc.function.name else "",
|
| 677 |
+
"arguments": ""
|
| 678 |
+
}
|
| 679 |
+
if tc.function and tc.function.name:
|
| 680 |
+
tool_calls_raw[idx]["name"] = tc.function.name
|
| 681 |
+
if tc.id:
|
| 682 |
+
tool_calls_raw[idx]["id"] = tc.id
|
| 683 |
+
if tc.function and tc.function.arguments:
|
| 684 |
+
tool_calls_raw[idx]["arguments"] += tc.function.arguments
|
| 685 |
+
|
| 686 |
+
# Check for finish
|
| 687 |
+
if choice.finish_reason == "stop":
|
| 688 |
+
break
|
| 689 |
+
if choice.finish_reason == "tool_calls":
|
| 690 |
+
break
|
| 691 |
|
| 692 |
# If no tool calls, we're done
|
| 693 |
if not tool_calls_raw:
|
|
|
|
| 1180 |
</html>
|
| 1181 |
"""
|
| 1182 |
|
| 1183 |
+
# ---------------------------------------------------------------------------
|
| 1184 |
+
# Concurrency Settings
|
| 1185 |
+
# ---------------------------------------------------------------------------
|
| 1186 |
+
completions_semaphore = asyncio.Semaphore(2)
|
| 1187 |
+
|
| 1188 |
+
|
| 1189 |
+
# ---------------------------------------------------------------------------
|
| 1190 |
+
# SessionStore API (Claude Agent SDK / Claude Code Compatible)
|
| 1191 |
+
# ---------------------------------------------------------------------------
|
| 1192 |
+
|
| 1193 |
+
class SessionAppendRequest(BaseModel):
|
| 1194 |
+
project_key: str
|
| 1195 |
+
session_id: str
|
| 1196 |
+
subpath: Optional[str] = None
|
| 1197 |
+
entries: List[Dict[str, Any]]
|
| 1198 |
+
|
| 1199 |
+
@app.post("/api/sessions/append")
|
| 1200 |
+
async def append_session_entries(req: SessionAppendRequest, authorization: str = Header(None)):
|
| 1201 |
+
auth(authorization)
|
| 1202 |
+
if not db_pool:
|
| 1203 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 1204 |
+
try:
|
| 1205 |
+
# Check for mirror_error and log/alert if present
|
| 1206 |
+
for e in req.entries:
|
| 1207 |
+
if e.get("type") == "system" and e.get("subtype") == "mirror_error":
|
| 1208 |
+
log_activity(f"[ALERT] Claude Agent SDK reported mirror_error: {e.get('message')}")
|
| 1209 |
+
|
| 1210 |
+
rows = [(req.project_key, req.session_id, req.subpath, json.dumps(e)) for e in req.entries]
|
| 1211 |
+
async with db_pool.acquire() as conn:
|
| 1212 |
+
await conn.executemany(
|
| 1213 |
+
"INSERT INTO agent_session_entries (project_key, session_id, subpath, entry) VALUES ($1, $2, $3, $4)",
|
| 1214 |
+
rows
|
| 1215 |
+
)
|
| 1216 |
+
return {"status": "success"}
|
| 1217 |
+
except Exception as e:
|
| 1218 |
+
log_activity(f"[SessionStore Error] Failed append: {e}")
|
| 1219 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1220 |
+
|
| 1221 |
+
class SessionLoadRequest(BaseModel):
|
| 1222 |
+
project_key: str
|
| 1223 |
+
session_id: str
|
| 1224 |
+
subpath: Optional[str] = None
|
| 1225 |
+
|
| 1226 |
+
@app.post("/api/sessions/load")
|
| 1227 |
+
async def load_session_entries(req: SessionLoadRequest, authorization: str = Header(None)):
|
| 1228 |
+
auth(authorization)
|
| 1229 |
+
if not db_pool:
|
| 1230 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 1231 |
+
try:
|
| 1232 |
+
async with db_pool.acquire() as conn:
|
| 1233 |
+
rows = await conn.fetch(
|
| 1234 |
+
"SELECT entry FROM agent_session_entries WHERE project_key=$1 AND session_id=$2 AND subpath IS NOT DISTINCT FROM $3 ORDER BY id",
|
| 1235 |
+
req.project_key, req.session_id, req.subpath
|
| 1236 |
+
)
|
| 1237 |
+
return {"entries": [json.loads(r["entry"]) for r in rows]}
|
| 1238 |
+
except Exception as e:
|
| 1239 |
+
log_activity(f"[SessionStore Error] Failed load: {e}")
|
| 1240 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1241 |
+
|
| 1242 |
+
class SessionListRequest(BaseModel):
|
| 1243 |
+
project_key: str
|
| 1244 |
+
|
| 1245 |
+
@app.post("/api/sessions/list")
|
| 1246 |
+
async def list_sessions(req: SessionListRequest, authorization: str = Header(None)):
|
| 1247 |
+
auth(authorization)
|
| 1248 |
+
if not db_pool:
|
| 1249 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 1250 |
+
try:
|
| 1251 |
+
async with db_pool.acquire() as conn:
|
| 1252 |
+
rows = await conn.fetch(
|
| 1253 |
+
"SELECT DISTINCT session_id FROM agent_session_entries WHERE project_key=$1",
|
| 1254 |
+
req.project_key
|
| 1255 |
+
)
|
| 1256 |
+
return {"sessions": [r["session_id"] for r in rows]}
|
| 1257 |
+
except Exception as e:
|
| 1258 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1259 |
+
|
| 1260 |
+
class SessionDeleteRequest(BaseModel):
|
| 1261 |
+
project_key: str
|
| 1262 |
+
session_id: str
|
| 1263 |
+
|
| 1264 |
+
@app.post("/api/sessions/delete")
|
| 1265 |
+
async def delete_session(req: SessionDeleteRequest, authorization: str = Header(None)):
|
| 1266 |
+
auth(authorization)
|
| 1267 |
+
if not db_pool:
|
| 1268 |
+
raise HTTPException(status_code=500, detail="Database not connected")
|
| 1269 |
+
try:
|
| 1270 |
+
async with db_pool.acquire() as conn:
|
| 1271 |
+
await conn.execute(
|
| 1272 |
+
"DELETE FROM agent_session_entries WHERE project_key=$1 AND session_id=$2",
|
| 1273 |
+
req.project_key, req.session_id
|
| 1274 |
+
)
|
| 1275 |
+
return {"status": "success"}
|
| 1276 |
+
except Exception as e:
|
| 1277 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1278 |
+
|
| 1279 |
+
|
| 1280 |
+
# ---------------------------------------------------------------------------
|
| 1281 |
+
# Database Retention & Heartbeat loops
|
| 1282 |
+
# ---------------------------------------------------------------------------
|
| 1283 |
+
|
| 1284 |
+
async def db_cleanup_loop():
|
| 1285 |
+
log_activity("Database Retention Cleanup task started")
|
| 1286 |
+
while True:
|
| 1287 |
+
try:
|
| 1288 |
+
if db_pool:
|
| 1289 |
+
async with db_pool.acquire() as conn:
|
| 1290 |
+
# Purge session entries older than 30 days
|
| 1291 |
+
result = await conn.execute(
|
| 1292 |
+
"DELETE FROM agent_session_entries WHERE created_at < NOW() - INTERVAL '30 days'"
|
| 1293 |
+
)
|
| 1294 |
+
log_activity(f"[Cleanup] Nightly retention sweep complete. Status: {result}")
|
| 1295 |
+
except Exception as e:
|
| 1296 |
+
log_activity(f"[Cleanup Warning] Failed to run retention cleanup: {e}")
|
| 1297 |
+
await asyncio.sleep(86400) # Every 24 hours
|
| 1298 |
+
|
| 1299 |
+
|
| 1300 |
@app.get("/", response_class=HTMLResponse)
|
| 1301 |
async def dashboard():
|
| 1302 |
return HTMLResponse(content=DASHBOARD_HTML)
|
|
|
|
| 1467 |
threading.Thread(target=run_watchdog, daemon=True).start()
|
| 1468 |
# Start the db keep-alive loop on FastAPI event loop
|
| 1469 |
asyncio.create_task(db_heartbeat_loop())
|
| 1470 |
+
# Start the db nightly retention cleanup loop
|
| 1471 |
+
asyncio.create_task(db_cleanup_loop())
|
| 1472 |
|
| 1473 |
|
| 1474 |
# ---------------------------------------------------------------------------
|