ltcai 0.1.30 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -184
- package/auto_setup.py +279 -55
- package/docs/CHANGELOG.md +69 -0
- package/knowledge_graph.py +1338 -3
- package/knowledge_graph_api.py +112 -0
- package/latticeai/__init__.py +1 -0
- package/latticeai/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/api/__init__.py +1 -0
- package/latticeai/api/__pycache__/admin.cpython-314.pyc +0 -0
- package/latticeai/api/__pycache__/auth.cpython-314.pyc +0 -0
- package/latticeai/api/admin.py +187 -0
- package/latticeai/api/auth.py +233 -0
- package/latticeai/core/__init__.py +1 -0
- package/latticeai/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/audit.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/security.cpython-314.pyc +0 -0
- package/latticeai/core/__pycache__/sessions.cpython-314.pyc +0 -0
- package/latticeai/core/audit.py +245 -0
- package/latticeai/core/security.py +131 -0
- package/latticeai/core/sessions.py +72 -0
- package/llm_router.py +13 -7
- package/local_knowledge_api.py +319 -0
- package/package.json +5 -2
- package/requirements.txt +2 -1
- package/server.py +290 -901
- package/static/graph.html +7 -2
- package/static/lattice-reference.css +220 -0
- package/static/scripts/graph.js +305 -4
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Audit logging, sensitivity analysis, and admin reporting."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
_history_lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
SENSITIVE_PATTERNS = [
|
|
15
|
+
{"key": "rrn", "label": "주민등록번호", "severity": "high", "pattern": r"\b\d{6}[- ]?[1-4]\d{6}\b"},
|
|
16
|
+
{"key": "card", "label": "카드번호", "severity": "high", "pattern": r"\b(?:\d[ -]?){13,19}\b"},
|
|
17
|
+
{"key": "account", "label": "계좌번호", "severity": "medium", "pattern": r"(?:계좌|account|bank).{0,12}\d[\d -]{8,24}"},
|
|
18
|
+
{"key": "password", "label": "비밀번호/인증정보", "severity": "high", "pattern": r"(?:password|passwd|비밀번호|암호|token|api[_ -]?key|secret)\s*[:=]\s*[^\s,;]{4,}"},
|
|
19
|
+
{"key": "email", "label": "이메일", "severity": "low", "pattern": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"},
|
|
20
|
+
{"key": "phone", "label": "전화번호", "severity": "medium", "pattern": r"\b(?:01[016789]|02|0[3-6][1-5])[- ]?\d{3,4}[- ]?\d{4}\b"},
|
|
21
|
+
{"key": "address", "label": "주소", "severity": "medium", "pattern": r"(?:[가-힣]+(?:시|도)\s*)?[가-힣]+(?:시|군|구)\s+[가-힣0-9\s-]+(?:로|길)\s*\d*"},
|
|
22
|
+
{"key": "health", "label": "건강/의료정보", "severity": "medium", "pattern": r"(?:진단|병명|처방|복용|수술|장애|임신|혈액형|알레르기|medical|diagnosis)"},
|
|
23
|
+
]
|
|
24
|
+
SEVERITY_SCORE = {"low": 1, "medium": 2, "high": 3}
|
|
25
|
+
AUDIT_DELETE_EVENTS = {"conversation_delete", "history_delete", "user_delete"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_audit_log(audit_file: Path) -> List[Dict]:
|
|
29
|
+
if not os.path.exists(audit_file):
|
|
30
|
+
return []
|
|
31
|
+
try:
|
|
32
|
+
with open(audit_file, "r", encoding="utf-8") as f:
|
|
33
|
+
data = json.load(f)
|
|
34
|
+
return data if isinstance(data, list) else []
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logging.warning("get_audit_log failed: %s", e)
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def append_audit_event(audit_file: Path, event_type: str, **payload) -> None:
|
|
41
|
+
try:
|
|
42
|
+
event = {
|
|
43
|
+
"event_type": event_type,
|
|
44
|
+
"timestamp": datetime.now().isoformat(),
|
|
45
|
+
**payload,
|
|
46
|
+
}
|
|
47
|
+
with _history_lock:
|
|
48
|
+
events = get_audit_log(audit_file)
|
|
49
|
+
events.append(event)
|
|
50
|
+
if len(events) > 5000:
|
|
51
|
+
events = events[-5000:]
|
|
52
|
+
tmp_path = str(audit_file) + ".tmp"
|
|
53
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
54
|
+
json.dump(events, f, ensure_ascii=False, indent=2)
|
|
55
|
+
os.replace(tmp_path, audit_file)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logging.warning("append_audit_event failed: %s", e)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def mask_sensitive_text(text: str, matches: List[Dict]) -> str:
|
|
61
|
+
masked = text
|
|
62
|
+
for item in sorted(matches, key=lambda m: m["start"], reverse=True):
|
|
63
|
+
value = masked[item["start"]:item["end"]]
|
|
64
|
+
if len(value) <= 4:
|
|
65
|
+
replacement = "*" * len(value)
|
|
66
|
+
else:
|
|
67
|
+
replacement = value[:2] + "*" * min(len(value) - 4, 12) + value[-2:]
|
|
68
|
+
masked = masked[:item["start"]] + replacement + masked[item["end"]:]
|
|
69
|
+
return masked
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def classify_sensitive_message(item: Dict, index: int) -> Dict:
|
|
73
|
+
content = str(item.get("content", ""))
|
|
74
|
+
found = []
|
|
75
|
+
seen: set = set()
|
|
76
|
+
for rule in SENSITIVE_PATTERNS:
|
|
77
|
+
for match in re.finditer(rule["pattern"], content, flags=re.IGNORECASE):
|
|
78
|
+
key = (rule["key"], match.start(), match.end())
|
|
79
|
+
if key in seen:
|
|
80
|
+
continue
|
|
81
|
+
seen.add(key)
|
|
82
|
+
found.append({
|
|
83
|
+
"type": rule["key"],
|
|
84
|
+
"label": rule["label"],
|
|
85
|
+
"severity": rule["severity"],
|
|
86
|
+
"start": match.start(),
|
|
87
|
+
"end": match.end(),
|
|
88
|
+
})
|
|
89
|
+
severity = "none"
|
|
90
|
+
if found:
|
|
91
|
+
severity = max(found, key=lambda m: SEVERITY_SCORE[m["severity"]])["severity"]
|
|
92
|
+
preview_text = content[:240]
|
|
93
|
+
preview_matches = [m for m in found if m["start"] < len(preview_text)]
|
|
94
|
+
return {
|
|
95
|
+
"index": index,
|
|
96
|
+
"role": item.get("role", ""),
|
|
97
|
+
"user_email": item.get("user_email"),
|
|
98
|
+
"user_nickname": item.get("user_nickname") or item.get("user_email") or "Unknown",
|
|
99
|
+
"timestamp": item.get("timestamp"),
|
|
100
|
+
"sensitivity": severity,
|
|
101
|
+
"labels": sorted({m["label"] for m in found}),
|
|
102
|
+
"risk_fields": found,
|
|
103
|
+
"compliance_fields": [] if found else ["민감정보 미검출"],
|
|
104
|
+
"preview": mask_sensitive_text(preview_text, preview_matches),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_sensitivity_report(history: List[Dict]) -> Dict:
|
|
109
|
+
items = [classify_sensitive_message(item, i) for i, item in enumerate(history)]
|
|
110
|
+
risky = [x for x in items if x["risk_fields"]]
|
|
111
|
+
compliant = [x for x in items if not x["risk_fields"]]
|
|
112
|
+
field_counts: Dict[str, int] = {}
|
|
113
|
+
user_counts: Dict[str, int] = {}
|
|
114
|
+
severity_counts = {"high": 0, "medium": 0, "low": 0, "none": len(compliant)}
|
|
115
|
+
for item in risky:
|
|
116
|
+
severity_counts[item["sensitivity"]] += 1
|
|
117
|
+
user_key = item.get("user_email") or item.get("user_nickname") or "Unknown"
|
|
118
|
+
user_counts[user_key] = user_counts.get(user_key, 0) + 1
|
|
119
|
+
for field in item["risk_fields"]:
|
|
120
|
+
field_counts[field["label"]] = field_counts.get(field["label"], 0) + 1
|
|
121
|
+
return {
|
|
122
|
+
"summary": {
|
|
123
|
+
"total_messages": len(items),
|
|
124
|
+
"risky_messages": len(risky),
|
|
125
|
+
"compliant_messages": len(compliant),
|
|
126
|
+
"risk_rate": round((len(risky) / len(items)) * 100, 1) if items else 0,
|
|
127
|
+
"severity_counts": severity_counts,
|
|
128
|
+
"field_counts": field_counts,
|
|
129
|
+
"user_counts": user_counts,
|
|
130
|
+
},
|
|
131
|
+
"risk_fields": risky[-30:],
|
|
132
|
+
"compliance_fields": compliant[-30:],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_admin_audit_report(
|
|
137
|
+
audit_file: Path,
|
|
138
|
+
users: Dict,
|
|
139
|
+
*,
|
|
140
|
+
get_user_role: Callable[[str, Optional[Dict]], str],
|
|
141
|
+
graph_stats: Optional[Dict] = None,
|
|
142
|
+
) -> Dict:
|
|
143
|
+
events = get_audit_log(audit_file)
|
|
144
|
+
|
|
145
|
+
def _user_bucket(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
146
|
+
user = users.get(email or "", {})
|
|
147
|
+
return {
|
|
148
|
+
"email": email or "Unknown",
|
|
149
|
+
"nickname": nickname or user.get("nickname") or user.get("name") or email or "Unknown",
|
|
150
|
+
"role": get_user_role(email, users) if email else "unknown",
|
|
151
|
+
"disabled": bool(user.get("disabled")) if user else False,
|
|
152
|
+
"user_messages": 0, "assistant_messages": 0, "document_uploads": 0,
|
|
153
|
+
"clear_events": 0, "delete_events": 0, "sensitive_events": 0,
|
|
154
|
+
"high_sensitive_events": 0, "total_content_chars": 0, "last_activity_at": None,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
per_user: Dict[str, Dict] = {}
|
|
158
|
+
|
|
159
|
+
def ensure(email: Optional[str], nickname: Optional[str] = None) -> Dict:
|
|
160
|
+
key = email or nickname or "Unknown"
|
|
161
|
+
if key not in per_user:
|
|
162
|
+
per_user[key] = _user_bucket(email, nickname)
|
|
163
|
+
elif nickname and per_user[key].get("nickname") in {"Unknown", email, None}:
|
|
164
|
+
per_user[key]["nickname"] = nickname
|
|
165
|
+
return per_user[key]
|
|
166
|
+
|
|
167
|
+
for email, user in users.items():
|
|
168
|
+
ensure(email, user.get("nickname") or user.get("name"))
|
|
169
|
+
|
|
170
|
+
summary: Dict[str, Any] = {
|
|
171
|
+
"total_events": len(events), "chat_events": 0, "user_messages": 0,
|
|
172
|
+
"assistant_messages": 0, "document_uploads": 0, "clear_events": 0,
|
|
173
|
+
"delete_events": 0, "sensitive_events": 0, "high_sensitive_events": 0,
|
|
174
|
+
}
|
|
175
|
+
sensitive_events: List[Dict] = []
|
|
176
|
+
deletion_events: List[Dict] = []
|
|
177
|
+
|
|
178
|
+
for event in events:
|
|
179
|
+
event_type = event.get("event_type")
|
|
180
|
+
email = event.get("user_email")
|
|
181
|
+
u = ensure(email, event.get("user_nickname"))
|
|
182
|
+
ts = event.get("timestamp")
|
|
183
|
+
if ts and (not u["last_activity_at"] or ts > u["last_activity_at"]):
|
|
184
|
+
u["last_activity_at"] = ts
|
|
185
|
+
u["total_content_chars"] += int(event.get("content_chars") or event.get("extracted_chars") or 0)
|
|
186
|
+
sensitivity = event.get("sensitivity") or "none"
|
|
187
|
+
labels = event.get("sensitive_labels") or []
|
|
188
|
+
is_sensitive = sensitivity != "none" or bool(labels)
|
|
189
|
+
|
|
190
|
+
if event_type == "chat_message":
|
|
191
|
+
summary["chat_events"] += 1
|
|
192
|
+
if event.get("role") == "user":
|
|
193
|
+
summary["user_messages"] += 1
|
|
194
|
+
u["user_messages"] += 1
|
|
195
|
+
elif event.get("role") == "assistant":
|
|
196
|
+
summary["assistant_messages"] += 1
|
|
197
|
+
u["assistant_messages"] += 1
|
|
198
|
+
elif event_type == "document_upload":
|
|
199
|
+
summary["document_uploads"] += 1
|
|
200
|
+
u["document_uploads"] += 1
|
|
201
|
+
elif event_type == "clear_command":
|
|
202
|
+
summary["clear_events"] += 1
|
|
203
|
+
u["clear_events"] += 1
|
|
204
|
+
elif event_type in AUDIT_DELETE_EVENTS:
|
|
205
|
+
summary["delete_events"] += 1
|
|
206
|
+
u["delete_events"] += 1
|
|
207
|
+
deletion_events.append(_public_audit_event(event))
|
|
208
|
+
|
|
209
|
+
if is_sensitive:
|
|
210
|
+
summary["sensitive_events"] += 1
|
|
211
|
+
u["sensitive_events"] += 1
|
|
212
|
+
if sensitivity == "high":
|
|
213
|
+
summary["high_sensitive_events"] += 1
|
|
214
|
+
u["high_sensitive_events"] += 1
|
|
215
|
+
sensitive_events.append(_public_audit_event(event))
|
|
216
|
+
|
|
217
|
+
allowed_keys = {
|
|
218
|
+
"event_type", "timestamp", "role", "user_email", "user_nickname", "source",
|
|
219
|
+
"conversation_id", "command", "scope", "target_email", "filename", "mime_type",
|
|
220
|
+
"ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
|
|
221
|
+
"started_at", "sensitivity", "sensitive_labels", "content_preview", "content_chars",
|
|
222
|
+
}
|
|
223
|
+
recent = [_public_audit_event(e) for e in events[-50:]]
|
|
224
|
+
|
|
225
|
+
result: Dict[str, Any] = {
|
|
226
|
+
"summary": summary,
|
|
227
|
+
"per_user": sorted(per_user.values(), key=lambda u: u.get("last_activity_at") or "", reverse=True),
|
|
228
|
+
"recent_events": list(reversed(recent)),
|
|
229
|
+
"sensitive_events": sensitive_events[-30:],
|
|
230
|
+
"deletion_events": deletion_events[-30:],
|
|
231
|
+
}
|
|
232
|
+
if graph_stats:
|
|
233
|
+
result["summary"]["graph_nodes"] = graph_stats.get("total_nodes", 0)
|
|
234
|
+
result["summary"]["graph_edges"] = graph_stats.get("total_edges", 0)
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _public_audit_event(event: Dict) -> Dict:
|
|
239
|
+
allowed = {
|
|
240
|
+
"event_type", "timestamp", "role", "user_email", "user_nickname", "source",
|
|
241
|
+
"conversation_id", "command", "scope", "target_email", "filename", "mime_type",
|
|
242
|
+
"ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
|
|
243
|
+
"started_at", "sensitivity", "sensitive_labels", "content_preview", "content_chars",
|
|
244
|
+
}
|
|
245
|
+
return {k: event.get(k) for k in allowed if k in event}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Password hashing, rate limiting, IP detection, file-magic validation."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import HTTPException
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hash_password(password: str) -> str:
|
|
15
|
+
salt = secrets.token_hex(16)
|
|
16
|
+
key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
|
|
17
|
+
return f"{salt}:{key.hex()}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verify_password(password: str, hashed: str) -> bool:
|
|
21
|
+
try:
|
|
22
|
+
salt, key_hex = hashed.split(":", 1)
|
|
23
|
+
key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
|
|
24
|
+
return secrets.compare_digest(key.hex(), key_hex)
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def host_is_loopback(host: str) -> bool:
|
|
30
|
+
if host in {"localhost", "127.0.0.1", "::1"}:
|
|
31
|
+
return True
|
|
32
|
+
try:
|
|
33
|
+
return ipaddress.ip_address(host).is_loopback
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def client_ip(request) -> str:
|
|
39
|
+
for header in ("CF-Connecting-IP", "X-Forwarded-For"):
|
|
40
|
+
val = request.headers.get(header)
|
|
41
|
+
if val:
|
|
42
|
+
return val.split(",")[0].strip()
|
|
43
|
+
return request.client.host if request.client else "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_FILE_MAGIC: Dict[str, List[bytes]] = {
|
|
47
|
+
".pdf": [b"%PDF-"],
|
|
48
|
+
".docx": [b"PK\x03\x04"],
|
|
49
|
+
".xlsx": [b"PK\x03\x04"],
|
|
50
|
+
".pptx": [b"PK\x03\x04"],
|
|
51
|
+
".zip": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
|
52
|
+
".png": [b"\x89PNG\r\n\x1a\n"],
|
|
53
|
+
".jpg": [b"\xff\xd8\xff"],
|
|
54
|
+
".jpeg": [b"\xff\xd8\xff"],
|
|
55
|
+
".gif": [b"GIF87a", b"GIF89a"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bytes_match_extension(data: bytes, ext: str) -> bool:
|
|
60
|
+
ext = (ext or "").lower()
|
|
61
|
+
signatures = _FILE_MAGIC.get(ext)
|
|
62
|
+
if not signatures:
|
|
63
|
+
return True
|
|
64
|
+
head = data[:16]
|
|
65
|
+
return any(head.startswith(sig) for sig in signatures)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def redact_secret_text(text: str) -> str:
|
|
69
|
+
if not text:
|
|
70
|
+
return ""
|
|
71
|
+
patterns = [
|
|
72
|
+
r"(?i)(api[_ -]?key|secret|token|password|passwd)\s*[:=]\s*['\"]?([A-Za-z0-9_\-\.]{12,})['\"]?",
|
|
73
|
+
r"\b(sk-[A-Za-z0-9_\-]{16,})\b",
|
|
74
|
+
r"\b(xai-[A-Za-z0-9_\-]{16,})\b",
|
|
75
|
+
r"\b(gsk_[A-Za-z0-9_\-]{16,})\b",
|
|
76
|
+
]
|
|
77
|
+
redacted = str(text)
|
|
78
|
+
for pattern in patterns:
|
|
79
|
+
redacted = re.sub(pattern, lambda m: f"{m.group(1)}=[REDACTED]" if len(m.groups()) > 1 else "[REDACTED]", redacted)
|
|
80
|
+
return redacted
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── IP-based rate limiting (registration / login) ────────────────────────────
|
|
84
|
+
_ip_rate_windows: dict = {}
|
|
85
|
+
_ip_rate_lock = threading.Lock()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_ip_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
|
|
89
|
+
key = (ip, action)
|
|
90
|
+
now = time.time()
|
|
91
|
+
cutoff = now - window_secs
|
|
92
|
+
with _ip_rate_lock:
|
|
93
|
+
calls = [t for t in _ip_rate_windows.get(key, []) if t > cutoff]
|
|
94
|
+
if len(calls) >= max_calls:
|
|
95
|
+
raise HTTPException(status_code=429, detail="요청이 너무 많습니다. 잠시 후 다시 시도하세요.")
|
|
96
|
+
calls.append(now)
|
|
97
|
+
_ip_rate_windows[key] = calls
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Per-user token-bucket rate limiting ──────────────────────────────────────
|
|
101
|
+
_RATE_LIMITS = {
|
|
102
|
+
"chat": (30, 0.5),
|
|
103
|
+
"agent": (10, 0.1),
|
|
104
|
+
"upload": (20, 0.2),
|
|
105
|
+
}
|
|
106
|
+
_rate_buckets: Dict[str, Dict[str, float]] = {}
|
|
107
|
+
_user_rate_lock = threading.Lock()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def enforce_rate_limit(email: str, bucket_key: str, *, enabled: bool = True) -> None:
|
|
111
|
+
if not enabled or not email:
|
|
112
|
+
return
|
|
113
|
+
cap, refill = _RATE_LIMITS.get(bucket_key, (60, 1.0))
|
|
114
|
+
key = f"{email}:{bucket_key}"
|
|
115
|
+
now = time.time()
|
|
116
|
+
with _user_rate_lock:
|
|
117
|
+
bucket = _rate_buckets.get(key)
|
|
118
|
+
if bucket is None:
|
|
119
|
+
_rate_buckets[key] = {"tokens": cap - 1, "ts": now}
|
|
120
|
+
return
|
|
121
|
+
elapsed = now - bucket["ts"]
|
|
122
|
+
bucket["tokens"] = min(cap, bucket["tokens"] + elapsed * refill)
|
|
123
|
+
bucket["ts"] = now
|
|
124
|
+
if bucket["tokens"] < 1:
|
|
125
|
+
retry_after = max(1, int((1 - bucket["tokens"]) / refill))
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=429,
|
|
128
|
+
detail=f"Rate limit exceeded for {bucket_key}. Retry after {retry_after}s.",
|
|
129
|
+
headers={"Retry-After": str(retry_after)},
|
|
130
|
+
)
|
|
131
|
+
bucket["tokens"] -= 1
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""File-backed session store with sliding-window TTL."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
SESSION_TTL = 60 * 60 * 24 # 24 hours
|
|
13
|
+
SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
|
|
14
|
+
_lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sessions_file(data_dir: Optional[Path] = None) -> Path:
|
|
18
|
+
d = data_dir or Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
|
|
19
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return d / "sessions.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_sessions(data_dir: Optional[Path] = None) -> Dict[str, tuple]:
|
|
24
|
+
try:
|
|
25
|
+
f = _sessions_file(data_dir)
|
|
26
|
+
if f.exists():
|
|
27
|
+
raw = json.loads(f.read_text())
|
|
28
|
+
return {k: tuple(v) for k, v in raw.items()}
|
|
29
|
+
except Exception as e:
|
|
30
|
+
logging.warning("load_sessions failed (starting empty): %s", e)
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None) -> None:
|
|
35
|
+
try:
|
|
36
|
+
_sessions_file(data_dir).write_text(json.dumps({k: list(v) for k, v in sessions.items()}, ensure_ascii=False))
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logging.warning("persist_sessions failed: %s", e)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SessionStore:
|
|
42
|
+
def __init__(self, data_dir: Optional[Path] = None):
|
|
43
|
+
self._data_dir = data_dir
|
|
44
|
+
self._sessions: Dict[str, tuple] = load_sessions(data_dir)
|
|
45
|
+
|
|
46
|
+
def create(self, email: str) -> str:
|
|
47
|
+
token = secrets.token_urlsafe(32)
|
|
48
|
+
with _lock:
|
|
49
|
+
self._sessions[token] = (email, time.time())
|
|
50
|
+
persist_sessions(self._sessions, self._data_dir)
|
|
51
|
+
return token
|
|
52
|
+
|
|
53
|
+
def get_email(self, token: str) -> Optional[str]:
|
|
54
|
+
now = time.time()
|
|
55
|
+
with _lock:
|
|
56
|
+
entry = self._sessions.get(token)
|
|
57
|
+
if entry is None:
|
|
58
|
+
return None
|
|
59
|
+
email, created_at = entry
|
|
60
|
+
if now - created_at > SESSION_TTL:
|
|
61
|
+
self._sessions.pop(token, None)
|
|
62
|
+
persist_sessions(self._sessions, self._data_dir)
|
|
63
|
+
return None
|
|
64
|
+
if now - created_at > SESSION_REFRESH_THRESHOLD:
|
|
65
|
+
self._sessions[token] = (email, now)
|
|
66
|
+
persist_sessions(self._sessions, self._data_dir)
|
|
67
|
+
return email
|
|
68
|
+
|
|
69
|
+
def invalidate(self, token: str) -> None:
|
|
70
|
+
with _lock:
|
|
71
|
+
self._sessions.pop(token, None)
|
|
72
|
+
persist_sessions(self._sessions, self._data_dir)
|
package/llm_router.py
CHANGED
|
@@ -100,7 +100,7 @@ OPENAI_COMPATIBLE_PROVIDERS = {
|
|
|
100
100
|
"env_key": "VLLM_API_KEY",
|
|
101
101
|
"base_url_env": "VLLM_BASE_URL",
|
|
102
102
|
"base_url": "http://localhost:8000/v1",
|
|
103
|
-
"default_model": "
|
|
103
|
+
"default_model": "meta-llama/Llama-3.1-8B-Instruct",
|
|
104
104
|
"api_key_fallback": "vllm",
|
|
105
105
|
},
|
|
106
106
|
"lmstudio": {
|
|
@@ -121,29 +121,35 @@ OPENAI_COMPATIBLE_PROVIDERS = {
|
|
|
121
121
|
|
|
122
122
|
PROVIDER_MODEL_CATALOG = {
|
|
123
123
|
"openai": [
|
|
124
|
+
{"id": "gpt-5.5", "name": "GPT-5.5", "family": "GPT"},
|
|
125
|
+
{"id": "gpt-5.4", "name": "GPT-5.4", "family": "GPT"},
|
|
126
|
+
{"id": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "family": "GPT"},
|
|
127
|
+
{"id": "gpt-5.4-nano", "name": "GPT-5.4 Nano", "family": "GPT"},
|
|
124
128
|
{"id": "gpt-4o-mini", "name": "GPT-4o Mini", "family": "GPT"},
|
|
125
129
|
{"id": "gpt-4o", "name": "GPT-4o", "family": "GPT"},
|
|
126
130
|
{"id": "gpt-4.1-mini", "name": "GPT-4.1 Mini", "family": "GPT"},
|
|
127
131
|
{"id": "gpt-4.1", "name": "GPT-4.1", "family": "GPT"},
|
|
128
132
|
],
|
|
129
133
|
"openrouter": [
|
|
134
|
+
{"id": "openai/gpt-5.5", "name": "GPT-5.5 via OpenRouter", "family": "GPT"},
|
|
130
135
|
{"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini via OpenRouter", "family": "GPT"},
|
|
131
|
-
{"id": "anthropic/claude-
|
|
132
|
-
{"id": "anthropic/claude-
|
|
136
|
+
{"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
|
|
137
|
+
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
|
|
138
|
+
{"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
|
|
139
|
+
{"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
|
|
140
|
+
{"id": "qwen/qwen3-coder", "name": "Qwen3 Coder via OpenRouter", "family": "Qwen"},
|
|
133
141
|
{"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
|
|
134
142
|
{"id": "meta-llama/llama-3.3-70b-instruct", "name": "Llama 3.3 70B via OpenRouter", "family": "Llama"},
|
|
135
|
-
{"id": "qwen/qwen-2.5-72b-instruct", "name": "Qwen 2.5 72B via OpenRouter", "family": "Qwen"},
|
|
136
143
|
{"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
|
|
137
144
|
],
|
|
138
145
|
"groq": [
|
|
146
|
+
{"id": "qwen/qwen3-32b", "name": "Qwen3 32B", "family": "Qwen"},
|
|
139
147
|
{"id": "llama-3.1-8b-instant", "name": "Llama 3.1 8B Instant", "family": "Llama"},
|
|
140
148
|
{"id": "llama-3.3-70b-versatile", "name": "Llama 3.3 70B Versatile", "family": "Llama"},
|
|
141
|
-
{"id": "qwen-qwq-32b", "name": "Qwen QwQ 32B", "family": "Qwen"},
|
|
142
149
|
],
|
|
143
150
|
"together": [
|
|
151
|
+
{"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
|
|
144
152
|
{"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "name": "Llama 3.3 70B Turbo", "family": "Llama"},
|
|
145
|
-
{"id": "Qwen/Qwen2.5-72B-Instruct-Turbo", "name": "Qwen 2.5 72B Turbo", "family": "Qwen"},
|
|
146
|
-
{"id": "deepseek-ai/DeepSeek-R1", "name": "DeepSeek R1", "family": "DeepSeek"},
|
|
147
153
|
{"id": "mistralai/Mixtral-8x22B-Instruct-v0.1", "name": "Mixtral 8x22B", "family": "Mistral"},
|
|
148
154
|
],
|
|
149
155
|
"xai": [
|