Download chat.py from North-ML1/ink: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/North-ML1/ink/resolve/main/chat.py
- Command line
-
hf download hf://North-ML1/ink/chat.py
-
curl -L -o chat.py https://huggingface.co/North-ML1/ink/resolve/main/chat.py
11.4 kB
| """Interactive chat with a North ML Flare model. | |
| REMOTE (recommended — runs the model on an SSH device, nothing to download): | |
| python3 chat.py --ssh mac-mini | |
| python3 chat.py --ssh mac-mini --model fiber-1 | |
| LOCAL (downloads HF weights, runs on this machine): | |
| python3 chat.py --model North-ML1/fiber-pro | |
| Remote needs: ssh access to the host + NORTH_API_KEY or ~/.config/northml/api.key | |
| Local needs: pip install torch transformers safetensors huggingface_hub | |
| Commands: /model /search /think /new /quit | |
| """ | |
| import argparse, json, os, sys, threading, importlib.util | |
| IM_S = "<" + "|im_start" + "|>" | |
| IM_E = "<" + "|im_end" + "|>" | |
| TH_S = "<" + "think" + ">" | |
| TH_E = "<" + "/think" + ">" | |
| EOT = "<" + "|endoftext" + "|>" | |
| DIM = "\033[2m"; CYAN = "\033[36m"; BOLD = "\033[1m"; RESET = "\033[0m"; GRAY = "\033[90m" | |
| if not sys.stdout.isatty(): | |
| DIM = CYAN = BOLD = RESET = GRAY = "" | |
| # ============================================================ remote (ssh) | |
| REMOTE_PORT = 8095 | |
| LOCAL_PORT = 18095 | |
| def get_key(): | |
| k = os.environ.get("NORTH_API_KEY", "").strip() | |
| if k: | |
| return k | |
| p = os.path.expanduser("~/.config/northml/api.key") | |
| if os.path.exists(p): | |
| return open(p).read().strip() | |
| sys.exit("No API key. Set NORTH_API_KEY or write it to " + p) | |
| def health(): | |
| import urllib.request | |
| try: | |
| return urllib.request.urlopen( | |
| "http://127.0.0.1:%d/health" % LOCAL_PORT, timeout=3).status == 200 | |
| except Exception: | |
| return False | |
| def ensure_tunnel(host): | |
| import subprocess, time | |
| if health(): | |
| return | |
| print("%sopening ssh tunnel to %s...%s" % (DIM, host, RESET)) | |
| subprocess.Popen( | |
| ["ssh", "-N", "-L", "%d:127.0.0.1:%d" % (LOCAL_PORT, REMOTE_PORT), | |
| "-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=15", host], | |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
| for _ in range(40): | |
| if health(): | |
| print("%stunnel up on :%d%s" % (DIM, LOCAL_PORT, RESET)) | |
| return | |
| time.sleep(0.5) | |
| sys.exit("Tunnel failed — is `ssh %s` working and the API running?" % host) | |
| def ask(key, model, messages, search, think): | |
| import urllib.request | |
| req = urllib.request.Request( | |
| "http://127.0.0.1:%d/v1/chat/completions" % LOCAL_PORT, | |
| data=json.dumps({"model": model, "messages": messages, "stream": True, | |
| "search": search, "think": think}).encode(), | |
| headers={"Authorization": "Bearer " + key, | |
| "Content-Type": "application/json"}) | |
| return urllib.request.urlopen(req, timeout=180) | |
| def handle_sse(resp): | |
| answer, thinking = [], False | |
| for raw in resp: | |
| line = raw.decode("utf-8", "replace").strip() | |
| if not line.startswith("data: "): | |
| continue | |
| payload = line[6:] | |
| if payload == "[DONE]": | |
| break | |
| try: | |
| ev = json.loads(payload) | |
| except json.JSONDecodeError: | |
| continue | |
| agent = ev.get("agent") | |
| if agent: | |
| t = agent.get("type") | |
| if t == "status": | |
| print("\n%s ▸ %s%s" % (GRAY, agent.get("stage", t), RESET), flush=True) | |
| elif t == "route": | |
| print("%s ▸ route: search=%s think=%s%s" % ( | |
| GRAY, agent.get("search"), agent.get("think"), RESET), flush=True) | |
| elif t == "search": | |
| print("%s ▸ query: %s [%s]%s" % ( | |
| GRAY, agent.get("query", ""), agent.get("engine", ""), RESET), flush=True) | |
| elif t == "source": | |
| mark = "✓" if agent.get("kept") else "·" | |
| print("%s %s %s %s%s" % (GRAY, mark, agent.get("title", "?"), | |
| agent.get("url", ""), RESET)) | |
| elif t == "thinking": | |
| print("%s%s%s" % (DIM, agent.get("text", ""), RESET), end="", flush=True) | |
| thinking = True | |
| elif t == "verify": | |
| print("%s ▸ verify: %s%s" % ( | |
| GRAY, str(agent.get("verdict", agent.get("text", "")))[:160], RESET), | |
| flush=True) | |
| elif t == "error": | |
| print("\033[31m error: %s%s" % (agent.get("message", agent), RESET)) | |
| continue | |
| for ch in ev.get("choices") or []: | |
| d = ch.get("delta", {}).get("content") | |
| if d: | |
| if thinking: | |
| print() | |
| thinking = False | |
| print(d, end="", flush=True) | |
| answer.append(d) | |
| print() | |
| return "".join(answer) | |
| def remote_loop(host, model): | |
| import urllib.error | |
| key = get_key() | |
| ensure_tunnel(host) | |
| search, think = "auto", "auto" | |
| history = [] | |
| print("%sNorth ML chat — model on %s%s" % (BOLD, host, RESET)) | |
| print("model: %s%s%s · /model /search /think /new /quit\n" % (CYAN, model, RESET)) | |
| while True: | |
| try: | |
| q = input("%syou > %s" % (BOLD, RESET)).strip() | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| break | |
| if not q: | |
| continue | |
| if q.startswith("/"): | |
| parts = q.split() | |
| cmd = parts[0] | |
| if cmd in ("/quit", "/q"): | |
| break | |
| if cmd == "/new": | |
| history = [] | |
| print("%s(history cleared)%s" % (DIM, RESET)) | |
| continue | |
| if cmd == "/model" and len(parts) > 1: | |
| model = parts[1] | |
| continue | |
| if cmd == "/search" and len(parts) > 1: | |
| search = parts[1] | |
| continue | |
| if cmd == "/think" and len(parts) > 1: | |
| think = parts[1] | |
| continue | |
| print("%scommands: /model ink-1|fiber-1|fiber-1-pro " | |
| "/search auto|on|off /think auto|on|off /new /quit%s" % (DIM, RESET)) | |
| continue | |
| history.append({"role": "user", "content": q}) | |
| try: | |
| ans = handle_sse(ask(key, model, history, search, think)) | |
| history.append({"role": "assistant", "content": ans}) | |
| except urllib.error.HTTPError as e: | |
| print("%sHTTP %s: %s%s" % (DIM, e.code, e.read()[:200].decode("utf-8", "replace"), RESET)) | |
| history.pop() | |
| except Exception as e: | |
| print("%serror: %s%s" % (DIM, e, RESET)) | |
| history.pop() | |
| # ============================================================== local (hf) | |
| def pick_device(): | |
| import torch | |
| if torch.cuda.is_available(): | |
| return "cuda", torch.bfloat16 | |
| if torch.backends.mps.is_available(): | |
| return "mps", torch.float16 | |
| return "cpu", torch.float32 | |
| def load(repo): | |
| import torch | |
| from huggingface_hub import snapshot_download | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| local = snapshot_download(repo) if not os.path.isdir(repo) else repo | |
| device, dtype = pick_device() | |
| tok = AutoTokenizer.from_pretrained(local) | |
| model = AutoModelForCausalLM.from_pretrained(local, dtype=dtype).to(device).eval() | |
| arch_p = os.path.join(local, "starlight_arch.py") | |
| reg_p = os.path.join(local, "agent_register.pt") | |
| meta_p = os.path.join(local, "starlight.json") | |
| if all(os.path.exists(p) for p in (arch_p, reg_p, meta_p)): | |
| spec = importlib.util.spec_from_file_location("starlight_arch", arch_p) | |
| arch = importlib.util.module_from_spec(spec) | |
| spec.loader.exec_module(arch) | |
| meta = json.load(open(meta_p)) | |
| regs = arch.attach_registers(model, arch.hidden_size_of(model), | |
| slots=int(meta.get("slots", 16))) | |
| regs.load_state_dict(torch.load(reg_p, map_location="cpu")["registers"]) | |
| regs.to(device=device, dtype=dtype) | |
| print("registers attached (%s slots)" % meta.get("slots", 16)) | |
| print("loaded on %s (%s)" % (device, dtype)) | |
| return model, tok, device | |
| def render(history, think): | |
| s = "" | |
| for role, text in history: | |
| s += IM_S + role + "\n" + text + IM_E + "\n" | |
| s += IM_S + "assistant\n" | |
| if think: | |
| s += TH_S + "\n" | |
| return s | |
| def gen_stream(model, tok, device, input_ids, cap, eos_ids): | |
| import torch | |
| from transformers import TextIteratorStreamer | |
| streamer = TextIteratorStreamer(tok, skip_special_tokens=True, skip_prompt=True) | |
| holder = {} | |
| def _run(): | |
| holder["out"] = model.generate( | |
| input_ids=input_ids, max_new_tokens=cap, do_sample=False, | |
| repetition_penalty=1.05, eos_token_id=eos_ids, | |
| pad_token_id=tok.eos_token_id, streamer=streamer) | |
| t = threading.Thread(target=_run) | |
| t.start() | |
| text = "" | |
| for chunk in streamer: | |
| text += chunk | |
| sys.stdout.write(chunk) | |
| sys.stdout.flush() | |
| t.join() | |
| return text, holder["out"] | |
| def chat_turn(model, tok, device, history, think): | |
| import torch | |
| im_end = tok.convert_tokens_to_ids(IM_E) | |
| eot = tok.convert_tokens_to_ids(EOT) | |
| th_e = tok.convert_tokens_to_ids(TH_E) | |
| eos = list({i for i in (tok.eos_token_id, im_end, eot) if i is not None}) | |
| prompt = render(history, think) | |
| ids = tok(prompt, add_special_tokens=False, return_tensors="pt").input_ids.to(device) | |
| if think: | |
| sys.stdout.write("\n[think] ") | |
| th_text, out1 = gen_stream(model, tok, device, ids, 220, eos + [th_e]) | |
| if th_e not in out1[0].tolist()[ids.shape[1]:]: | |
| extra = tok(TH_E + "\n", add_special_tokens=False, | |
| return_tensors="pt").input_ids.to(device) | |
| ids2 = torch.cat([out1, extra], dim=1) | |
| else: | |
| ids2 = out1 | |
| sys.stdout.write("\n[answer] ") | |
| ans_text, _ = gen_stream(model, tok, device, ids2, 500, eos) | |
| sys.stdout.write("\n") | |
| return ans_text | |
| ans_text, _ = gen_stream(model, tok, device, ids, 500, eos) | |
| sys.stdout.write("\n") | |
| return ans_text | |
| def local_loop(repo, think): | |
| print("downloading %s ..." % repo) | |
| model, tok, device = load(repo) | |
| history = [] | |
| print("\nReady. Ask anything. /think on|off /new /quit\n") | |
| while True: | |
| try: | |
| q = input("you> ").strip() | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| break | |
| if not q: | |
| continue | |
| if q in ("/quit", "/q", "exit"): | |
| break | |
| if q == "/new": | |
| history = [] | |
| print("(cleared)\n") | |
| continue | |
| if q.startswith("/think"): | |
| think = "off" not in q | |
| print("think =", "on" if think else "off", "\n") | |
| continue | |
| history.append(("user", q)) | |
| ans = chat_turn(model, tok, device, history, think) | |
| history.append(("assistant", ans.strip())) | |
| sys.stdout.write("\n") | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--ssh", metavar="HOST", default="", | |
| help="ssh host running the North ML API (e.g. mac-mini)") | |
| ap.add_argument("--model", default="", | |
| help="remote: ink-1|fiber-1|fiber-1-pro · local: HF repo id") | |
| ap.add_argument("--no-think", action="store_true") | |
| a = ap.parse_args() | |
| if a.ssh: | |
| remote_loop(a.ssh, a.model or "fiber-1-pro") | |
| else: | |
| local_loop(a.model or "North-ML1/fiber-pro", not a.no_think) | |
| if __name__ == "__main__": | |
| main() | |