File size: 8,319 Bytes
c27193b 730c5bb c27193b 730c5bb c27193b 730c5bb c27193b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | """Talk to what you just trained — Ollama helpers, ported from this house's own consoles.
Provenance: `pythaiml/automindx · ollama_codephreak.py` (the live model picker, streaming `/api/chat`,
a token counter built from the engine's final object, errors written as instructions) and
`Professor-Codephreak/automind · memory.py` (one record per exchange). Two years of interfaces agreed
on one rule and it is kept here: **never block the interface on a model load** — the daemon holds the
weights, the UI asks it questions.
Nothing in this module imports torch. A training box that cannot spare the RAM can still use the
console, because the model lives in Ollama's process, not in this one.
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from collections.abc import Iterator
from pathlib import Path
from typing import Any
HOST = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
# The standard set, with the defaults Ollama itself uses. The UI renders these; the engine honours
# every one of them, so nothing here is decorative.
DEFAULTS: dict[str, Any] = {
"temperature": 0.7, "top_p": 0.9, "top_k": 40, "min_p": 0.0,
"repeat_penalty": 1.1, "repeat_last_n": 64, "num_predict": 256, "num_ctx": 4096,
"seed": 0, "stop": [], "presence_penalty": 0.0, "frequency_penalty": 0.0,
"mirostat": 0, "mirostat_tau": 5.0, "mirostat_eta": 0.1,
}
# The imprint gate's decoding. A score measured under anything else is not comparable with the
# numbers in an ascent log, and saying so is cheaper than explaining a mismatch later.
GATE_DECODING: dict[str, Any] = {"temperature": 0.0, "repeat_penalty": 1.3, "top_p": 1.0, "top_k": 0}
# Options whose value here is also the engine's own default when the key is absent. Sending them
# changes nothing for Ollama, but an engine that refuses penalties / mirostat outright (bankml:
# "not reproduced") would refuse the whole request — so `chat` leaves them out at these values.
# A deliberate non-default value is always sent; the engine then honours or refuses it, visibly.
ENGINE_DEFAULTS: dict[str, Any] = {
"repeat_penalty": 1.1, "presence_penalty": 0.0, "frequency_penalty": 0.0, "typical_p": 1.0,
"mirostat": 0,
}
def wire_options(options: dict[str, Any] | None) -> dict[str, Any]:
"""The options actually sent: empties dropped, and penalty/mirostat keys left at the engine's
own default omitted (mirostat's tau/eta only matter, and are only sent, when mirostat is on)."""
opts = {k: v for k, v in (options or {}).items() if v not in (None, "", [])}
miro_on = bool(opts.get("mirostat"))
out: dict[str, Any] = {}
for k, v in opts.items():
if k in ("mirostat_tau", "mirostat_eta") and not miro_on:
continue
if k in ENGINE_DEFAULTS and v == ENGINE_DEFAULTS[k]:
continue
out[k] = v
return out
def models(host: str = "", timeout: float = 4.0) -> list[str]:
"""Tags the daemon is serving, local first. Empty when it is down — never an exception."""
try:
with urllib.request.urlopen((host or HOST).rstrip("/") + "/api/tags", timeout=timeout) as r:
names = [m["name"] for m in json.loads(r.read().decode()).get("models", [])]
except Exception:
return []
names.sort(key=lambda n: (n.endswith(":cloud"), n))
return names
def running(host: str = "", timeout: float = 4.0) -> list[dict[str, Any]]:
"""What is resident right now, with its size and expiry — the honest answer to "is it warm?"."""
try:
with urllib.request.urlopen((host or HOST).rstrip("/") + "/api/ps", timeout=timeout) as r:
return json.loads(r.read().decode()).get("models", []) or []
except Exception:
return []
def token_line(done: dict[str, Any]) -> str:
prompt = int(done.get("prompt_eval_count") or 0)
completion = int(done.get("eval_count") or 0)
eval_s = (done.get("eval_duration") or 0) / 1e9
load_s = (done.get("load_duration") or 0) / 1e9
rate = f" · {completion / eval_s:.2f} tok/s" if eval_s > 0 else ""
load = f" · {load_s:.1f}s load" if load_s > 0.5 else ""
return f"{prompt + completion} tokens — {prompt} prompt + {completion} completion{rate}{load}"
def token_stats(done: dict[str, Any]) -> dict[str, Any]:
prompt = int(done.get("prompt_eval_count") or 0)
completion = int(done.get("eval_count") or 0)
eval_s = (done.get("eval_duration") or 0) / 1e9
return {"prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": prompt + completion,
"tokens_per_s": round(completion / eval_s, 2) if eval_s > 0 else None,
"load_s": round((done.get("load_duration") or 0) / 1e9, 2),
"eval_s": round(eval_s, 2), "done_reason": done.get("done_reason")}
def explain(status: int, body: str, model: str, host: str = "") -> str:
text = (body or "")[:400]
if status == 404 or "not found" in text.lower():
return f"**{model}** is not on this daemon:\n\n```bash\nollama pull {model}\n```\n\nOr serve a checkpoint you trained: `mindxtrain serve --to ollama --tag {model}`."
if status in (401, 402, 403) or "subscription" in text.lower():
return f"**{model}** looks like a cloud tag needing a subscription. Use a local tag instead."
if "context" in text.lower():
return "The prompt is longer than `num_ctx`. Raise it, or send less."
return f"Ollama answered **{status}** from `{host or HOST}`:\n\n```\n{text}\n```"
def unreachable(host: str = "") -> str:
return (f"No Ollama at `{host or HOST}`. Start it (`ollama serve`), then either pull a tag or serve "
"a checkpoint from the **Serve** room. The UI stays up either way — it never holds the weights.")
def chat(messages: list[dict[str, str]], model: str, *, options: dict[str, Any] | None = None,
keep_alive: str = "10m", host: str = "", timeout: float = 900.0) -> Iterator[tuple[str, dict[str, Any]]]:
"""Stream `(text_so_far, stats)`. `stats` is empty until the final object, which carries the real
counts. Options are passed through as `wire_options` leaves them: a penalty or mirostat key at
the engine's own default is omitted (identical for Ollama, and accepted by bankml)."""
host = (host or HOST).rstrip("/")
body = {"model": model, "messages": messages, "stream": True, "keep_alive": keep_alive,
"options": wire_options(options)}
req = urllib.request.Request(f"{host}/api/chat", data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json"})
acc = ""
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
for raw in r:
line = raw.decode("utf-8", errors="replace").strip()
if not line:
continue
try:
obj = json.loads(line)
except Exception:
continue
acc += (obj.get("message") or {}).get("content", "")
if obj.get("done"):
yield acc, token_stats(obj)
return
if acc:
yield acc, {}
except urllib.error.HTTPError as e:
yield explain(e.code, e.read().decode("utf-8", errors="replace"), model, host), {"error": True}
return
except Exception:
yield unreachable(host), {"error": True}
return
if not acc:
yield "[the model returned nothing — raise num_predict, or try another tag]", {"error": True}
def record(prompt: str, answer: str, meta: dict[str, Any] | None = None, path: Path | None = None) -> Path | None:
"""One JSONL line per exchange, so a session leaves evidence rather than scrollback."""
try:
p = Path(path or os.environ.get("MINDXTRAIN_CONSOLE_LOG") or (Path.home() / ".mindxtrain" / "console.jsonl"))
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a", encoding="utf-8") as fh:
fh.write(json.dumps({"ts": time.time(), "prompt": prompt[:4000],
"answer": (answer or "")[:8000], **(meta or {})}, ensure_ascii=False) + "\n")
return p
except Exception:
return None
|