meshctx-ctxkit 0.1.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.
- meshctx_ctxkit/__init__.py +17 -0
- meshctx_ctxkit/__main__.py +72 -0
- meshctx_ctxkit/consensus.py +314 -0
- meshctx_ctxkit/license.py +179 -0
- meshctx_ctxkit/memory.py +208 -0
- meshctx_ctxkit/py.typed +0 -0
- meshctx_ctxkit/server.py +241 -0
- meshctx_ctxkit/usage.py +70 -0
- meshctx_ctxkit-0.1.0.dist-info/METADATA +282 -0
- meshctx_ctxkit-0.1.0.dist-info/RECORD +14 -0
- meshctx_ctxkit-0.1.0.dist-info/WHEEL +5 -0
- meshctx_ctxkit-0.1.0.dist-info/entry_points.txt +3 -0
- meshctx_ctxkit-0.1.0.dist-info/licenses/LICENSE +661 -0
- meshctx_ctxkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ctxkit — local-first context kit for agents.
|
|
2
|
+
|
|
3
|
+
Two products in one MCP server:
|
|
4
|
+
|
|
5
|
+
1. ``memory`` — cross-session memory on local disk. Survives restarts,
|
|
6
|
+
never leaves the machine. No account, no cloud.
|
|
7
|
+
2. ``consensus`` — the same question asked across multiple models, then
|
|
8
|
+
scored for agreement. Catches hallucinations that a single model
|
|
9
|
+
would confidently hand you.
|
|
10
|
+
|
|
11
|
+
Everything degrades gracefully: with no API key and no browser it still
|
|
12
|
+
runs (memory works fully offline; consensus returns a clear, actionable
|
|
13
|
+
error instead of crashing).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CLI: ``ctxkit`` command line interface.
|
|
2
|
+
|
|
3
|
+
ctxkit add "fact text" --tag project/alpha
|
|
4
|
+
ctxkit search "query" [--limit 5]
|
|
5
|
+
ctxkit stats
|
|
6
|
+
ctxkit verify "text to check"
|
|
7
|
+
ctxkit serve # run the MCP stdio server
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .memory import MemoryStore
|
|
16
|
+
from .consensus import run_offline_checks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
ap = argparse.ArgumentParser(prog="ctxkit",
|
|
21
|
+
description="local-first memory + verification for agents")
|
|
22
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
23
|
+
|
|
24
|
+
p_add = sub.add_parser("add", help="store a memory")
|
|
25
|
+
p_add.add_argument("text")
|
|
26
|
+
p_add.add_argument("--tag", action="append", default=[])
|
|
27
|
+
|
|
28
|
+
p_search = sub.add_parser("search", help="search memories")
|
|
29
|
+
p_search.add_argument("query")
|
|
30
|
+
p_search.add_argument("--limit", type=int, default=5)
|
|
31
|
+
|
|
32
|
+
sub.add_parser("stats", help="store stats")
|
|
33
|
+
|
|
34
|
+
p_verify = sub.add_parser("verify", help="offline sanity checks on text")
|
|
35
|
+
p_verify.add_argument("text")
|
|
36
|
+
|
|
37
|
+
p_lic = sub.add_parser("license", help="show license tier, expiry and usage")
|
|
38
|
+
p_lic.add_argument("--key", help="verify a specific license token instead of the stored one")
|
|
39
|
+
|
|
40
|
+
sub.add_parser("serve", help="run MCP stdio server")
|
|
41
|
+
|
|
42
|
+
args = ap.parse_args(argv)
|
|
43
|
+
if args.cmd == "serve":
|
|
44
|
+
from .server import main as serve_main
|
|
45
|
+
serve_main()
|
|
46
|
+
return 0
|
|
47
|
+
store = MemoryStore()
|
|
48
|
+
if args.cmd == "license":
|
|
49
|
+
from .license import load_license_from_env, verify_license
|
|
50
|
+
from .usage import UsageMeter
|
|
51
|
+
lic = verify_license(args.key) if args.key else load_license_from_env()
|
|
52
|
+
out = lic.to_dict()
|
|
53
|
+
out["usage"] = UsageMeter(store.path.parent).snapshot()
|
|
54
|
+
out["free_tools"] = ["ctx_remember", "ctx_recall", "ctx_forget",
|
|
55
|
+
"ctx_stats", "ctx_verify_text"]
|
|
56
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
57
|
+
return 0
|
|
58
|
+
if args.cmd == "add":
|
|
59
|
+
mem = store.remember(args.text, tags=args.tag)
|
|
60
|
+
print(json.dumps({"ok": True, "id": mem.id}, ensure_ascii=False))
|
|
61
|
+
elif args.cmd == "search":
|
|
62
|
+
print(json.dumps({"hits": store.recall(args.query, args.limit)},
|
|
63
|
+
ensure_ascii=False, indent=2))
|
|
64
|
+
elif args.cmd == "stats":
|
|
65
|
+
print(json.dumps(store.stats(), ensure_ascii=False, indent=2))
|
|
66
|
+
elif args.cmd == "verify":
|
|
67
|
+
print(json.dumps(run_offline_checks(args.text), ensure_ascii=False, indent=2))
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Verification & multi-model consensus for ctxkit.
|
|
2
|
+
|
|
3
|
+
Two layers:
|
|
4
|
+
|
|
5
|
+
``run_offline_checks(text)``
|
|
6
|
+
Pure-stdlib sanity checks that catch the most common LLM failure
|
|
7
|
+
modes without any network: broken arithmetic, impossible dates,
|
|
8
|
+
unreachable-looking URLs, and unqualified absolute claims.
|
|
9
|
+
|
|
10
|
+
``multi_model_consensus(question, ...)``
|
|
11
|
+
Ask the same question to N OpenAI-compatible endpoints, extract
|
|
12
|
+
each model's key claims, and score cross-model agreement. This is
|
|
13
|
+
the paid/hosted path (``ctxkit[api]`` + ``CTXKIT_API_KEY``); it is
|
|
14
|
+
optional and the server never requires it.
|
|
15
|
+
|
|
16
|
+
The offline layer is the free product core; the consensus layer is the
|
|
17
|
+
commercial add-on. Both are honest about what they cannot do: they flag
|
|
18
|
+
suspicion, they do not prove truth.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import statistics
|
|
26
|
+
from dataclasses import dataclass, field, asdict
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
# ── offline checks ─────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
_NUM = r"\d[\d,]*(?:\.\d+)?"
|
|
32
|
+
_ARITH_RE = re.compile(
|
|
33
|
+
rf"({_NUM})\s*([+\-*/×x÷])\s*({_NUM})\s*=\s*({_NUM})", re.IGNORECASE
|
|
34
|
+
)
|
|
35
|
+
_WORD_ARITH_RE = re.compile(
|
|
36
|
+
r"({num})\s*(plus|minus|times|multiplied by|divided by)\s*({num})\s*"
|
|
37
|
+
r"(?:is|equals|=)\s*({num})".format(num=_NUM),
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
_PCT_RE = re.compile(
|
|
41
|
+
rf"({_NUM})\s*%\s*of\s*({_NUM})\s*(?:is|equals|=)\s*({_NUM})", re.IGNORECASE
|
|
42
|
+
)
|
|
43
|
+
_YEAR_RE = re.compile(r"\b((?:18|19|20|21)\d{2})\b")
|
|
44
|
+
_MD_DATE_RE = re.compile(
|
|
45
|
+
r"\b(January|February|March|April|May|June|July|August|September|October|"
|
|
46
|
+
r"November|December)\s+(\d{1,2})(?:st|nd|rd|th)?\b,?\s*((?:18|19|20|21)\d{2})?",
|
|
47
|
+
re.IGNORECASE,
|
|
48
|
+
)
|
|
49
|
+
_URL_RE = re.compile(r"https?://[^\s<>\"')\]]+", re.IGNORECASE)
|
|
50
|
+
_ABSOLUTE_RE = re.compile(
|
|
51
|
+
r"\b(always|never|100%|guaranteed|impossible|everyone|nobody|"
|
|
52
|
+
r"all users|zero risk|risk[- ]free|completely safe)\b",
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
_MONTH_DAYS = {
|
|
56
|
+
"january": 31, "february": 29, "march": 31, "april": 30, "may": 31,
|
|
57
|
+
"june": 30, "july": 31, "august": 31, "september": 30, "october": 31,
|
|
58
|
+
"november": 30, "december": 31,
|
|
59
|
+
}
|
|
60
|
+
_OPS = {"+": 1, "-": 1, "*": 1, "/": 1, "×": 1, "x": 1, "÷": 1,
|
|
61
|
+
"plus": 1, "minus": 1, "times": 1, "multiplied by": 1, "divided by": 1}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _num(s: str) -> float:
|
|
65
|
+
return float(s.replace(",", ""))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fmt(x: float) -> str:
|
|
69
|
+
return f"{x:.6f}".rstrip("0").rstrip(".")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CheckResult:
|
|
74
|
+
name: str
|
|
75
|
+
passed: bool
|
|
76
|
+
detail: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def arithmetic_check(text: str) -> list[CheckResult]:
|
|
80
|
+
"""Recompute every ``a op b = c`` pattern stated in the text."""
|
|
81
|
+
out: list[CheckResult] = []
|
|
82
|
+
for a, op, b, c in _ARITH_RE.findall(text):
|
|
83
|
+
try:
|
|
84
|
+
x, y, z = _num(a), _num(b), _num(c)
|
|
85
|
+
got = {"+": x + y, "-": x - y, "*": x * y, "×": x * y, "x": x * y,
|
|
86
|
+
"/": x / y if y else float("inf"), "÷": x / y if y else float("inf")}[op.lower() if op.lower() in ("x",) else op]
|
|
87
|
+
except Exception:
|
|
88
|
+
continue
|
|
89
|
+
ok = abs(got - z) < 1e-6 * max(1.0, abs(z))
|
|
90
|
+
out.append(CheckResult(
|
|
91
|
+
"arithmetic", ok,
|
|
92
|
+
f'"{a} {op} {b} = {c}" — computed {_fmt(got)}'
|
|
93
|
+
+ ("" if ok else f", text says {c}")))
|
|
94
|
+
for a, op, b, c in _WORD_ARITH_RE.findall(text):
|
|
95
|
+
try:
|
|
96
|
+
x, y, z = _num(a), _num(b), _num(c)
|
|
97
|
+
got = {"plus": x + y, "minus": x - y, "times": x * y,
|
|
98
|
+
"multiplied by": x * y,
|
|
99
|
+
"divided by": x / y if y else float("inf")}[op.lower()]
|
|
100
|
+
except Exception:
|
|
101
|
+
continue
|
|
102
|
+
ok = abs(got - z) < 1e-6 * max(1.0, abs(z))
|
|
103
|
+
out.append(CheckResult(
|
|
104
|
+
"arithmetic", ok,
|
|
105
|
+
f'"{a} {op} {b} = {c}" — computed {_fmt(got)}'
|
|
106
|
+
+ ("" if ok else f", text says {c}")))
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def percent_check(text: str) -> list[CheckResult]:
|
|
111
|
+
out: list[CheckResult] = []
|
|
112
|
+
for p, base, claimed in _PCT_RE.findall(text):
|
|
113
|
+
try:
|
|
114
|
+
got = _num(base) * _num(p) / 100.0
|
|
115
|
+
except Exception:
|
|
116
|
+
continue
|
|
117
|
+
ok = abs(got - _num(claimed)) < 1e-6 * max(1.0, abs(got))
|
|
118
|
+
out.append(CheckResult(
|
|
119
|
+
"percent", ok,
|
|
120
|
+
f'"{p}% of {base} = {claimed}" — computed {_fmt(got)}'
|
|
121
|
+
+ ("" if ok else f", text says {claimed}")))
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def date_check(text: str, *, now_year: int | None = None) -> list[CheckResult]:
|
|
126
|
+
import datetime
|
|
127
|
+
this_year = now_year or datetime.date.today().year
|
|
128
|
+
out: list[CheckResult] = []
|
|
129
|
+
for year in _YEAR_RE.findall(text):
|
|
130
|
+
y = int(year)
|
|
131
|
+
if y > this_year + 2:
|
|
132
|
+
out.append(CheckResult(
|
|
133
|
+
"date", False,
|
|
134
|
+
f"year {y} is more than 2 years in the future "
|
|
135
|
+
f"(current {this_year}) — likely a hallucinated date"))
|
|
136
|
+
for month, day, year in _MD_DATE_RE.findall(text):
|
|
137
|
+
d = int(day)
|
|
138
|
+
max_d = _MONTH_DAYS[month.lower()]
|
|
139
|
+
if d > max_d:
|
|
140
|
+
out.append(CheckResult(
|
|
141
|
+
"date", False,
|
|
142
|
+
f'"{month} {day}" does not exist ({month} has {max_d} days)'))
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def absolute_claim_check(text: str) -> list[CheckResult]:
|
|
147
|
+
hits = sorted({m.group(0).lower() for m in _ABSOLUTE_RE.finditer(text)})
|
|
148
|
+
if not hits:
|
|
149
|
+
return [CheckResult("absolute_claims", True, "no unqualified absolutes found")]
|
|
150
|
+
return [CheckResult(
|
|
151
|
+
"absolute_claims", False,
|
|
152
|
+
"unqualified absolutes (verify or hedge these): " + ", ".join(hits))]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def url_check(text: str) -> list[CheckResult]:
|
|
156
|
+
urls = _URL_RE.findall(text)
|
|
157
|
+
if not urls:
|
|
158
|
+
return []
|
|
159
|
+
suspicious = [u for u in urls
|
|
160
|
+
if u.endswith((".png", ".jpg", ".pdf")) and "arxiv" not in u]
|
|
161
|
+
ok = not suspicious
|
|
162
|
+
return [CheckResult(
|
|
163
|
+
"url_format", ok,
|
|
164
|
+
f"{len(urls)} URL(s) found" + (f"; suspicious: {suspicious}" if suspicious else ""))]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_offline_checks(text: str) -> dict[str, Any]:
|
|
168
|
+
checks = (arithmetic_check(text) + percent_check(text)
|
|
169
|
+
+ date_check(text) + absolute_claim_check(text) + url_check(text))
|
|
170
|
+
failed = [c for c in checks if not c.passed]
|
|
171
|
+
return {
|
|
172
|
+
"verdict": "suspicious" if failed else "clean",
|
|
173
|
+
"n_checks": len(checks),
|
|
174
|
+
"failed": [asdict(c) for c in failed],
|
|
175
|
+
"passed": [asdict(c) for c in checks if c.passed],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── multi-model consensus (optional, hosted) ──────────────────────────
|
|
180
|
+
|
|
181
|
+
_STOP = {"the", "a", "an", "of", "is", "are", "was", "were", "to", "in",
|
|
182
|
+
"on", "for", "and", "or", "that", "this", "it", "with", "as", "by"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _norm_claim(s: str) -> frozenset[str]:
|
|
186
|
+
words = re.findall(r"[a-z0-9]+", s.lower())
|
|
187
|
+
return frozenset(w for w in words if w not in _STOP)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def claim_similarity(a: str, b: str) -> float:
|
|
191
|
+
sa, sb = _norm_claim(a), _norm_claim(b)
|
|
192
|
+
if not sa or not sb:
|
|
193
|
+
return 0.0
|
|
194
|
+
return len(sa & sb) / len(sa | sb)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extract_claims(answer: str) -> list[str]:
|
|
198
|
+
"""Ask-format helper: models are instructed to end with a JSON array of
|
|
199
|
+
claims; pull it out, falling back to sentence split."""
|
|
200
|
+
m = re.search(r"\[\s*\".*\"\s*\]", answer, re.DOTALL)
|
|
201
|
+
if m:
|
|
202
|
+
try:
|
|
203
|
+
arr = json.loads(m.group(0))
|
|
204
|
+
if isinstance(arr, list) and arr and all(isinstance(x, str) for x in arr):
|
|
205
|
+
return arr
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
pass
|
|
208
|
+
sents = re.split(r"(?<=[.!?])\s+", answer.strip())
|
|
209
|
+
return [s for s in sents if len(s.split()) >= 4][:8]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class ConsensusReport:
|
|
214
|
+
question: str
|
|
215
|
+
models: list[str]
|
|
216
|
+
verdict: str # high | medium | low | error
|
|
217
|
+
agreement_score: float # 0..1
|
|
218
|
+
shared_claims: list[str] = field(default_factory=list)
|
|
219
|
+
outlier_claims: dict[str, list[str]] = field(default_factory=dict)
|
|
220
|
+
per_model: dict[str, str] = field(default_factory=dict)
|
|
221
|
+
notes: list[str] = field(default_factory=list)
|
|
222
|
+
|
|
223
|
+
def to_dict(self) -> dict[str, Any]:
|
|
224
|
+
return asdict(self)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def score_consensus(question: str, answers: dict[str, str],
|
|
228
|
+
threshold: float = 0.45) -> ConsensusReport:
|
|
229
|
+
"""Pure scoring — injectable and testable without any network."""
|
|
230
|
+
models = list(answers)
|
|
231
|
+
if len(models) < 2:
|
|
232
|
+
return ConsensusReport(question, models, "error", 0.0, notes=[
|
|
233
|
+
"consensus needs >= 2 model answers"])
|
|
234
|
+
claim_sets = {m: extract_claims(a) for m, a in answers.items()}
|
|
235
|
+
shared: list[str] = []
|
|
236
|
+
outliers: dict[str, list[str]] = {m: [] for m in models}
|
|
237
|
+
pair_scores: list[float] = []
|
|
238
|
+
matched: set[tuple[int, int]] = set()
|
|
239
|
+
for m in models:
|
|
240
|
+
for claim in claim_sets[m]:
|
|
241
|
+
hit = False
|
|
242
|
+
for n in models:
|
|
243
|
+
if n == m:
|
|
244
|
+
continue
|
|
245
|
+
for i, c2 in enumerate(claim_sets[n]):
|
|
246
|
+
if claim_similarity(claim, c2) >= threshold:
|
|
247
|
+
pair_scores.append(claim_similarity(claim, c2))
|
|
248
|
+
matched.add((models.index(m), models.index(n)))
|
|
249
|
+
hit = True
|
|
250
|
+
break
|
|
251
|
+
if hit:
|
|
252
|
+
break
|
|
253
|
+
if hit and claim not in shared:
|
|
254
|
+
shared.append(claim)
|
|
255
|
+
elif not hit:
|
|
256
|
+
outliers[m].append(claim)
|
|
257
|
+
n_pairs = len(models) * (len(models) - 1)
|
|
258
|
+
coverage = len({(i, j) for i, j in matched}) / n_pairs if n_pairs else 0.0
|
|
259
|
+
avg_sim = statistics.mean(pair_scores) if pair_scores else 0.0
|
|
260
|
+
score = round(0.6 * coverage + 0.4 * avg_sim, 3)
|
|
261
|
+
verdict = "high" if score >= 0.7 else ("medium" if score >= 0.4 else "low")
|
|
262
|
+
return ConsensusReport(question, models, verdict, score,
|
|
263
|
+
shared_claims=shared[:10],
|
|
264
|
+
outlier_claims={m: v[:5] for m, v in outliers.items() if v},
|
|
265
|
+
per_model={m: a[:400] for m, a in answers.items()},
|
|
266
|
+
notes=["lexical claim matching; threshold "
|
|
267
|
+
f"{threshold} — flags suspicion, does not prove truth"])
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
CONSENSUS_PROMPT = (
|
|
271
|
+
"Answer the question below. After your answer, append a line "
|
|
272
|
+
"'CLAIMS:' followed by a JSON array of 3-7 short factual claims "
|
|
273
|
+
"your answer depends on.\n\nQuestion: {q}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def default_http_transport(model: str, prompt: str, timeout: float) -> str:
|
|
278
|
+
"""OpenAI-compatible chat completion. Requires httpx + env config:
|
|
279
|
+
CTXKIT_API_BASE, CTXKIT_API_KEY. The model name is passed through."""
|
|
280
|
+
import httpx # optional extra
|
|
281
|
+
base = os.environ.get("CTXKIT_API_BASE", "").rstrip("/")
|
|
282
|
+
key = os.environ.get("CTXKIT_API_KEY", "")
|
|
283
|
+
if not base or not key:
|
|
284
|
+
raise RuntimeError("CTXKIT_API_BASE / CTXKIT_API_KEY not configured")
|
|
285
|
+
r = httpx.post(
|
|
286
|
+
f"{base}/chat/completions",
|
|
287
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
288
|
+
json={"model": model, "messages": [{"role": "user", "content": prompt}],
|
|
289
|
+
"temperature": 0.2},
|
|
290
|
+
timeout=timeout,
|
|
291
|
+
)
|
|
292
|
+
r.raise_for_status()
|
|
293
|
+
return r.json()["choices"][0]["message"]["content"]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def multi_model_consensus(question: str, models: list[str],
|
|
297
|
+
transport: Callable[[str, str, float], str] | None = None,
|
|
298
|
+
timeout: float = 120.0) -> dict[str, Any]:
|
|
299
|
+
transport = transport or default_http_transport
|
|
300
|
+
prompt = CONSENSUS_PROMPT.format(q=question)
|
|
301
|
+
answers: dict[str, str] = {}
|
|
302
|
+
errors: dict[str, str] = {}
|
|
303
|
+
for m in models:
|
|
304
|
+
try:
|
|
305
|
+
answers[m] = transport(m, prompt, timeout)
|
|
306
|
+
except Exception as e: # one model failing must not kill the report
|
|
307
|
+
errors[m] = str(e)[:200]
|
|
308
|
+
if len(answers) < 2:
|
|
309
|
+
return {"verdict": "error", "question": question,
|
|
310
|
+
"errors": errors,
|
|
311
|
+
"notes": ["need >= 2 successful model calls for consensus"]}
|
|
312
|
+
report = score_consensus(question, answers)
|
|
313
|
+
report.notes.append(f"model errors: {errors}" if errors else "all models answered")
|
|
314
|
+
return report.to_dict()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""License checking for ctxkit's paid tier.
|
|
2
|
+
|
|
3
|
+
Design constraint that drives everything here: **the license must verify
|
|
4
|
+
offline.** A user who paid should not be locked out because our licence
|
|
5
|
+
server is down, and we should not be running a phone-home endpoint that
|
|
6
|
+
sees every user's usage.
|
|
7
|
+
|
|
8
|
+
So licenses are Ed25519-signed tokens (JWT, ``EdDSA``):
|
|
9
|
+
- private key lives only on the server (never shipped)
|
|
10
|
+
- public key is baked into the package
|
|
11
|
+
- the client verifies the signature locally, against the expiry
|
|
12
|
+
|
|
13
|
+
This module is intentionally dependency-light: it uses ``PyJWT[crypto]``
|
|
14
|
+
when available, and falls back to a clear error when it is not. Memory and
|
|
15
|
+
verification (the free tier) never touch this module at all.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
Tier = Literal["free", "pro", "team"]
|
|
25
|
+
|
|
26
|
+
# Public key shipped with the client. Replace with the real one when the
|
|
27
|
+
# Stripe setup is live (see billing/README.md). The placeholder is refused
|
|
28
|
+
# explicitly rather than silently trusting anything.
|
|
29
|
+
# The public key baked into the package at build time. This is the key the
|
|
30
|
+
# billing service signs licences with.
|
|
31
|
+
BUILTIN_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
|
32
|
+
MCowBQYDK2VwAyEAsnFCGUPdXj1osPhWOsdxNj8ngmaMciTksQP2h86hmw8=
|
|
33
|
+
-----END PUBLIC KEY-----"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_public_key() -> str:
|
|
37
|
+
"""Return the public key to verify against.
|
|
38
|
+
|
|
39
|
+
Reads ``CTXKIT_LICENSE_PUBKEY`` at *call* time rather than import time.
|
|
40
|
+
Resolving it at import made the override silently useless for anyone who
|
|
41
|
+
exported the variable after the module was already imported (e.g. from a
|
|
42
|
+
test or a wrapper script) — the override looked supported but did nothing.
|
|
43
|
+
"""
|
|
44
|
+
return os.environ.get("CTXKIT_LICENSE_PUBKEY") or BUILTIN_PUBLIC_KEY
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Kept for backwards compatibility with anything importing the old name.
|
|
48
|
+
PUBLIC_KEY_PEM = BUILTIN_PUBLIC_KEY
|
|
49
|
+
|
|
50
|
+
_PLACEHOLDER = "PLACEHOLDER_REPLACE_ME"
|
|
51
|
+
|
|
52
|
+
# Free tier never expires and needs no token.
|
|
53
|
+
FREE = "free"
|
|
54
|
+
|
|
55
|
+
QUOTAS: dict[Tier, dict[str, Any]] = {
|
|
56
|
+
"free": {"consensus_per_month": 0, "email_support": False, "managed_keys": False, "seats": 1},
|
|
57
|
+
"pro": {"consensus_per_month": 300, "email_support": True, "managed_keys": False, "seats": 1},
|
|
58
|
+
"team": {"consensus_per_month": 3000, "email_support": True, "managed_keys": True, "seats": 5},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class License:
|
|
64
|
+
tier: Tier = "free"
|
|
65
|
+
email: str = ""
|
|
66
|
+
exp: int = 0 # unix ts; 0 = never (free)
|
|
67
|
+
seats: int = 1
|
|
68
|
+
managed_keys: bool = False
|
|
69
|
+
claims: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
reason: str = "" # why the license isn't active, if so
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def active(self) -> bool:
|
|
74
|
+
return self.tier != "free" and (self.exp == 0 or self.exp > time.time())
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def consensus_quota(self) -> int:
|
|
78
|
+
return QUOTAS[self.tier]["consensus_per_month"]
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"tier": self.tier,
|
|
83
|
+
"active": self.active,
|
|
84
|
+
"email": self.email,
|
|
85
|
+
"expires": self.exp,
|
|
86
|
+
"expires_human": (time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(self.exp))
|
|
87
|
+
if self.exp else "never"),
|
|
88
|
+
"seats": self.seats,
|
|
89
|
+
"managed_keys": self.managed_keys,
|
|
90
|
+
"consensus_quota": self.consensus_quota,
|
|
91
|
+
"reason": self.reason,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _jwt():
|
|
96
|
+
try:
|
|
97
|
+
import jwt # type: ignore
|
|
98
|
+
return jwt
|
|
99
|
+
except ImportError: # pragma: no cover - optional dependency path
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def verify_license(token: str | None, public_key: str | None = None,
|
|
104
|
+
*, now: float | None = None) -> License:
|
|
105
|
+
"""Verify a license token locally. Never raises; returns a License with
|
|
106
|
+
``reason`` set if anything is off."""
|
|
107
|
+
if not token:
|
|
108
|
+
return License(tier="free", reason="no token (free tier)")
|
|
109
|
+
jwt = _jwt()
|
|
110
|
+
if jwt is None:
|
|
111
|
+
return License(tier="free", reason=(
|
|
112
|
+
"licence verification needs PyJWT: "
|
|
113
|
+
"pip install 'meshctx-ctxkit[license]'"))
|
|
114
|
+
key = public_key or _resolve_public_key()
|
|
115
|
+
if _PLACEHOLDER in key:
|
|
116
|
+
return License(tier="free",
|
|
117
|
+
reason="license public key not configured in this build")
|
|
118
|
+
try:
|
|
119
|
+
# EdDSA only. Rejecting HS256/RS256 prevents the classic
|
|
120
|
+
# "alg: none" and public-key-as-HMAC-secret downgrade attacks.
|
|
121
|
+
claims = jwt.decode(token, key, algorithms=["EdDSA"],
|
|
122
|
+
options={"require": ["tier", "exp"]})
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return License(tier="free", reason=f"invalid license: {type(e).__name__}: {e}")
|
|
125
|
+
|
|
126
|
+
tier = claims.get("tier", "free")
|
|
127
|
+
if tier not in QUOTAS:
|
|
128
|
+
return License(tier="free", reason=f"unknown tier: {tier}")
|
|
129
|
+
exp = int(claims.get("exp") or 0)
|
|
130
|
+
lic = License(
|
|
131
|
+
tier=tier,
|
|
132
|
+
email=claims.get("email", ""),
|
|
133
|
+
exp=exp,
|
|
134
|
+
seats=int(claims.get("seats") or QUOTAS[tier]["seats"]),
|
|
135
|
+
managed_keys=bool(claims.get("managed_keys",
|
|
136
|
+
QUOTAS[tier]["managed_keys"])),
|
|
137
|
+
claims=claims,
|
|
138
|
+
)
|
|
139
|
+
if now is not None and exp and exp <= now:
|
|
140
|
+
lic.tier = "free"
|
|
141
|
+
lic.reason = f"license expired on {time.strftime('%Y-%m-%d', time.gmtime(exp))}"
|
|
142
|
+
return lic
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_license_from_env(public_key: str | None = None) -> License:
|
|
146
|
+
"""Read CTXKIT_LICENSE, or ~/.ctxkit/license.key if the env var is unset."""
|
|
147
|
+
token = os.environ.get("CTXKIT_LICENSE", "").strip()
|
|
148
|
+
if not token:
|
|
149
|
+
path = os.path.join(os.environ.get("CTXKIT_HOME") or
|
|
150
|
+
os.path.join(os.path.expanduser("~"), ".ctxkit"),
|
|
151
|
+
"license.key")
|
|
152
|
+
try:
|
|
153
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
154
|
+
token = fh.read().strip()
|
|
155
|
+
except OSError:
|
|
156
|
+
token = ""
|
|
157
|
+
return verify_license(token, public_key)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def sign_license(private_key_pem: str, *, tier: Tier, email: str,
|
|
161
|
+
days: int = 30, seats: int | None = None,
|
|
162
|
+
managed_keys: bool | None = None) -> str:
|
|
163
|
+
"""Server-side only. Produces the token we email to a paying customer."""
|
|
164
|
+
jwt = _jwt()
|
|
165
|
+
if jwt is None:
|
|
166
|
+
raise RuntimeError("PyJWT required on the signing side: pip install pyjwt[crypto]")
|
|
167
|
+
now = int(time.time())
|
|
168
|
+
claims: dict[str, Any] = {
|
|
169
|
+
"tier": tier,
|
|
170
|
+
"email": email,
|
|
171
|
+
"iat": now,
|
|
172
|
+
"exp": now + days * 86400,
|
|
173
|
+
"iss": "ctxkit-billing",
|
|
174
|
+
}
|
|
175
|
+
if seats is not None:
|
|
176
|
+
claims["seats"] = seats
|
|
177
|
+
if managed_keys is not None:
|
|
178
|
+
claims["managed_keys"] = managed_keys
|
|
179
|
+
return jwt.encode(claims, private_key_pem, algorithm="EdDSA")
|