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