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/demo.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""`mnemonic-proxy demo`: see the proxy work in under a minute, offline, with no model and no GPU.
|
|
3
|
+
|
|
4
|
+
A scripted fake model (ctxproxy.fake_engine) runs behind the real proxy code in a temporary folder:
|
|
5
|
+
|
|
6
|
+
1. an agent session reads a long build log that contains one identifier;
|
|
7
|
+
2. the session keeps growing until the proxy hides old tool outputs (masking);
|
|
8
|
+
3. we show what the engine actually receives in place of the log (a one-line receipt);
|
|
9
|
+
4. the user asks for the identifier: the model calls `recall`, the proxy resolves it internally, the client only
|
|
10
|
+
sees the final answer;
|
|
11
|
+
5. the archived text returned by `recall` is compared byte for byte with the original output.
|
|
12
|
+
|
|
13
|
+
What it proves: archive, masking, recall (the proxy's own logic). What it does not prove: the quality of a real
|
|
14
|
+
model, tool calling of a real model, saved engine state (KV) — that needs a real engine (README, Tier 1).
|
|
15
|
+
Exit code 0 = PASS, 1 = FAIL.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from .core import RECEIPT_OPEN, OUT_PREFIX, Config, Journal, Store, TokenCounter
|
|
27
|
+
from .fake_engine import FakeEngine
|
|
28
|
+
from .server import Proxy
|
|
29
|
+
from .upstream import Upstream
|
|
30
|
+
|
|
31
|
+
SECRET = "BUILD-7f3a9c-ORCHID-42"
|
|
32
|
+
TOOLS = [{"type": "function", "function": {
|
|
33
|
+
"name": "bash", "description": "run a shell command",
|
|
34
|
+
"parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}]
|
|
35
|
+
SYSTEM = {"role": "system", "content": "You are a coding agent. Use the tools to inspect the project."}
|
|
36
|
+
QUESTION = "What was the build id printed in build.log?"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_log() -> str:
|
|
40
|
+
lines = []
|
|
41
|
+
for i in range(1, 181):
|
|
42
|
+
lines.append("[%04d] compiling module_%03d.c ... ok (%d ms)" % (i, i, 40 + (i * 37) % 300))
|
|
43
|
+
if i == 97:
|
|
44
|
+
lines.append("[%04d] build id: %s (keep this for the release notes)" % (i, SECRET))
|
|
45
|
+
return "\n".join(lines) + "\n"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def filler(k: int) -> str:
|
|
49
|
+
return "".join("test_%02d_%03d ... passed in %d.%02ds\n" % (k, j, j % 3, (j * 7) % 100) for j in range(150))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def call(cid: str, cmd: str) -> dict:
|
|
53
|
+
return {"role": "assistant", "content": "", "tool_calls": [
|
|
54
|
+
{"id": cid, "type": "function", "function": {"name": "bash", "arguments": json.dumps({"command": cmd})}}]}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ScriptedModel:
|
|
58
|
+
"""The fake model. It never sees anything the proxy did not send it."""
|
|
59
|
+
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self.recall_args = None
|
|
62
|
+
|
|
63
|
+
def __call__(self, body: dict) -> dict:
|
|
64
|
+
msgs = body.get("messages") or []
|
|
65
|
+
last = msgs[-1] if msgs else {}
|
|
66
|
+
if last.get("role") == "tool" and str(last.get("content", "")).startswith("[recall"):
|
|
67
|
+
m = re.search(r"build id: (\S+)", last["content"])
|
|
68
|
+
ans = ("The build id was %s (found in the archived build.log)." % m.group(1)) if m else \
|
|
69
|
+
"I could not find it."
|
|
70
|
+
return {"role": "assistant", "content": ans}
|
|
71
|
+
if last.get("role") == "user" and last.get("content") == QUESTION:
|
|
72
|
+
# the log is no longer in the prompt: only the receipt with its id is. Ask the proxy for the exact text.
|
|
73
|
+
for m in msgs:
|
|
74
|
+
c = str(m.get("content") or "")
|
|
75
|
+
if m.get("role") == "tool" and (c.startswith(RECEIPT_OPEN) or c.startswith(OUT_PREFIX)) \
|
|
76
|
+
and "cat build.log" in c:
|
|
77
|
+
rid = re.search(r"id=(\w+)", c)
|
|
78
|
+
if rid is None:
|
|
79
|
+
continue
|
|
80
|
+
self.recall_args = {"id": rid.group(1)}
|
|
81
|
+
return {"role": "assistant", "content": "", "tool_calls": [
|
|
82
|
+
{"id": "call_recall", "type": "function",
|
|
83
|
+
"function": {"name": "recall", "arguments": json.dumps(self.recall_args)}}]}
|
|
84
|
+
if any(SECRET in str(m.get("content")) for m in msgs):
|
|
85
|
+
return {"role": "assistant", "content": "The build id was %s (still in my prompt)." % SECRET}
|
|
86
|
+
return {"role": "assistant", "content": "I do not know."}
|
|
87
|
+
return {"role": "assistant", "content": "ok"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _p(out, s=""):
|
|
91
|
+
print(s, file=out, flush=True)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run(out=None, keep: str | None = None) -> bool:
|
|
95
|
+
out = out or sys.stdout
|
|
96
|
+
t0 = time.time()
|
|
97
|
+
checks: list[tuple[str, bool]] = []
|
|
98
|
+
_p(out, "mnemonic-proxy demo — offline, scripted fake model, temporary folder\n")
|
|
99
|
+
_p(out, "Proves: verbatim archive, masking (receipts), recall resolved inside the proxy.")
|
|
100
|
+
_p(out, "Does not prove: real model quality or tool calling, saved engine state (KV). See README, Tier 1.\n")
|
|
101
|
+
tmp = tempfile.TemporaryDirectory(prefix="mnemonic-demo-", ignore_cleanup_errors=True)
|
|
102
|
+
data = keep or tmp.name
|
|
103
|
+
os.makedirs(data, exist_ok=True)
|
|
104
|
+
model = ScriptedModel()
|
|
105
|
+
eng = FakeEngine(model, flavor="openai")
|
|
106
|
+
url = eng.start(0)
|
|
107
|
+
store = None
|
|
108
|
+
try:
|
|
109
|
+
cfg = Config.from_dict({"window": 16384, "mask_trigger": 6000, "mask_target": 3000,
|
|
110
|
+
"keep_recent_tokens": 1500, "min_batch_tokens": 1000, "min_age_turns": 2,
|
|
111
|
+
"reserve": 512, "default_response": 1024, "tail_max": 3000,
|
|
112
|
+
"notes_max_tokens": 1024, "recall_max_tokens": 6000})
|
|
113
|
+
store = Store(os.path.join(data, "archive.sqlite"))
|
|
114
|
+
proxy = Proxy(cfg, Upstream(url), store, Journal(os.path.join(data, "journal.jsonl")), TokenCounter(3.5))
|
|
115
|
+
log = build_log()
|
|
116
|
+
h = [SYSTEM, {"role": "user", "content": "The release build failed. Check build.log, then run the tests."},
|
|
117
|
+
call("call_log", "cat build.log"), {"role": "tool", "tool_call_id": "call_log", "content": log}]
|
|
118
|
+
|
|
119
|
+
def send(msgs):
|
|
120
|
+
st, r, _ = proxy.chat({"model": "local-model", "messages": msgs, "tools": TOOLS, "max_tokens": 512})
|
|
121
|
+
if st != 200:
|
|
122
|
+
raise RuntimeError("proxy returned HTTP %d: %s" % (st, r))
|
|
123
|
+
return r
|
|
124
|
+
|
|
125
|
+
_p(out, "1. The agent reads build.log: %d lines, %d chars; line 98 holds the build id %s."
|
|
126
|
+
% (log.count("\n"), len(log), SECRET))
|
|
127
|
+
masked_at = None
|
|
128
|
+
for k in range(1, 13):
|
|
129
|
+
r = send(h)
|
|
130
|
+
ctx = r["strata_context"]
|
|
131
|
+
if ctx["masked"] and masked_at is None:
|
|
132
|
+
masked_at = k
|
|
133
|
+
_p(out, "2. Turn %d: the session holds ~%d tokens; the proxy hid %d old output(s) (~%d tokens)."
|
|
134
|
+
% (k, ctx["virtual_tokens"], ctx["masked"], ctx["masked_tokens"]))
|
|
135
|
+
break
|
|
136
|
+
h += [call("call_t%d" % k, "pytest -q tests/part%d" % k),
|
|
137
|
+
{"role": "tool", "tool_call_id": "call_t%d" % k, "content": filler(k)}]
|
|
138
|
+
checks.append(("masking happened", masked_at is not None))
|
|
139
|
+
sent = eng.requests[-1]["messages"]
|
|
140
|
+
seen = next((m for m in sent if m.get("role") == "tool" and m.get("tool_call_id") == "call_log"), None)
|
|
141
|
+
hidden = seen is not None and SECRET not in str(seen.get("content")) and len(str(seen.get("content"))) < 600
|
|
142
|
+
checks.append(("the engine no longer receives build.log", hidden))
|
|
143
|
+
_p(out, "3. What the engine receives in place of build.log (%d chars instead of %d):"
|
|
144
|
+
% (len(str((seen or {}).get("content"))), len(log)))
|
|
145
|
+
_p(out, " " + str((seen or {}).get("content"))[:400].replace("\n", " "))
|
|
146
|
+
_p(out, " (receipts are in English for new conversations; conversations born before 0.3.1 keep Italian)")
|
|
147
|
+
n0 = len(eng.requests)
|
|
148
|
+
h += [{"role": "assistant", "content": "Tests pass."}, {"role": "user", "content": QUESTION}]
|
|
149
|
+
r = send(h)
|
|
150
|
+
answer = r["choices"][0]["message"].get("content") or ""
|
|
151
|
+
internal = len(eng.requests) - n0
|
|
152
|
+
_p(out, "4. The user asks: %r" % QUESTION)
|
|
153
|
+
_p(out, " The model called recall(%s); the proxy answered it itself (%d engine rounds, the client saw one"
|
|
154
|
+
" reply):" % (json.dumps(model.recall_args), internal))
|
|
155
|
+
_p(out, " " + answer)
|
|
156
|
+
checks.append(("recall resolved inside the proxy", model.recall_args is not None and internal == 2
|
|
157
|
+
and not r["choices"][0]["message"].get("tool_calls")))
|
|
158
|
+
checks.append(("answer contains the build id", SECRET in answer))
|
|
159
|
+
conv = r["strata_context"]["conversation_id"]
|
|
160
|
+
got = proxy.mgr.recall(conv, model.recall_args or {})
|
|
161
|
+
body = got.split("\n", 1)[1] if "\n" in got else ""
|
|
162
|
+
same = body == log
|
|
163
|
+
checks.append(("recall returns the original output byte for byte", same))
|
|
164
|
+
_p(out, "5. recall(%s) returns %d chars; identical to the original: %s"
|
|
165
|
+
% (json.dumps(model.recall_args), len(body), "yes" if same else "NO"))
|
|
166
|
+
except Exception as e: # noqa: BLE001
|
|
167
|
+
checks.append(("demo ran without errors: %r" % e, False))
|
|
168
|
+
finally:
|
|
169
|
+
eng.stop()
|
|
170
|
+
if store is not None:
|
|
171
|
+
store.db.close()
|
|
172
|
+
tmp.cleanup()
|
|
173
|
+
ok = bool(checks) and all(c for _, c in checks)
|
|
174
|
+
_p(out)
|
|
175
|
+
for name, c in checks:
|
|
176
|
+
_p(out, " [%s] %s" % ("ok" if c else "FAIL", name))
|
|
177
|
+
_p(out, "\n%s in %.1f s" % ("PASS" if ok else "FAIL", time.time() - t0))
|
|
178
|
+
if ok:
|
|
179
|
+
_p(out, "Next: a real model on CPU — README, Tier 1 (`mnemonic-proxy check` verifies the setup).")
|
|
180
|
+
return ok
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main(argv=None):
|
|
184
|
+
import argparse
|
|
185
|
+
ap = argparse.ArgumentParser(prog="mnemonic-proxy demo",
|
|
186
|
+
description="Offline demo: archive, masking and recall with a scripted fake model.")
|
|
187
|
+
ap.add_argument("--keep", metavar="DIR", help="keep the demo archive in DIR instead of a temporary folder")
|
|
188
|
+
a = ap.parse_args(argv)
|
|
189
|
+
sys.exit(0 if run(keep=a.keep) else 1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
ctxproxy/engines.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# Author: Maurizio Verde — LastReload
|
|
2
|
+
"""Motori dietro al proxy: che cosa sa fare ciascuno (docs/dev-notes/ENGINES-RESULT.md).
|
|
3
|
+
|
|
4
|
+
Il proxy è nato davanti a Strata; qui si descrive il motore e lo si riconosce all'avvio:
|
|
5
|
+
|
|
6
|
+
kind strata | llama.cpp | openai (generico OpenAI-compatibile, anche online)
|
|
7
|
+
slot_save salvataggio/ripristino della sessione su file (POST /slots/{id}?action=save|restore)
|
|
8
|
+
slot_id slot usato (llama-server con --parallel N: il proxy fissa `id_slot` nelle richieste)
|
|
9
|
+
status stato del motore normalizzato nella forma di /v1/status di Strata:
|
|
10
|
+
{"loaded", "started", "activity": {"requests", "in_flight"}}
|
|
11
|
+
- Strata: /v1/status così com'è;
|
|
12
|
+
- llama-server: /slots (id_task dello slot = contatore richieste, is_processing = in corso);
|
|
13
|
+
tokenize conteggio token esatto lato motore (POST /tokenize di llama-server)
|
|
14
|
+
n_ctx finestra di contesto (da /props o /slots; Strata: dalla configurazione)
|
|
15
|
+
|
|
16
|
+
ds4-server (https://github.com/antirez/ds4): niente /v1/status, /props, /slots, /tokenize; si riconosce da
|
|
17
|
+
`GET /v1/models`, i cui modelli hanno `owned_by: "ds4.c"` e `context_length` (= --ctx). Salva da solo lo stato su
|
|
18
|
+
disco (`--kv-disk-dir`, checkpoint per prefisso di testo) e ricorda il testo esatto delle tool call per id
|
|
19
|
+
(«exact DSML tool replay»): il proxy quindi non salva né archivia stato (slot_save spento) e non accorcia gli
|
|
20
|
+
argomenti delle chiamate (`mask_tool_args`), perché ds4 rimetterebbe nel prompt il testo originale preso per id.
|
|
21
|
+
|
|
22
|
+
Rilevamento (`detect`): /v1/status con `activity` -> Strata; /v1/models con `owned_by` "ds4.c" -> ds4;
|
|
23
|
+
/props con `default_generation_settings` ->
|
|
24
|
+
llama.cpp (salvataggi attivi se il server è partito con --slot-save-path: lo si prova con un'azione non valida,
|
|
25
|
+
che risponde 400 "Invalid action" se i salvataggi ci sono e 501 se mancano, senza toccare lo slot); altrimenti
|
|
26
|
+
OpenAI generico. `engine` in configurazione forza il tipo (auto = rileva).
|
|
27
|
+
|
|
28
|
+
`apply` spegne con un avviso le funzioni che richiedono i salvataggi quando il motore non li ha
|
|
29
|
+
(slot_save, mask_anchor, autosave/autorestore, seal_experimental, kv_archive) e, con llama.cpp, prende la finestra
|
|
30
|
+
da n_ctx se non è stata scritta in configurazione (scalando le soglie di serie, pensate per 128K).
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import dataclasses
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
KINDS = ("strata", "llama.cpp", "ds4", "openai")
|
|
39
|
+
ALIASES = {"llama": "llama.cpp", "llamacpp": "llama.cpp", "llama-server": "llama.cpp", "llama_cpp": "llama.cpp",
|
|
40
|
+
"ds4-server": "ds4", "ds4.c": "ds4", "dwarfstar": "ds4",
|
|
41
|
+
"generic": "openai", "generic-openai": "openai", "openai-compatible": "openai", "online": "openai"}
|
|
42
|
+
# funzioni che vivono di file di sessione del motore
|
|
43
|
+
NEEDS_SLOTS = ("slot_save", "mask_anchor", "autosave", "autorestore", "seal_experimental", "kv_archive")
|
|
44
|
+
# soglie di serie pensate per la finestra di Strata (131072): con una finestra più piccola si scalano (se non
|
|
45
|
+
# scritte in configurazione)
|
|
46
|
+
SCALED = ("mask_trigger", "mask_target", "keep_recent_tokens", "min_batch_tokens", "tail_max", "reserve",
|
|
47
|
+
"default_response", "notes_max_tokens", "anchor_min_tokens", "autosave_min_tokens", "autorestore_min_gain",
|
|
48
|
+
"pins_max_tokens", "recall_max_tokens", "recall_struct_max_tokens", "auto_recall_max_tokens",
|
|
49
|
+
"response_floor")
|
|
50
|
+
BASE_WINDOW = 131072
|
|
51
|
+
# floors when scaling down (small windows): below these a recall result or a handoff note is too short to be useful
|
|
52
|
+
SCALED_MIN = {"recall_max_tokens": 1024, "recall_struct_max_tokens": 1024, "notes_max_tokens": 1024,
|
|
53
|
+
"default_response": 1024, "auto_recall_max_tokens": 512, "pins_max_tokens": 512}
|
|
54
|
+
# 0.3.0: finestra minima del motore. Sotto 16K il proxy non parte (8K provato dal vivo una sola volta, dopo la
|
|
55
|
+
# correzione della recall: escluso finché non è provato su più corse). Consigliata >= 32K.
|
|
56
|
+
MIN_WINDOW = 16384
|
|
57
|
+
RECOMMENDED_WINDOW = 32768
|
|
58
|
+
# solo per sviluppo (test interni, prove su finestre piccole): non documentata nel README
|
|
59
|
+
ALLOW_SMALL_WINDOW_ENV = "MNEMONIC_PROXY_ALLOW_SMALL_WINDOW"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def small_window_allowed() -> bool:
|
|
63
|
+
return os.environ.get(ALLOW_SMALL_WINDOW_ENV, "").strip().lower() in ("1", "true", "yes")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ENGINE_FIX = ("restart the engine with a bigger window, e.g. `llama-server ... -c %d` or `ds4-server ... --ctx %d`"
|
|
67
|
+
% (RECOMMENDED_WINDOW, RECOMMENDED_WINDOW))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def window_problems(cfg, engine: Engine | None, explicit=()) -> list[tuple[str, str]]:
|
|
71
|
+
"""Finestre sotto il minimo -> [(evidence, fix)]: `window` scritta in configurazione e n_ctx del motore.
|
|
72
|
+
Vuota se tutto va bene o se MNEMONIC_PROXY_ALLOW_SMALL_WINDOW=1."""
|
|
73
|
+
if small_window_allowed():
|
|
74
|
+
return []
|
|
75
|
+
out = []
|
|
76
|
+
w = int(getattr(cfg, "window", 0) or 0)
|
|
77
|
+
if "window" in set(explicit or ()) and w and w < MIN_WINDOW:
|
|
78
|
+
out.append(("config window=%d: the minimum is %d tokens, recommended %d or more"
|
|
79
|
+
% (w, MIN_WINDOW, RECOMMENDED_WINDOW),
|
|
80
|
+
"remove \"window\" from the config (it is read from the engine) or set it to %d or more; "
|
|
81
|
+
"the engine window must be at least as big: %s" % (MIN_WINDOW, ENGINE_FIX)))
|
|
82
|
+
n = int(getattr(engine, "n_ctx", 0) or 0) if engine is not None else 0
|
|
83
|
+
if n and n < MIN_WINDOW:
|
|
84
|
+
out.append(("engine window n_ctx=%d: the minimum is %d tokens, recommended %d or more"
|
|
85
|
+
% (n, MIN_WINDOW, RECOMMENDED_WINDOW), ENGINE_FIX))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclasses.dataclass
|
|
90
|
+
class Engine:
|
|
91
|
+
kind: str = "strata"
|
|
92
|
+
slot_save: bool = True
|
|
93
|
+
slot_id: int = 0
|
|
94
|
+
status_kind: str | None = "strata" # strata | llama.cpp | None
|
|
95
|
+
tokenize: bool = False
|
|
96
|
+
n_ctx: int | None = None
|
|
97
|
+
model: str | None = None
|
|
98
|
+
detected: bool = False # True = rilevato dagli endpoint, False = forzato/di serie
|
|
99
|
+
notes: list = dataclasses.field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
# il contatore richieste di llama-server (id_task) cresce di più di 1 per richiesta: va riletto dopo
|
|
102
|
+
@property
|
|
103
|
+
def counter_after_call(self) -> bool:
|
|
104
|
+
return self.status_kind == "llama.cpp"
|
|
105
|
+
|
|
106
|
+
def summary(self) -> dict:
|
|
107
|
+
return {"kind": self.kind, "slot_save": self.slot_save, "slot_id": self.slot_id, "status": self.status_kind,
|
|
108
|
+
"tokenize": self.tokenize, "n_ctx": self.n_ctx, "model": self.model, "detected": self.detected,
|
|
109
|
+
"notes": self.notes}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def norm_kind(k: str | None) -> str:
|
|
113
|
+
k = (k or "auto").strip().lower()
|
|
114
|
+
k = ALIASES.get(k, k)
|
|
115
|
+
if k not in KINDS + ("auto",):
|
|
116
|
+
raise ValueError("engine sconosciuto %r (strata | llama.cpp | openai | auto)" % k)
|
|
117
|
+
return k
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get_json(up, path):
|
|
121
|
+
try:
|
|
122
|
+
st, _, data = up.raw("GET", path)
|
|
123
|
+
if st != 200:
|
|
124
|
+
return None
|
|
125
|
+
return json.loads(data)
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _slots_enabled(up, slot_id: int) -> bool | None:
|
|
131
|
+
"""llama-server: prova un'azione non valida. 400 'Invalid action' = salvataggi attivi; 501 = server senza
|
|
132
|
+
--slot-save-path. Lo slot non viene toccato. None = non si sa."""
|
|
133
|
+
try:
|
|
134
|
+
st, _, data = up.raw("POST", "/slots/%d?action=ctxproxy-probe" % slot_id, b"{}")
|
|
135
|
+
except Exception: # noqa: BLE001
|
|
136
|
+
return None
|
|
137
|
+
if st == 501:
|
|
138
|
+
return False
|
|
139
|
+
if st == 400 and b"action" in data.lower():
|
|
140
|
+
return True
|
|
141
|
+
if st in (404, 405):
|
|
142
|
+
return False
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def detect(up, forced: str | None = "auto", slot_id: int = 0) -> Engine:
|
|
147
|
+
"""Riconosce il motore dietro `up` (Upstream). Con `forced` diverso da auto prova solo quel tipo, ma legge
|
|
148
|
+
comunque n_ctx/salvataggi se gli endpoint rispondono."""
|
|
149
|
+
kind = norm_kind(forced)
|
|
150
|
+
s = _get_json(up, "/v1/status") if kind in ("auto", "strata") else None
|
|
151
|
+
if kind == "strata" or (kind == "auto" and isinstance(s, dict) and ("activity" in s or s.get("service") ==
|
|
152
|
+
"strata")):
|
|
153
|
+
e = Engine("strata", slot_save=True, slot_id=slot_id, status_kind="strata", detected=kind == "auto")
|
|
154
|
+
if s is None:
|
|
155
|
+
e.notes.append("/v1/status non risponde (motore spento o non raggiungibile)")
|
|
156
|
+
else:
|
|
157
|
+
# Strata espone la finestra in context (native = max_positions = --max-context): la finestra
|
|
158
|
+
# del motore viene letta qui, come llama.cpp la legge da /slots
|
|
159
|
+
ctx = s.get("context")
|
|
160
|
+
if isinstance(ctx, dict):
|
|
161
|
+
e.n_ctx = int(ctx.get("native") or ctx.get("max_positions") or 0) or None
|
|
162
|
+
return e
|
|
163
|
+
models = _get_json(up, "/v1/models") if kind in ("auto", "ds4") else None
|
|
164
|
+
ds4_models = [m for m in (models or {}).get("data") or [] if isinstance(m, dict)
|
|
165
|
+
and m.get("owned_by") == "ds4.c"] if isinstance(models, dict) else []
|
|
166
|
+
if kind == "ds4" or (kind == "auto" and ds4_models):
|
|
167
|
+
e = Engine("ds4", slot_save=False, slot_id=slot_id, status_kind=None, detected=kind == "auto")
|
|
168
|
+
if ds4_models:
|
|
169
|
+
m0 = ds4_models[0]
|
|
170
|
+
e.model = m0.get("name") or m0.get("id")
|
|
171
|
+
e.n_ctx = int(m0.get("context_length") or 0) or None
|
|
172
|
+
else:
|
|
173
|
+
e.notes.append("/v1/models non risponde come ds4-server (motore spento o non raggiungibile)")
|
|
174
|
+
return e
|
|
175
|
+
props = _get_json(up, "/props") if kind in ("auto", "llama.cpp") else None
|
|
176
|
+
if kind == "llama.cpp" or (kind == "auto" and isinstance(props, dict) and "default_generation_settings" in props):
|
|
177
|
+
e = Engine("llama.cpp", slot_save=False, slot_id=slot_id, status_kind="llama.cpp", tokenize=True,
|
|
178
|
+
detected=kind == "auto")
|
|
179
|
+
props = props or {}
|
|
180
|
+
e.model = os.path.basename(str(props.get("model_path") or "")) or None
|
|
181
|
+
slots = _get_json(up, "/slots")
|
|
182
|
+
if isinstance(slots, list):
|
|
183
|
+
mine = next((x for x in slots if x.get("id") == slot_id), None)
|
|
184
|
+
if mine is None:
|
|
185
|
+
e.notes.append("slot %d assente (slot: %s)" % (slot_id, [x.get("id") for x in slots]))
|
|
186
|
+
else:
|
|
187
|
+
e.n_ctx = int(mine.get("n_ctx") or 0) or None
|
|
188
|
+
if len(slots) > 1:
|
|
189
|
+
e.notes.append("%d slot (--parallel): il proxy fissa id_slot=%d nelle richieste" % (len(slots),
|
|
190
|
+
slot_id))
|
|
191
|
+
else:
|
|
192
|
+
e.status_kind = None
|
|
193
|
+
e.notes.append("/slots non disponibile (server con --no-slots): niente stato del motore")
|
|
194
|
+
if e.n_ctx is None:
|
|
195
|
+
dgs = props.get("default_generation_settings") or {}
|
|
196
|
+
e.n_ctx = int(dgs.get("n_ctx") or 0) or None
|
|
197
|
+
en = _slots_enabled(up, slot_id)
|
|
198
|
+
e.slot_save = bool(en) and e.status_kind is not None
|
|
199
|
+
if en is False:
|
|
200
|
+
e.notes.append("salvataggi spenti: avviare llama-server con --slot-save-path DIR")
|
|
201
|
+
return e
|
|
202
|
+
return Engine("openai", slot_save=False, slot_id=slot_id, status_kind=None, detected=kind == "auto")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def normalize_status(engine: Engine | None, up) -> dict | None:
|
|
206
|
+
"""Stato del motore nella forma di /v1/status di Strata (la usano Tracker e Archiver)."""
|
|
207
|
+
if engine is None or engine.status_kind == "strata":
|
|
208
|
+
return _get_json(up, "/v1/status")
|
|
209
|
+
if engine.status_kind == "llama.cpp":
|
|
210
|
+
slots = _get_json(up, "/slots")
|
|
211
|
+
if not isinstance(slots, list):
|
|
212
|
+
return None
|
|
213
|
+
mine = next((x for x in slots if x.get("id") == engine.slot_id), None)
|
|
214
|
+
if mine is None:
|
|
215
|
+
return None
|
|
216
|
+
return {"service": "llama.cpp", "loaded": True, "started": None,
|
|
217
|
+
"activity": {"requests": mine.get("id_task"), "in_flight": int(bool(mine.get("is_processing"))),
|
|
218
|
+
"n_prompt_tokens": mine.get("n_prompt_tokens")}}
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def apply(cfg, engine: Engine, journal=None, explicit: set | None = None, log=print) -> list[str]:
|
|
223
|
+
"""Adatta la configurazione al motore: spegne le funzioni senza supporto, finestra da n_ctx. -> avvisi."""
|
|
224
|
+
explicit = set(explicit or ())
|
|
225
|
+
warn = []
|
|
226
|
+
if not engine.slot_save:
|
|
227
|
+
off = [f for f in NEEDS_SLOTS if getattr(cfg, f, False) and (f != "autorestore" or cfg.autosave)]
|
|
228
|
+
for f in NEEDS_SLOTS:
|
|
229
|
+
if getattr(cfg, f, False):
|
|
230
|
+
setattr(cfg, f, False)
|
|
231
|
+
if off:
|
|
232
|
+
warn.append("engine %s has no saved-state support: disabled %s" % (engine.kind, ", ".join(off)))
|
|
233
|
+
if engine.kind == "ds4" and getattr(cfg, "mask_tool_args", False) and getattr(cfg, "ds4_exact_tool_replay", True):
|
|
234
|
+
# ds4 rimette nel prompt il testo campionato della chiamata, preso per id: gli argomenti accorciati dal
|
|
235
|
+
# proxy non arriverebbero al modello (nessun risparmio) ma il proxy li conterebbe come risparmiati
|
|
236
|
+
cfg.mask_tool_args = False
|
|
237
|
+
warn.append("engine ds4 replays sampled tool calls by id: disabled mask_tool_args (set "
|
|
238
|
+
"ds4_exact_tool_replay=false only if ds4-server runs with --disable-exact-dsml-tool-replay)")
|
|
239
|
+
if engine.n_ctx and "window" not in explicit and engine.kind in ("llama.cpp", "ds4", "strata") \
|
|
240
|
+
and engine.n_ctx != cfg.window:
|
|
241
|
+
ratio = engine.n_ctx / float(cfg.window or BASE_WINDOW)
|
|
242
|
+
old = cfg.window
|
|
243
|
+
cfg.window = engine.n_ctx
|
|
244
|
+
scaled = {}
|
|
245
|
+
for f in SCALED:
|
|
246
|
+
if f in explicit or not hasattr(cfg, f):
|
|
247
|
+
continue
|
|
248
|
+
v = getattr(cfg, f)
|
|
249
|
+
if isinstance(v, int) and v > 0:
|
|
250
|
+
nv = max(64, SCALED_MIN.get(f, 0) if ratio < 1 else 0, int(v * ratio))
|
|
251
|
+
nv = min(nv, v) if ratio < 1 else nv
|
|
252
|
+
if nv != v:
|
|
253
|
+
setattr(cfg, f, nv)
|
|
254
|
+
scaled[f] = nv
|
|
255
|
+
warn.append("window from engine n_ctx: %d (was %d); thresholds scaled x%.3f" % (cfg.window, old, ratio))
|
|
256
|
+
if journal is not None:
|
|
257
|
+
journal.log("engine_window", window=cfg.window, was=old, scaled=scaled)
|
|
258
|
+
# una risposta minima che da sola riempie (quasi) la finestra fa scattare un cambio di segmento a ogni richiesta
|
|
259
|
+
floor = int(getattr(cfg, "response_floor", 0) or 0)
|
|
260
|
+
if floor and cfg.window and floor > cfg.window // 4:
|
|
261
|
+
cfg.response_floor = cfg.window // 4
|
|
262
|
+
warn.append("response_floor %d too large for window %d: lowered to %d" % (floor, cfg.window,
|
|
263
|
+
cfg.response_floor))
|
|
264
|
+
for w in warn:
|
|
265
|
+
if log:
|
|
266
|
+
log("[mnemonic-proxy] warning: " + w)
|
|
267
|
+
if journal is not None:
|
|
268
|
+
journal.log("engine_warning", message=w)
|
|
269
|
+
if journal is not None:
|
|
270
|
+
journal.log("engine", **engine.summary())
|
|
271
|
+
return warn
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def setup(cfg, up, journal=None, explicit=None, counter=None, log=print) -> Engine:
|
|
275
|
+
"""All'avvio del server: rileva (o forza) il motore, lo collega all'Upstream, adatta la configurazione e, se
|
|
276
|
+
chiesto e possibile, usa /tokenize del motore per contare i token."""
|
|
277
|
+
eng = detect(up, getattr(cfg, "engine", "auto"), int(getattr(cfg, "slot_id", 0) or 0))
|
|
278
|
+
if hasattr(up, "set_engine"):
|
|
279
|
+
up.set_engine(eng, getattr(cfg, "slot_dir", "") or "")
|
|
280
|
+
apply(cfg, eng, journal, explicit, log)
|
|
281
|
+
if counter is not None and getattr(cfg, "engine_tokenize", False) and eng.tokenize and not counter.exact:
|
|
282
|
+
counter.tk = RemoteTokenizer(up)
|
|
283
|
+
counter.kind = "esatto:/tokenize"
|
|
284
|
+
return eng
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class RemoteTokenizer:
|
|
288
|
+
"""Tokenizer via POST /tokenize di llama-server, con l'interfaccia minima di `tokenizers` usata da
|
|
289
|
+
TokenCounter (encode(text, add_special_tokens=False).ids). TokenCounter mette in cache i conteggi."""
|
|
290
|
+
|
|
291
|
+
class _Enc:
|
|
292
|
+
def __init__(self, ids):
|
|
293
|
+
self.ids = ids
|
|
294
|
+
|
|
295
|
+
def __init__(self, up):
|
|
296
|
+
self.up = up
|
|
297
|
+
|
|
298
|
+
def encode(self, text, add_special_tokens=False):
|
|
299
|
+
st, _, data = self.up.raw("POST", "/tokenize", json.dumps({"content": text, "add_special": bool(
|
|
300
|
+
add_special_tokens)}, ensure_ascii=False).encode("utf-8"))
|
|
301
|
+
if st != 200:
|
|
302
|
+
raise RuntimeError("tokenize HTTP %d" % st)
|
|
303
|
+
return self._Enc(json.loads(data).get("tokens") or [])
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def slot_error_status(engine: Engine | None, action: str, status: int, body: bytes, slot_dir: str = "",
|
|
307
|
+
filename: str = "") -> int:
|
|
308
|
+
"""llama-server risponde 400 anche per un file mancante: lo si riporta a 404 (come Strata) se il file non c'è
|
|
309
|
+
nella slot_dir, così autorestore lo segna come cancellato."""
|
|
310
|
+
if engine is None or engine.kind != "llama.cpp" or action != "restore" or status != 400:
|
|
311
|
+
return status
|
|
312
|
+
if slot_dir and filename and not os.path.exists(os.path.join(slot_dir, filename)):
|
|
313
|
+
return 404
|
|
314
|
+
return status
|