mirror of
https://github.com/nethical6/conversation-steganography.git
synced 2026-07-22 01:55:08 +02:00
163 lines
6.7 KiB
Python
163 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Persistent deterministic MLX-LM backend for Conversation Stenography on Apple Silicon."""
|
|
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def reply(**values):
|
|
print(json.dumps({"ok": True, **values}, separators=(",", ":")), flush=True)
|
|
|
|
|
|
def fail(exc):
|
|
print(json.dumps({"ok": False, "error": str(exc)}, separators=(",", ":")), flush=True)
|
|
|
|
|
|
def weight_identity(model_name):
|
|
path = Path(model_name).expanduser()
|
|
if not path.is_dir():
|
|
return model_name
|
|
files = []
|
|
for item in sorted(path.glob("model*.safetensors")):
|
|
resolved = item.resolve()
|
|
files.append(resolved.name + ":" + str(resolved.stat().st_size))
|
|
return files
|
|
|
|
|
|
DISALLOWED_WORDS = {
|
|
"assistant", "example", "format", "input", "instruction", "instructions",
|
|
"message", "messages", "metadata", "note", "output", "prompt", "prompts",
|
|
"recipient", "recipients", "response", "role", "sender", "sent", "system",
|
|
"timestamp", "transcript", "user", "analysis",
|
|
}
|
|
|
|
|
|
def ordinary_visible_token(text):
|
|
if not text or any(ch in text for ch in '\r\n\t0123456789#{}[]()<>*`|\\\":~&=+_^%$@/“”„‟«»()【】'):
|
|
return False
|
|
return text.lower().strip(" .,!?;'-_") not in DISALLOWED_WORDS
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--model", required=True)
|
|
parser.add_argument("--revision", default="main")
|
|
args = parser.parse_args()
|
|
|
|
import mlx.core as mx
|
|
import mlx_lm
|
|
import numpy as np
|
|
from mlx_lm import load
|
|
from mlx_lm.models.cache import make_prompt_cache
|
|
|
|
model, tokenizer = load(args.model, revision=args.revision)
|
|
tokenizer_impl = getattr(tokenizer, "_tokenizer", tokenizer)
|
|
identity = json.dumps({
|
|
"runtime": "mlx",
|
|
"model": weight_identity(args.model),
|
|
"revision": args.revision,
|
|
"tokenizer": tokenizer_impl.__class__.__name__,
|
|
"vocab_size": len(tokenizer_impl),
|
|
"mlx": getattr(mx, "__version__", "unknown"),
|
|
"mlx_lm": getattr(mlx_lm, "__version__", "unknown"),
|
|
}, sort_keys=True).encode()
|
|
fingerprint = "mlx:" + hashlib.sha256(identity).hexdigest()
|
|
special = set(getattr(tokenizer_impl, "all_special_ids", []))
|
|
token_text_cache = {}
|
|
|
|
def token_text(token_id):
|
|
if token_id not in token_text_cache:
|
|
token_text_cache[token_id] = tokenizer_impl.decode(
|
|
[token_id], skip_special_tokens=False, clean_up_tokenization_spaces=False
|
|
)
|
|
return token_text_cache[token_id]
|
|
|
|
for token, token_id in tokenizer_impl.get_vocab().items():
|
|
if token.startswith("<|") and token.endswith("|>"):
|
|
special.add(token_id)
|
|
cached_tokens = None
|
|
prompt_cache = None
|
|
# Snapshot of the KV state at the start of the most recent generation
|
|
# (after processing the prompt prefix, before any generated tokens).
|
|
# Reused to skip re-prefilling the identical prompt for every trial.
|
|
gen_start_tokens = None
|
|
gen_start_cache = None
|
|
|
|
for line in sys.stdin:
|
|
try:
|
|
request = json.loads(line)
|
|
op = request["op"]
|
|
if op == "info":
|
|
reply(fingerprint=fingerprint)
|
|
elif op == "tokenize":
|
|
ids = tokenizer_impl.encode(request.get("text", ""), add_special_tokens=False)
|
|
reply(tokens=ids)
|
|
elif op == "detokenize":
|
|
text = tokenizer_impl.decode(request.get("tokens", []), skip_special_tokens=False,
|
|
clean_up_tokenization_spaces=False)
|
|
reply(text=text)
|
|
elif op == "next":
|
|
ids = request["tokens"]
|
|
top_n = int(request["top_n"])
|
|
visible_tokens = request.get("visible_tokens")
|
|
if not ids:
|
|
raise ValueError("model context cannot be empty")
|
|
|
|
if cached_tokens is not None and ids[:-1] == cached_tokens:
|
|
# Continue current generation — only decode the new token.
|
|
logits = model(mx.array([[ids[-1]]]), cache=prompt_cache)[0, -1]
|
|
elif gen_start_tokens is not None and ids[:-1] == gen_start_tokens:
|
|
# New trial with the same prompt prefix — restore the KV
|
|
# snapshot so we avoid re-prefilling the whole context.
|
|
prompt_cache = copy.deepcopy(gen_start_cache)
|
|
logits = model(mx.array([[ids[-1]]]), cache=prompt_cache)[0, -1]
|
|
else:
|
|
# New context: full prefill required.
|
|
prompt_cache = make_prompt_cache(model)
|
|
gen_start_tokens = None
|
|
gen_start_cache = None
|
|
if len(ids) > 1:
|
|
model(mx.array([ids[:-1]]), cache=prompt_cache)
|
|
mx.eval() # materialise cache before snapshotting
|
|
gen_start_tokens = list(ids[:-1])
|
|
gen_start_cache = copy.deepcopy(prompt_cache)
|
|
logits = model(mx.array([[ids[-1]]]), cache=prompt_cache)[0, -1]
|
|
mx.eval(logits)
|
|
scores = np.asarray(logits, dtype=np.float32)
|
|
if special:
|
|
scores[list(special)] = -np.inf
|
|
pool_n = top_n if visible_tokens is None else min(len(scores), top_n * 4)
|
|
indices = np.argpartition(-scores, pool_n - 1)[:pool_n]
|
|
indices = sorted(indices, key=lambda i: (-float(scores[i]), int(i)))
|
|
candidates = []
|
|
tail = list(visible_tokens[-8:]) if visible_tokens else []
|
|
for i in indices:
|
|
i = int(i)
|
|
if visible_tokens is not None:
|
|
probe = tail + [i]
|
|
probe_text = tokenizer_impl.decode(probe, skip_special_tokens=False,
|
|
clean_up_tokenization_spaces=False)
|
|
encoded = tokenizer_impl.encode(probe_text, add_special_tokens=False)
|
|
if encoded != probe:
|
|
continue
|
|
candidates.append({"id": i, "score": float(scores[i]), "text": token_text(i)})
|
|
if len(candidates) == top_n:
|
|
break
|
|
if len(candidates) != top_n:
|
|
raise ValueError(f"only {len(candidates)} copy-safe candidates available; need {top_n}")
|
|
cached_tokens = list(ids)
|
|
reply(candidates=candidates)
|
|
else:
|
|
raise ValueError("unknown operation: " + str(op))
|
|
except Exception as exc:
|
|
fail(exc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|