mnemonic-proxy 0.3.2__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.
- ctxproxy/__init__.py +8 -0
- ctxproxy/api_anthropic.py +426 -0
- ctxproxy/api_responses.py +436 -0
- ctxproxy/autosave.py +350 -0
- ctxproxy/check.py +356 -0
- ctxproxy/compat.py +91 -0
- ctxproxy/core.py +2218 -0
- ctxproxy/demo.py +193 -0
- ctxproxy/engines.py +314 -0
- ctxproxy/fake_engine.py +304 -0
- ctxproxy/kvarchive.py +855 -0
- ctxproxy/lang.py +57 -0
- ctxproxy/mcp_server.py +381 -0
- ctxproxy/packtok.py +29 -0
- ctxproxy/paging.py +508 -0
- ctxproxy/recall2.py +1003 -0
- ctxproxy/render.py +274 -0
- ctxproxy/server.py +797 -0
- ctxproxy/tooldefs.py +111 -0
- ctxproxy/upstream.py +106 -0
- mnemonic_proxy-0.3.2.dist-info/METADATA +841 -0
- mnemonic_proxy-0.3.2.dist-info/RECORD +26 -0
- mnemonic_proxy-0.3.2.dist-info/WHEEL +5 -0
- mnemonic_proxy-0.3.2.dist-info/entry_points.txt +4 -0
- mnemonic_proxy-0.3.2.dist-info/licenses/LICENSE +21 -0
- mnemonic_proxy-0.3.2.dist-info/top_level.txt +1 -0
ctxproxy/check.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""`mnemonic-proxy check`: is this setup ready? Reads the configuration the server would run with (same options:
|
|
3
|
+
--upstream, --config, --data, --port, --engine ...) and checks it against the engine that is actually running.
|
|
4
|
+
|
|
5
|
+
Every line: status, evidence, fix. Saved state is reported on three levels:
|
|
6
|
+
supported the engine can save/restore its state (llama-server started with --slot-save-path, Strata
|
|
7
|
+
session-files);
|
|
8
|
+
configured the proxy config turns the saved-state features on (slot_save, mask_anchor, autosave ...);
|
|
9
|
+
verified `--live` really saved and restored a state file, and found it in `slot_dir`.
|
|
10
|
+
|
|
11
|
+
`--live` also runs one short generation and one tool-call request on the engine. It refuses to run while the engine
|
|
12
|
+
slot is busy; the slot content is saved first and put back at the end, so an idle conversation is not lost.
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 = ready, 2 = ready with warnings, 1 = not ready (at least one failure).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import socket
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
|
|
27
|
+
from . import engines
|
|
28
|
+
from .core import Config
|
|
29
|
+
from .upstream import Upstream
|
|
30
|
+
|
|
31
|
+
OK, WARN, FAIL, INFO = "ok", "warn", "fail", "info"
|
|
32
|
+
ICON = {OK: "\u2705", WARN: "\u26a0\ufe0f ", FAIL: "\u274c", INFO: "\u2139\ufe0f "}
|
|
33
|
+
PLAIN = {OK: "[ OK ]", WARN: "[WARN]", FAIL: "[FAIL]", INFO: "[INFO]"}
|
|
34
|
+
SAVED_STATE = ("slot_save", "mask_anchor", "autosave", "seal_experimental", "kv_archive")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Report:
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.items: list[dict] = []
|
|
40
|
+
|
|
41
|
+
def add(self, status, name, evidence, fix=""):
|
|
42
|
+
self.items.append({"status": status, "check": name, "evidence": evidence, "fix": fix})
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def exit_code(self) -> int:
|
|
46
|
+
st = {i["status"] for i in self.items}
|
|
47
|
+
return 1 if FAIL in st else 2 if WARN in st else 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get(url, timeout=5.0):
|
|
51
|
+
"""-> (status, parsed JSON or text) ; status 0 = not reachable."""
|
|
52
|
+
try:
|
|
53
|
+
with urllib.request.urlopen(url, timeout=timeout) as r:
|
|
54
|
+
data, st = r.read(), r.status
|
|
55
|
+
except urllib.error.HTTPError as e:
|
|
56
|
+
data, st = e.read(), e.code
|
|
57
|
+
except Exception as e: # noqa: BLE001
|
|
58
|
+
return 0, repr(e)
|
|
59
|
+
try:
|
|
60
|
+
return st, json.loads(data)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return st, data.decode("utf-8", "replace")[:300]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _port_state(host, port):
|
|
66
|
+
"""-> "free" | "proxy" (a mnemonic-proxy answers there) | "busy"."""
|
|
67
|
+
s = socket.socket(socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM)
|
|
68
|
+
try:
|
|
69
|
+
s.bind((host, port))
|
|
70
|
+
return "free", None
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
finally:
|
|
74
|
+
s.close()
|
|
75
|
+
st, body = _get("http://%s:%d/v1/engine" % (host if ":" not in host else "[%s]" % host, port), 3)
|
|
76
|
+
if st == 200 and isinstance(body, dict) and "kind" in body:
|
|
77
|
+
return "proxy", body
|
|
78
|
+
return "busy", None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _writable(d):
|
|
82
|
+
try:
|
|
83
|
+
os.makedirs(d, exist_ok=True)
|
|
84
|
+
p = os.path.join(d, ".mnemonic-check-%d" % os.getpid())
|
|
85
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
86
|
+
f.write("x")
|
|
87
|
+
os.remove(p)
|
|
88
|
+
return True, ""
|
|
89
|
+
except OSError as e:
|
|
90
|
+
return False, str(e)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_checks(a, raw_cfg: dict, cfg: Config, live: bool = False) -> Report:
|
|
94
|
+
rep = Report()
|
|
95
|
+
explicit = set(raw_cfg)
|
|
96
|
+
# --- config file
|
|
97
|
+
if a.config:
|
|
98
|
+
names = {f.name for f in dataclasses.fields(Config)}
|
|
99
|
+
unknown = sorted(k for k in raw_cfg if k not in names)
|
|
100
|
+
if unknown:
|
|
101
|
+
rep.add(WARN, "config", "%s: unknown fields ignored: %s" % (a.config, ", ".join(unknown)),
|
|
102
|
+
"check the spelling against the Configuration table in the README")
|
|
103
|
+
else:
|
|
104
|
+
rep.add(OK, "config", "%s: %d fields, all known" % (a.config, len(raw_cfg)))
|
|
105
|
+
else:
|
|
106
|
+
rep.add(INFO, "config", "no --config: defaults (saved-state features off)",
|
|
107
|
+
"for llama-server use examples/config.llama-server.json")
|
|
108
|
+
# --- python
|
|
109
|
+
if cfg.kv_archive and sys.version_info < (3, 14):
|
|
110
|
+
rep.add(FAIL, "python", "kv_archive needs Python >= 3.14, this is %d.%d" % sys.version_info[:2],
|
|
111
|
+
'set "kv_archive": false or use Python 3.14')
|
|
112
|
+
else:
|
|
113
|
+
rep.add(OK, "python", "Python %d.%d" % sys.version_info[:2])
|
|
114
|
+
# --- data folder
|
|
115
|
+
ok, err = _writable(a.data)
|
|
116
|
+
rep.add(OK if ok else FAIL, "data folder", "%s %s" % (os.path.abspath(a.data), "is writable" if ok else
|
|
117
|
+
"is not writable: " + err),
|
|
118
|
+
"" if ok else "choose another --data folder or fix its permissions")
|
|
119
|
+
# --- port
|
|
120
|
+
state, body = _port_state(a.host, a.port)
|
|
121
|
+
if state == "free":
|
|
122
|
+
rep.add(OK, "port", "%s:%d is free" % (a.host, a.port))
|
|
123
|
+
elif state == "proxy":
|
|
124
|
+
rep.add(INFO, "port", "%s:%d: a mnemonic-proxy is already running there (engine %s)"
|
|
125
|
+
% (a.host, a.port, (body or {}).get("kind")), "fine if it is this one; otherwise use another --port")
|
|
126
|
+
else:
|
|
127
|
+
rep.add(FAIL, "port", "%s:%d is taken by another program" % (a.host, a.port),
|
|
128
|
+
"pick a free --port (and the same port in the client's base URL)")
|
|
129
|
+
# --- upstream
|
|
130
|
+
up = Upstream(a.upstream, a.api_key, timeout=10)
|
|
131
|
+
st_h, h = _get(up.base + "/health", 5)
|
|
132
|
+
st_m, models = _get(up.base + "/v1/models", 5)
|
|
133
|
+
if st_h == 0 and st_m == 0:
|
|
134
|
+
rep.add(FAIL, "engine reachable", "%s does not answer (%s)" % (a.upstream, h),
|
|
135
|
+
"start the engine first (llama-server ... --port N) and pass the same URL to --upstream")
|
|
136
|
+
_reminder(rep)
|
|
137
|
+
return rep
|
|
138
|
+
rep.add(OK, "engine reachable", "%s answers (/health %s, /v1/models %s)" % (a.upstream, st_h or "-", st_m or "-"))
|
|
139
|
+
try:
|
|
140
|
+
eng = engines.detect(up, cfg.engine, int(cfg.slot_id or 0))
|
|
141
|
+
except ValueError as e:
|
|
142
|
+
rep.add(FAIL, "engine type", str(e), 'use "engine": "auto", "strata", "llama.cpp", "ds4" or "openai"')
|
|
143
|
+
return rep
|
|
144
|
+
how = "detected" if eng.detected else "forced by config/--engine"
|
|
145
|
+
rep.add(OK if eng.detected or cfg.engine != "auto" else WARN, "engine type",
|
|
146
|
+
"%s (%s)%s" % (eng.kind, how, ("; notes: " + "; ".join(eng.notes)) if eng.notes else ""),
|
|
147
|
+
"" if eng.kind != "openai" or cfg.engine != "auto" else
|
|
148
|
+
"llama-server, Strata and ds4-server are recognised; anything else runs in base mode (no saved state)")
|
|
149
|
+
# --- model loaded
|
|
150
|
+
if st_h == 503:
|
|
151
|
+
rep.add(FAIL, "model loaded", "/health answers 503: the engine is still loading the model",
|
|
152
|
+
"wait until llama-server prints 'server is listening', then run check again")
|
|
153
|
+
else:
|
|
154
|
+
ids = [m.get("id") for m in (models.get("data") or [])] if isinstance(models, dict) else []
|
|
155
|
+
if eng.kind == "strata":
|
|
156
|
+
s = engines._get_json(up, "/v1/status") or {}
|
|
157
|
+
loaded = bool(s.get("loaded"))
|
|
158
|
+
rep.add(OK if loaded else FAIL, "model loaded", "Strata /v1/status loaded=%s" % s.get("loaded"),
|
|
159
|
+
"" if loaded else "load a model in Strata")
|
|
160
|
+
elif ids or eng.model:
|
|
161
|
+
rep.add(OK, "model loaded", "model: %s" % (eng.model or ", ".join(map(str, ids[:3]))))
|
|
162
|
+
else:
|
|
163
|
+
rep.add(WARN, "model loaded", "the engine did not list a model (/v1/models %s)" % st_m,
|
|
164
|
+
"check that the engine has a model loaded")
|
|
165
|
+
# --- window vs n_ctx
|
|
166
|
+
if eng.n_ctx:
|
|
167
|
+
if "window" in explicit:
|
|
168
|
+
if cfg.window > eng.n_ctx:
|
|
169
|
+
rep.add(FAIL, "context window", "config window=%d > engine n_ctx=%d: prompts would overflow"
|
|
170
|
+
% (cfg.window, eng.n_ctx), "remove \"window\" from the config (taken from n_ctx) or start "
|
|
171
|
+
"the engine with -c %d" % cfg.window)
|
|
172
|
+
else:
|
|
173
|
+
rep.add(OK if cfg.window == eng.n_ctx else WARN, "context window",
|
|
174
|
+
"config window=%d, engine n_ctx=%d" % (cfg.window, eng.n_ctx),
|
|
175
|
+
"" if cfg.window == eng.n_ctx else "window smaller than n_ctx wastes context")
|
|
176
|
+
elif eng.kind in ("llama.cpp", "ds4"): # come engines.apply: finestra da n_ctx / context_length
|
|
177
|
+
probe = Config.from_dict(raw_cfg)
|
|
178
|
+
engines.apply(probe, eng, None, explicit, log=None)
|
|
179
|
+
rep.add(OK, "context window", "window from engine n_ctx=%d; thresholds scaled: mask at %d -> %d tokens, "
|
|
180
|
+
"protected tail %d" % (eng.n_ctx, probe.mask_trigger, probe.mask_target,
|
|
181
|
+
probe.keep_recent_tokens))
|
|
182
|
+
bad = [k for k in ("notes_max_tokens", "response_floor", "tail_max", "reserve")
|
|
183
|
+
if k in explicit and getattr(probe, k) * 4 > eng.n_ctx]
|
|
184
|
+
if bad:
|
|
185
|
+
rep.add(WARN, "context window", "fields written for a bigger window: %s (n_ctx %d)"
|
|
186
|
+
% (", ".join("%s=%d" % (k, getattr(probe, k)) for k in bad), eng.n_ctx),
|
|
187
|
+
"remove them from the config: unset thresholds scale with n_ctx")
|
|
188
|
+
else:
|
|
189
|
+
rep.add(INFO, "context window", "engine n_ctx=%d, proxy window=%d" % (eng.n_ctx, cfg.window))
|
|
190
|
+
elif eng.kind == "openai":
|
|
191
|
+
rep.add(WARN if "window" not in explicit else OK, "context window",
|
|
192
|
+
"generic engine: n_ctx unknown, proxy window=%d" % cfg.window,
|
|
193
|
+
"" if "window" in explicit else "set \"window\" in the config to the engine's real context size")
|
|
194
|
+
# --- minimum window (0.3.0): the server refuses to start below it, so this is a failure, not a warning
|
|
195
|
+
for evidence, fix in engines.window_problems(cfg, eng, explicit):
|
|
196
|
+
rep.add(FAIL, "context window", evidence + " (the proxy refuses to start)", fix)
|
|
197
|
+
# --- saved state: supported / configured / verified
|
|
198
|
+
configured = [k for k in SAVED_STATE if getattr(cfg, k, False)]
|
|
199
|
+
if eng.slot_save and configured:
|
|
200
|
+
rep.add(OK, "saved state", "supported by the engine, configured in the proxy (%s)" % ", ".join(configured),
|
|
201
|
+
"" if live else "run `mnemonic-proxy check --live ...` to verify a real save/restore")
|
|
202
|
+
elif eng.slot_save:
|
|
203
|
+
rep.add(WARN, "saved state", "supported by the engine, disabled in the proxy config",
|
|
204
|
+
"use examples/config.llama-server.json (slot_save, mask_anchor, autosave) with slot_dir set")
|
|
205
|
+
elif configured:
|
|
206
|
+
hint = {"llama.cpp": "start llama-server with --slot-save-path DIR (same folder as slot_dir)",
|
|
207
|
+
"strata": "official Strata has no session files: build the session-files branch (README, Engines), "
|
|
208
|
+
"or use examples/config.official-strata.json",
|
|
209
|
+
"openai": "this engine cannot save state: drop the saved-state fields (the proxy turns them off)",
|
|
210
|
+
"ds4": "ds4-server saves its own state with --kv-disk-dir: drop the saved-state fields "
|
|
211
|
+
"(use examples/config.ds4.json)"}
|
|
212
|
+
rep.add(WARN, "saved state", "configured in the proxy (%s) but not supported by the engine: the proxy will "
|
|
213
|
+
"turn them off" % ", ".join(configured), hint.get(eng.kind, ""))
|
|
214
|
+
elif eng.kind == "ds4":
|
|
215
|
+
rep.add(INFO, "saved state", "ds4-server keeps its own state (start it with --kv-disk-dir DIR); the proxy "
|
|
216
|
+
"does not save or restore it")
|
|
217
|
+
else:
|
|
218
|
+
rep.add(INFO, "saved state", "not supported by the engine and not configured: base mode (masking, archive, "
|
|
219
|
+
"recall, segments)")
|
|
220
|
+
if eng.slot_save and configured:
|
|
221
|
+
if not cfg.slot_dir:
|
|
222
|
+
rep.add(WARN, "slot_dir", "not set: old anchors and autosaves are never cleaned up, kv_archive off",
|
|
223
|
+
"set \"slot_dir\" to the engine's --slot-save-path folder")
|
|
224
|
+
elif not os.path.isdir(cfg.slot_dir):
|
|
225
|
+
rep.add(FAIL, "slot_dir", "%s does not exist" % cfg.slot_dir,
|
|
226
|
+
"create it and start the engine with --slot-save-path pointing to it (same folder)")
|
|
227
|
+
else:
|
|
228
|
+
ok, err = _writable(cfg.slot_dir)
|
|
229
|
+
rep.add(OK if ok else FAIL, "slot_dir", "%s exists%s" % (cfg.slot_dir, ", writable" if ok else
|
|
230
|
+
", NOT writable: " + err),
|
|
231
|
+
"" if live else "check --live verifies it is the engine's --slot-save-path")
|
|
232
|
+
if live:
|
|
233
|
+
_live(rep, up, eng, cfg)
|
|
234
|
+
_reminder(rep)
|
|
235
|
+
return rep
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _live(rep, up, eng, cfg):
|
|
239
|
+
up.timeout = 600
|
|
240
|
+
up.set_engine(eng, cfg.slot_dir)
|
|
241
|
+
st = engines.normalize_status(eng, up) or {}
|
|
242
|
+
if (st.get("activity") or {}).get("in_flight"):
|
|
243
|
+
rep.add(FAIL, "live", "the engine slot is busy right now", "run check --live when no agent is working")
|
|
244
|
+
return
|
|
245
|
+
tag = "mnemonic-check-%d" % os.getpid()
|
|
246
|
+
backup = None
|
|
247
|
+
if eng.slot_save:
|
|
248
|
+
try:
|
|
249
|
+
up.slot("save", tag + "-backup.bin")
|
|
250
|
+
backup = tag + "-backup.bin"
|
|
251
|
+
except Exception as e: # noqa: BLE001
|
|
252
|
+
rep.add(FAIL, "live: backup", "could not save the current slot first: %s" % str(e)[:200],
|
|
253
|
+
"not touching the engine; fix saved state first")
|
|
254
|
+
return
|
|
255
|
+
try:
|
|
256
|
+
t0 = time.time()
|
|
257
|
+
r = up.chat({"model": "local-model", "max_tokens": 16, "temperature": 0,
|
|
258
|
+
"messages": [{"role": "user", "content": "Reply with the single word: ready"}]})
|
|
259
|
+
txt = ((r.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
|
260
|
+
rep.add(OK, "live: generation", "%.1f s, reply %r" % (time.time() - t0, txt.strip()[:60]))
|
|
261
|
+
t0 = time.time()
|
|
262
|
+
tool = {"type": "function", "function": {
|
|
263
|
+
"name": "get_time", "description": "Return the current time in a city",
|
|
264
|
+
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}
|
|
265
|
+
r = up.chat({"model": "local-model", "max_tokens": 128, "temperature": 0, "tools": [tool],
|
|
266
|
+
"messages": [{"role": "user", "content": "What time is it in Rome? Use the tool."}]})
|
|
267
|
+
calls = ((r.get("choices") or [{}])[0].get("message") or {}).get("tool_calls") or []
|
|
268
|
+
good = False
|
|
269
|
+
if calls:
|
|
270
|
+
try:
|
|
271
|
+
good = bool(json.loads(calls[0]["function"]["arguments"]).get("city"))
|
|
272
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
273
|
+
good = False
|
|
274
|
+
rep.add(OK if good else WARN, "live: tool call",
|
|
275
|
+
("%.1f s, %s(%s)" % (time.time() - t0, calls[0]["function"]["name"],
|
|
276
|
+
calls[0]["function"]["arguments"])) if calls else
|
|
277
|
+
"the model answered without calling the tool", "" if good else
|
|
278
|
+
"llama-server: start with --jinja (chat template with tools); try a model with tool support")
|
|
279
|
+
if eng.slot_save:
|
|
280
|
+
fn = tag + ".bin"
|
|
281
|
+
t0 = time.time()
|
|
282
|
+
s = up.slot("save", fn)
|
|
283
|
+
r2 = up.slot("restore", fn)
|
|
284
|
+
n_s, n_r = s.get("n_saved"), r2.get("n_restored")
|
|
285
|
+
rep.add(OK if n_s and n_s == n_r else FAIL, "live: save/restore",
|
|
286
|
+
"saved %s tokens, restored %s, %.2f s" % (n_s, n_r, time.time() - t0),
|
|
287
|
+
"" if n_s and n_s == n_r else "the engine could not restore its own state file")
|
|
288
|
+
p = os.path.join(cfg.slot_dir, fn) if cfg.slot_dir else ""
|
|
289
|
+
if p and os.path.exists(p):
|
|
290
|
+
rep.add(OK, "live: slot_dir", "the state file appeared in %s: slot_dir matches --slot-save-path"
|
|
291
|
+
% cfg.slot_dir)
|
|
292
|
+
os.remove(p)
|
|
293
|
+
elif cfg.slot_dir:
|
|
294
|
+
rep.add(FAIL, "live: slot_dir", "the engine saved %s but it is not in %s" % (fn, cfg.slot_dir),
|
|
295
|
+
"slot_dir must be the same folder as llama-server --slot-save-path (and on the same host)")
|
|
296
|
+
except Exception as e: # noqa: BLE001
|
|
297
|
+
rep.add(FAIL, "live", "request failed: %s" % str(e)[:300], "see the engine log")
|
|
298
|
+
finally:
|
|
299
|
+
if backup:
|
|
300
|
+
try:
|
|
301
|
+
up.slot("restore", backup)
|
|
302
|
+
if cfg.slot_dir and os.path.exists(os.path.join(cfg.slot_dir, backup)):
|
|
303
|
+
os.remove(os.path.join(cfg.slot_dir, backup))
|
|
304
|
+
rep.add(INFO, "live", "the slot content from before the check was put back")
|
|
305
|
+
except Exception as e: # noqa: BLE001
|
|
306
|
+
rep.add(WARN, "live", "could not put back the previous slot content: %s" % str(e)[:200],
|
|
307
|
+
"the next request re-reads its prompt once")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _reminder(rep):
|
|
311
|
+
rep.add(INFO, "client", "turn off the client's own auto-compaction (pi: \"compaction\": {\"enabled\": false}; "
|
|
312
|
+
"Claude Code: DISABLE_AUTO_COMPACT=1), otherwise two context managers fight over the same history")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def render(rep: Report, out=sys.stdout):
|
|
316
|
+
enc = (getattr(out, "encoding", None) or "ascii").lower()
|
|
317
|
+
try:
|
|
318
|
+
"\u2705\u26a0\ufe0f\u274c\u2139\ufe0f".encode(enc)
|
|
319
|
+
icons = ICON
|
|
320
|
+
except (UnicodeEncodeError, LookupError):
|
|
321
|
+
icons = PLAIN
|
|
322
|
+
for i in rep.items:
|
|
323
|
+
print("%s %-18s %s" % (icons[i["status"]], i["check"], i["evidence"]), file=out)
|
|
324
|
+
if i["fix"]:
|
|
325
|
+
print(" %-18s -> %s" % ("", i["fix"]), file=out)
|
|
326
|
+
word = {0: "READY", 2: "READY with warnings", 1: "NOT READY"}[rep.exit_code]
|
|
327
|
+
print("\n%s (exit %d)" % (word, rep.exit_code), file=out)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def main(argv=None):
|
|
331
|
+
from .server import build_parser, load_config
|
|
332
|
+
ap = build_parser(prog="mnemonic-proxy check",
|
|
333
|
+
description="Check the setup the proxy would run with (same options as the server). "
|
|
334
|
+
"Exit 0 = ready, 2 = ready with warnings, 1 = not ready.")
|
|
335
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
336
|
+
ap.add_argument("--live", action="store_true",
|
|
337
|
+
help="also run a short generation, a tool call and a save/restore on the engine "
|
|
338
|
+
"(refused while the slot is busy; the slot content is put back)")
|
|
339
|
+
a = ap.parse_args(argv)
|
|
340
|
+
try:
|
|
341
|
+
raw_cfg, cfg = load_config(a)
|
|
342
|
+
except (OSError, ValueError) as e:
|
|
343
|
+
rep = Report()
|
|
344
|
+
rep.add(FAIL, "config", "%s: %s" % (a.config, e), "fix the JSON file")
|
|
345
|
+
else:
|
|
346
|
+
rep = run_checks(a, raw_cfg, cfg, live=a.live)
|
|
347
|
+
if a.json:
|
|
348
|
+
print(json.dumps({"ready": rep.exit_code != 1, "exit_code": rep.exit_code, "checks": rep.items},
|
|
349
|
+
ensure_ascii=False, indent=1))
|
|
350
|
+
else:
|
|
351
|
+
render(rep)
|
|
352
|
+
sys.exit(rep.exit_code)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
main()
|
ctxproxy/compat.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""Portability helpers (Linux / macOS / native Windows), standard library only.
|
|
3
|
+
|
|
4
|
+
- `lock_fd` / `unlock_fd`: exclusive, blocking lock on an open file descriptor. POSIX: `fcntl.flock` (unchanged
|
|
5
|
+
behaviour); Windows: `msvcrt.locking` on the first byte, retried until it is granted.
|
|
6
|
+
- `console_safe`: a Windows console (cp1252, cp850...) cannot encode characters like ⟪ 📌 🗑; printing them raised
|
|
7
|
+
UnicodeEncodeError. Non-UTF-8 stdout/stderr switch to `errors="backslashreplace"`; UTF-8 streams are untouched.
|
|
8
|
+
- `sqlite_ro_uri`: `file:` URI for a read-only sqlite connection that also works with `C:\\...` paths.
|
|
9
|
+
- `bad_name`: characters that cannot appear in a single file name on this OS.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
WINDOWS = os.name == "nt"
|
|
18
|
+
|
|
19
|
+
if WINDOWS: # pragma: no cover - exercised on the windows-latest CI runner
|
|
20
|
+
import msvcrt
|
|
21
|
+
|
|
22
|
+
def lock_fd(fd: int) -> None:
|
|
23
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1) # LK_LOCK itself retries for ~10 s, then OSError
|
|
27
|
+
return
|
|
28
|
+
except OSError:
|
|
29
|
+
time.sleep(0.05)
|
|
30
|
+
|
|
31
|
+
def unlock_fd(fd: int) -> None:
|
|
32
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
33
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
34
|
+
else:
|
|
35
|
+
import fcntl
|
|
36
|
+
|
|
37
|
+
def lock_fd(fd: int) -> None:
|
|
38
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
39
|
+
|
|
40
|
+
def unlock_fd(fd: int) -> None:
|
|
41
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def console_safe(*streams) -> None:
|
|
45
|
+
"""Never crash on a character the console code page cannot encode (UTF-8 streams are left as they are)."""
|
|
46
|
+
for s in streams or (sys.stdout, sys.stderr):
|
|
47
|
+
enc = (getattr(s, "encoding", None) or "").lower().replace("-", "").replace("_", "")
|
|
48
|
+
if enc in ("utf8", "utf8sig") or not hasattr(s, "reconfigure"):
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
s.reconfigure(errors="backslashreplace")
|
|
52
|
+
except (ValueError, OSError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sqlite_ro_uri(path: str) -> str:
|
|
57
|
+
"""Read-only sqlite URI. POSIX: `file:/abs/path?mode=ro` (as before). Windows: `file:///C:/abs/path?mode=ro`."""
|
|
58
|
+
p = os.path.abspath(path)
|
|
59
|
+
if WINDOWS:
|
|
60
|
+
p = "/" + p.replace("\\", "/")
|
|
61
|
+
if WINDOWS:
|
|
62
|
+
return "file://%s?mode=ro" % p.replace("%", "%25").replace("?", "%3f").replace("#", "%23")
|
|
63
|
+
return "file:%s?mode=ro" % p.replace("%", "%25").replace("?", "%3f").replace("#", "%23")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def replace(src: str, dst: str, tries: int = 50) -> None:
|
|
67
|
+
"""os.replace. On Windows a destination briefly open in another process (a reader of last_request.json, an
|
|
68
|
+
antivirus scan) makes it fail with PermissionError: retry for up to ~2.5 s. POSIX: a single os.replace."""
|
|
69
|
+
if not WINDOWS:
|
|
70
|
+
os.replace(src, dst)
|
|
71
|
+
return
|
|
72
|
+
for i in range(tries): # pragma: no cover - Windows only
|
|
73
|
+
try:
|
|
74
|
+
os.replace(src, dst)
|
|
75
|
+
return
|
|
76
|
+
except PermissionError:
|
|
77
|
+
if i == tries - 1:
|
|
78
|
+
raise
|
|
79
|
+
time.sleep(0.05)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
O_BINARY = getattr(os, "O_BINARY", 0) # Windows: os.open is text mode (LF -> CRLF on write) without it
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def bad_name(name: str) -> bool:
|
|
86
|
+
"""True if `name` is not a plain file name here (path separators; on Windows also `:` and reserved chars)."""
|
|
87
|
+
if "/" in name or name.startswith("."):
|
|
88
|
+
return True
|
|
89
|
+
if WINDOWS:
|
|
90
|
+
return any(c in name for c in '\\:*?"<>|') or name.rstrip(" .") != name
|
|
91
|
+
return False
|