tuieval 0.2.0.dev11__py3-none-any.whl → 0.2.0.dev23__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.
- tuieval/_version.py +2 -2
- tuieval/builtin.toml +83 -0
- tuieval/cli.py +21 -13
- tuieval/doctor.py +131 -0
- tuieval/engine.py +189 -22
- tuieval/onboard.py +132 -0
- tuieval/packs.py +17 -2
- tuieval/run_evals.py +88 -8
- tuieval/scaffold.py +145 -8
- tuieval/selftest.py +11 -7
- tuieval/templates/models.toml +13 -132
- tuieval/tui.py +417 -38
- tuieval/verdict.py +57 -0
- {tuieval-0.2.0.dev11.dist-info → tuieval-0.2.0.dev23.dist-info}/METADATA +20 -8
- {tuieval-0.2.0.dev11.dist-info → tuieval-0.2.0.dev23.dist-info}/RECORD +18 -15
- {tuieval-0.2.0.dev11.dist-info → tuieval-0.2.0.dev23.dist-info}/WHEEL +0 -0
- {tuieval-0.2.0.dev11.dist-info → tuieval-0.2.0.dev23.dist-info}/entry_points.txt +0 -0
- {tuieval-0.2.0.dev11.dist-info → tuieval-0.2.0.dev23.dist-info}/licenses/LICENSE +0 -0
tuieval/_version.py
CHANGED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
21
|
+
__version__ = version = '0.2.0.dev23'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev23')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
tuieval/builtin.toml
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Built-in defaults and servers, used when a workspace's models.toml doesn't define them.
|
|
2
|
+
# [defaults] fill in key by key; [sampling] only when models.toml has no [sampling] at all (it's part of
|
|
3
|
+
# every result's fingerprint); a server here is replaced whole by one of the same name in models.toml.
|
|
4
|
+
|
|
5
|
+
[defaults]
|
|
6
|
+
repeat = 3
|
|
7
|
+
request_timeout_ms = 1800000 # per request; long thinking runs need it
|
|
8
|
+
ready_timeout_s = 1200 # how long a model may take to load before giving up
|
|
9
|
+
model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
|
|
10
|
+
|
|
11
|
+
# Sent with every request. A model may override it (see `thinking` / `sampling` below); results
|
|
12
|
+
# record the settings each model actually ran with, and the scorecard shows any difference.
|
|
13
|
+
[sampling]
|
|
14
|
+
temperature = 0.7
|
|
15
|
+
top_p = 0.95
|
|
16
|
+
top_k = 20
|
|
17
|
+
min_p = 0.0
|
|
18
|
+
max_tokens = 16384
|
|
19
|
+
enable_thinking = true # test every model in the SAME mode
|
|
20
|
+
|
|
21
|
+
# llama.cpp's server (https://github.com/ggml-org/llama.cpp), started for each GGUF model.
|
|
22
|
+
[servers.llama]
|
|
23
|
+
# Output-affecting flags only. Anything here must be identical wherever results are compared,
|
|
24
|
+
# so it's part of each result's fingerprint. Speed-only flags live in `perf` and `tune` below.
|
|
25
|
+
cmd = [
|
|
26
|
+
"llama-server",
|
|
27
|
+
"-m", "{model}",
|
|
28
|
+
"--alias", "{served_name}",
|
|
29
|
+
"--host", "127.0.0.1", "--port", "{port}",
|
|
30
|
+
"--jinja",
|
|
31
|
+
"--metrics",
|
|
32
|
+
"-np", "1",
|
|
33
|
+
"-ctk", "{kv_type}", "-ctv", "{kv_type}",
|
|
34
|
+
"-c", "{ctx}",
|
|
35
|
+
"--reasoning-format", "auto",
|
|
36
|
+
]
|
|
37
|
+
port = 8080
|
|
38
|
+
model_is_path = true # check the GGUF exists and fits before starting
|
|
39
|
+
vision_args = ["--mmproj", "{mmproj}"] # appended only for models that set mmproj
|
|
40
|
+
kv_type = "f16" # a model's own `kv_type` (e.g. "q8_0") makes it a different model entry
|
|
41
|
+
max_ctx = 65536 # upper bound; the fit check lowers it per machine (see machines.py)
|
|
42
|
+
version_cmd = ["llama-server", "--version"] # recorded with every result
|
|
43
|
+
# Added to every request. cache_prompt = false: each answer is computed from scratch, so a repeat of
|
|
44
|
+
# the same question reuses nothing from the last time it was asked (and its TTFT is honest).
|
|
45
|
+
# Answers record any prompt tokens the server reused.
|
|
46
|
+
request = { cache_prompt = false }
|
|
47
|
+
|
|
48
|
+
# Speed-only flags, always applied (not tuned).
|
|
49
|
+
perf = ["-ngl", "99"]
|
|
50
|
+
|
|
51
|
+
# Speed-only knobs `tuieval tune` tries, one at a time from the best so far. The first option of
|
|
52
|
+
# each knob is the default for models not tuned on this machine yet. Placeholders: {p} P-cores,
|
|
53
|
+
# {p_minus_2}, {all} all cores. Options that stop the server from starting are skipped.
|
|
54
|
+
# Check the flags against `llama-server --help` for your build.
|
|
55
|
+
[servers.llama.tune]
|
|
56
|
+
threads = [["-t", "{p}", "-tb", "{p}"], ["-t", "{p_minus_2}", "-tb", "{p}"], ["-t", "{all}", "-tb", "{all}"]]
|
|
57
|
+
ubatch = [["-ub", "512"], ["-ub", "256"], ["-ub", "1024"]]
|
|
58
|
+
batch = [["-b", "2048"], ["-b", "4096"]]
|
|
59
|
+
flash_attn = [["-fa", "on"], ["-fa", "off"]]
|
|
60
|
+
|
|
61
|
+
# A server that's already running (LM Studio, Ollama, vLLM, a remote box): give its URL and no cmd.
|
|
62
|
+
# Its models are added with `tuieval add <model id> --server local`.
|
|
63
|
+
[servers.local]
|
|
64
|
+
url = "http://127.0.0.1:1234" # LM Studio's default; Ollama is http://127.0.0.1:11434, vLLM :8000
|
|
65
|
+
|
|
66
|
+
# OpenRouter: ANY model it serves, with no [[models]] entry. Name it when you run:
|
|
67
|
+
# tuieval run --tier smoke --only openrouter:qwen/qwen3-235b-a22b-2507
|
|
68
|
+
# TUI: tick "+ openrouter: any model" in the model list, or press "a" and type openrouter:<model id>
|
|
69
|
+
# Results go to results/or-<id>/ and the model stays listed once it has results. A plain
|
|
70
|
+
# `tuieval run` never includes OpenRouter models (they cost money); name them.
|
|
71
|
+
# Needs OPENROUTER_API_KEY in the environment of the shell that starts tuieval.
|
|
72
|
+
# pin_endpoint: every answer of a model comes from one provider endpoint (the closest to the released
|
|
73
|
+
# weights), with no fallback, since providers run different quantizations.
|
|
74
|
+
# To keep providers that store or train on prompts (and could learn your tests) out entirely, add
|
|
75
|
+
# request = { provider = { data_collection = "deny" } }
|
|
76
|
+
[servers.openrouter]
|
|
77
|
+
url = "https://openrouter.ai/api/v1"
|
|
78
|
+
any_model = true
|
|
79
|
+
label_prefix = "or"
|
|
80
|
+
pin_endpoint = true
|
|
81
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
82
|
+
thinking_param = "reasoning" # [sampling] enable_thinking -> OpenRouter's reasoning.enabled
|
|
83
|
+
headers = { "X-Title" = "tuieval" }
|
tuieval/cli.py
CHANGED
|
@@ -2,28 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
Usage: tuieval [--workspace DIR] [command] [options]
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
tuieval init [DIR] create a workspace
|
|
7
|
-
tuieval
|
|
8
|
-
tuieval
|
|
9
|
-
tuieval
|
|
10
|
-
tuieval
|
|
11
|
-
tuieval
|
|
5
|
+
Start here
|
|
6
|
+
tuieval init [DIR] create a workspace; in a terminal, a guided setup to a first result
|
|
7
|
+
tuieval open the TUI (? explains any screen)
|
|
8
|
+
tuieval doctor check servers, models, packs and API keys, and say what to fix
|
|
9
|
+
tuieval add [PATH|ID] add a model: a GGUF, a model id, or none to pick from running servers
|
|
10
|
+
tuieval new-pack NAME a new pack of example questions (--from CSV, --about TOPIC, --grader …)
|
|
11
|
+
tuieval selftest check every test's reference and wrong answers against its grader
|
|
12
12
|
tuieval run [...] run evals from the command line (tuieval run --help)
|
|
13
|
+
tuieval verdict which model is ready for what, and what to run next
|
|
14
|
+
|
|
15
|
+
Day to day
|
|
13
16
|
tuieval compare [...] scorecards (--speed, --pairwise, --failures)
|
|
14
|
-
tuieval
|
|
15
|
-
tuieval verdict production readiness (PASS/FAIL per model and use case)
|
|
16
|
-
tuieval report the readiness verdicts with their evidence, as a markdown file
|
|
17
|
+
tuieval report the verdicts with their evidence, as a markdown file
|
|
17
18
|
tuieval history every verdict change, newest first
|
|
18
|
-
tuieval selftest check every test's reference and wrong answers against its grader
|
|
19
19
|
tuieval items tests that don't separate models or look broken
|
|
20
20
|
tuieval capture LOG --pack turn a real failure (logs/live/*.txt) into a new test
|
|
21
|
+
tuieval regrade re-score stored answers after changing a grader
|
|
22
|
+
tuieval list the models tuieval knows, hidden ones separately (--packs: the packs)
|
|
23
|
+
tuieval remove MODEL remove models, or every hidden one (--hidden); archived in removed/
|
|
24
|
+
|
|
25
|
+
Advanced
|
|
26
|
+
tuieval scan list GGUFs in model_dirs that aren't in models.toml yet
|
|
21
27
|
tuieval machines this machine, other recorded machines, and fit and tuning per model
|
|
22
28
|
tuieval tune MODEL find the fastest speed-only server flags for a model on this machine
|
|
23
29
|
tuieval export pi MODEL serve a model in the pi coding agent with its tuned flags
|
|
24
30
|
tuieval watch a proxy that shows the reasoning of any app using your server
|
|
25
|
-
tuieval help
|
|
26
|
-
tuieval --version
|
|
31
|
+
tuieval help · --version
|
|
27
32
|
|
|
28
33
|
The workspace is --workspace DIR, else $TUIEVAL_HOME, else the current folder.
|
|
29
34
|
"""
|
|
@@ -67,6 +72,9 @@ def main(argv=None):
|
|
|
67
72
|
if not workspace.is_workspace():
|
|
68
73
|
sys.exit(f"{workspace.root()} isn't a tuieval workspace; run `tuieval init` first")
|
|
69
74
|
return scaffold.cmd_new_pack(rest)
|
|
75
|
+
if cmd == "doctor":
|
|
76
|
+
from . import doctor
|
|
77
|
+
return doctor.cmd_doctor(rest)
|
|
70
78
|
if cmd == "run":
|
|
71
79
|
from . import run_evals
|
|
72
80
|
return run_evals.main(rest)
|
tuieval/doctor.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""tuieval doctor: checks the setup and says what to fix (servers, models, packs, API keys).
|
|
2
|
+
|
|
3
|
+
tuieval doctor
|
|
4
|
+
|
|
5
|
+
Each line is ✓ (fine), ! (worth knowing) or ✗ (a run would fail), with the fix. Exits 1 if any ✗.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import engine
|
|
13
|
+
from . import workspace
|
|
14
|
+
|
|
15
|
+
OK, WARN, BAD = "✓", "!", "✗"
|
|
16
|
+
COLOR = {OK: "\033[32m", WARN: "\033[33m", BAD: "\033[31m"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Report:
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.bad = 0
|
|
22
|
+
self.color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
23
|
+
|
|
24
|
+
def line(self, mark, text, fix=""):
|
|
25
|
+
self.bad += mark == BAD
|
|
26
|
+
m = f"{COLOR[mark]}{mark}\033[0m" if self.color else mark
|
|
27
|
+
print(f" {m} {text}" + (f"\n → {fix}" if fix else ""))
|
|
28
|
+
|
|
29
|
+
def head(self, text):
|
|
30
|
+
print(f"\n{text}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check(models_path=None):
|
|
34
|
+
"""Print the report; returns the number of problems that would make a run fail."""
|
|
35
|
+
r = Report()
|
|
36
|
+
r.head("tuieval")
|
|
37
|
+
py = platform.python_version()
|
|
38
|
+
r.line(OK if sys.version_info >= (3, 11) else BAD, f"Python {py}", "" if sys.version_info >= (3, 11)
|
|
39
|
+
else "tuieval needs Python 3.11 or newer")
|
|
40
|
+
try:
|
|
41
|
+
e = engine.Engine(models_path=models_path)
|
|
42
|
+
except Exception as ex: # a broken models.toml: nothing else can be checked
|
|
43
|
+
r.line(BAD, f"models.toml in {workspace.root()}: {ex}", "fix the file (tuieval init makes a fresh one elsewhere)")
|
|
44
|
+
return r.bad
|
|
45
|
+
r.line(OK, f"workspace {e.root}")
|
|
46
|
+
|
|
47
|
+
r.head("Packs")
|
|
48
|
+
for err in e.pack_errors:
|
|
49
|
+
r.line(BAD, err, "fix the pack, then: tuieval selftest")
|
|
50
|
+
if e.packs:
|
|
51
|
+
tests = sum(len(p.tests) for p in e.packs.values())
|
|
52
|
+
r.line(OK, f"{len(e.packs)} pack(s), {tests} tests (tuieval selftest checks their answers)")
|
|
53
|
+
elif not e.pack_errors:
|
|
54
|
+
r.line(BAD, "no packs yet", "tuieval new-pack my-first-pack (a pack of example questions to edit)")
|
|
55
|
+
|
|
56
|
+
r.head("Servers")
|
|
57
|
+
found = engine.detect_servers(engine.detect_ports(e.cfg))
|
|
58
|
+
used = {m["server"] for m in e.cfg["models"]}
|
|
59
|
+
answering = {}
|
|
60
|
+
for name, server in e.cfg["servers"].items():
|
|
61
|
+
if server.get("any_model") or server.get("api_key_env"):
|
|
62
|
+
key = server.get("api_key_env")
|
|
63
|
+
if key and os.environ.get(key):
|
|
64
|
+
r.line(OK, f"{name}: {key} is set")
|
|
65
|
+
elif key:
|
|
66
|
+
r.line(WARN, f"{name}: {key} isn't set (needed only for {name} models)",
|
|
67
|
+
f"export {key}=… in the shell you start tuieval from")
|
|
68
|
+
continue
|
|
69
|
+
if server.get("cmd"):
|
|
70
|
+
missing = engine.missing_program([str(a) for a in server["cmd"]],
|
|
71
|
+
engine.expand(server["cwd"]) if server.get("cwd") else None)
|
|
72
|
+
if missing:
|
|
73
|
+
r.line(BAD if name in used else WARN,
|
|
74
|
+
f"{name}: {missing} isn't installed" + ("" if name in used else " (no model uses it yet)"),
|
|
75
|
+
engine.install_hint(missing))
|
|
76
|
+
else:
|
|
77
|
+
r.line(OK, f"{name}: {server['cmd'][0]} is installed; tuieval starts it for each model")
|
|
78
|
+
continue
|
|
79
|
+
url = server.get("url", "").rstrip("/").removesuffix("/v1")
|
|
80
|
+
ids = engine.probe_models(url) if url else None
|
|
81
|
+
answering[name] = ids
|
|
82
|
+
if ids is not None:
|
|
83
|
+
r.line(OK, f"{name}: {url} answers, serving {len(ids)} model(s)" + (f": {', '.join(ids[:5])}" if ids else ""))
|
|
84
|
+
else:
|
|
85
|
+
elsewhere = [f for f in found if f["url"] != url]
|
|
86
|
+
fix = (f"start the server at {url}, or "
|
|
87
|
+
+ ("run `tuieval add` to add the models of the one running at " + ", ".join(
|
|
88
|
+
f"{f['url']}" + (f" ({f['usual']})" if f["usual"] else "") for f in elsewhere)
|
|
89
|
+
if elsewhere else engine.url_fix(e.cfg, name)))
|
|
90
|
+
r.line(BAD if name in used else WARN,
|
|
91
|
+
f"{name}: nothing answers at {url}" + ("" if name in used else " (no model uses it yet)"), fix)
|
|
92
|
+
known = {s.get("url", "").rstrip("/").removesuffix("/v1") for s in e.cfg["servers"].values()}
|
|
93
|
+
for f in found:
|
|
94
|
+
if f["url"] not in known:
|
|
95
|
+
r.line(WARN, f"found a server at {f['url']}" + (f" ({f['usual']}'s usual port)" if f["usual"] else "")
|
|
96
|
+
+ f" serving {len(f['models'])} model(s), not in models.toml",
|
|
97
|
+
"add its models with: tuieval add")
|
|
98
|
+
|
|
99
|
+
r.head("Models")
|
|
100
|
+
if not e.cfg["models"]:
|
|
101
|
+
r.line(BAD, "no models yet", "tuieval add <GGUF path or model id>, or press a in the TUI")
|
|
102
|
+
for m in e.cfg["models"]:
|
|
103
|
+
server = e.cfg["servers"][m["server"]]
|
|
104
|
+
label = f"{m['label']} ({m['server']})"
|
|
105
|
+
if server.get("model_is_path"):
|
|
106
|
+
path = engine.expand(m["model"])
|
|
107
|
+
r.line(OK if os.path.exists(path) else BAD, f"{label}: {path}",
|
|
108
|
+
"" if os.path.exists(path) else "the file is gone; fix `model` in models.toml or tuieval remove it")
|
|
109
|
+
elif m["server"] in answering:
|
|
110
|
+
ids = answering[m["server"]]
|
|
111
|
+
if ids is None:
|
|
112
|
+
r.line(BAD, f"{label}: its server isn't running", "see Servers above")
|
|
113
|
+
elif engine.serves(ids, m["served_name"]):
|
|
114
|
+
r.line(OK, f"{label}: served")
|
|
115
|
+
else:
|
|
116
|
+
r.line(BAD, f"{label}: the server doesn't list {m['served_name']!r}",
|
|
117
|
+
f"load it in the server, or use one of: {', '.join(ids[:8]) or 'none listed'}")
|
|
118
|
+
else:
|
|
119
|
+
r.line(OK, label)
|
|
120
|
+
|
|
121
|
+
print("\n" + ("Ready: open the TUI with `tuieval`, or run `tuieval run --tier smoke`." if not r.bad
|
|
122
|
+
else f"{r.bad} problem(s) to fix before a run (✗ above)."))
|
|
123
|
+
return r.bad
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_doctor(argv):
|
|
127
|
+
p = argparse.ArgumentParser(prog="tuieval doctor", description=__doc__,
|
|
128
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
129
|
+
p.add_argument("--models", default=None, help="default: the workspace's models.toml")
|
|
130
|
+
a = p.parse_args(argv)
|
|
131
|
+
sys.exit(1 if check(a.models) else 0)
|
tuieval/engine.py
CHANGED
|
@@ -38,6 +38,7 @@ import mimetypes
|
|
|
38
38
|
import os
|
|
39
39
|
import random
|
|
40
40
|
import re
|
|
41
|
+
import shutil
|
|
41
42
|
import signal
|
|
42
43
|
import socket
|
|
43
44
|
import statistics
|
|
@@ -72,10 +73,26 @@ def slug(model):
|
|
|
72
73
|
return re.sub(r"[^a-z0-9._-]+", "-", name.lower()).strip("-.")
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
BUILTIN_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "builtin.toml")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def builtin_config():
|
|
80
|
+
with open(BUILTIN_PATH, "rb") as f:
|
|
81
|
+
return tomllib.load(f)
|
|
82
|
+
|
|
83
|
+
|
|
75
84
|
def load_config(path):
|
|
76
|
-
"""Read models.toml
|
|
85
|
+
"""Read models.toml, fill in the built-ins it leaves out (builtin.toml) and each model's defaults.
|
|
86
|
+
[defaults] are filled key by key; [sampling] only when models.toml has none (sampling is part of
|
|
87
|
+
every result's fingerprint, so a workspace's own keys are never added to); servers by name."""
|
|
77
88
|
with open(path, "rb") as f:
|
|
78
89
|
cfg = tomllib.load(f)
|
|
90
|
+
builtin = builtin_config()
|
|
91
|
+
cfg["defaults"] = {**builtin["defaults"], **cfg.get("defaults", {})}
|
|
92
|
+
cfg.setdefault("sampling", builtin["sampling"])
|
|
93
|
+
own = cfg.get("servers", {})
|
|
94
|
+
cfg["servers"] = {**builtin["servers"], **own}
|
|
95
|
+
cfg["builtin_servers"] = sorted(set(builtin["servers"]) - set(own)) # not defined in models.toml
|
|
79
96
|
cfg.setdefault("models", [])
|
|
80
97
|
seen = set()
|
|
81
98
|
for m in cfg["models"]:
|
|
@@ -218,6 +235,8 @@ def infer_server(cfg, model):
|
|
|
218
235
|
is_file = model.lower().endswith(".gguf")
|
|
219
236
|
choices = [n for n, s in cfg["servers"].items()
|
|
220
237
|
if bool(s.get("model_is_path")) == is_file and not s.get("any_model")]
|
|
238
|
+
own = [n for n in choices if n not in cfg.get("builtin_servers", ())] # the workspace's own come first
|
|
239
|
+
choices = own or choices
|
|
221
240
|
return choices[0] if len(choices) == 1 else None
|
|
222
241
|
|
|
223
242
|
|
|
@@ -358,6 +377,7 @@ def load_presets(path):
|
|
|
358
377
|
tags = ["moe"] # models with any of these tags …
|
|
359
378
|
models = ["foo", "bar"] # … and/or these labels (omit both for every model)
|
|
360
379
|
repeat = 3
|
|
380
|
+
tests = { coding = ["parse-dates"] } # optional: only these tests of a pack
|
|
361
381
|
"""
|
|
362
382
|
if not os.path.isfile(path):
|
|
363
383
|
return {}
|
|
@@ -366,19 +386,24 @@ def load_presets(path):
|
|
|
366
386
|
|
|
367
387
|
|
|
368
388
|
def resolve_preset(cfg, preset, pack_names):
|
|
369
|
-
"""(labels, packs, repeat) for a preset; unknown names are dropped.
|
|
389
|
+
"""(labels, packs, repeat, tests) for a preset; unknown names are dropped. tests: {pack: [test
|
|
390
|
+
ids]} for packs where only some tests run."""
|
|
370
391
|
labels = [m["label"] for m in cfg["models"]]
|
|
371
392
|
if preset.get("models") or preset.get("tags"):
|
|
372
393
|
want, tags = set(preset.get("models", [])), set(preset.get("tags", []))
|
|
373
394
|
labels = [m["label"] for m in cfg["models"] if m["label"] in want or tags & set(m["tags"])]
|
|
374
395
|
packs = [p for p in preset.get("packs", pack_names) if p in pack_names]
|
|
375
|
-
|
|
396
|
+
tests = {p: list(ids) for p, ids in (preset.get("tests") or {}).items() if p in packs and ids}
|
|
397
|
+
return labels, packs, preset.get("repeat"), tests
|
|
376
398
|
|
|
377
399
|
|
|
378
|
-
def save_preset(path, name, labels, packs, repeat):
|
|
400
|
+
def save_preset(path, name, labels, packs, repeat, tests=None):
|
|
379
401
|
"""Add or replace one preset, keeping the rest of the file as written."""
|
|
402
|
+
tests = {p: ids for p, ids in (tests or {}).items() if p in packs and ids}
|
|
380
403
|
block = (f"[{name}]\nmodels = [{', '.join(json.dumps(l) for l in labels)}]\n"
|
|
381
|
-
f"packs = [{', '.join(json.dumps(p) for p in packs)}]\n" + (f"repeat = {int(repeat)}\n" if repeat else "")
|
|
404
|
+
f"packs = [{', '.join(json.dumps(p) for p in packs)}]\n" + (f"repeat = {int(repeat)}\n" if repeat else "")
|
|
405
|
+
+ ("tests = { " + ", ".join(f"{json.dumps(p)} = [{', '.join(json.dumps(i) for i in ids)}]"
|
|
406
|
+
for p, ids in tests.items()) + " }\n" if tests else ""))
|
|
382
407
|
text = open(path).read() if os.path.isfile(path) else \
|
|
383
408
|
"# Saved selections for the TUI (p) and tuieval run --preset <name>. See engine.load_presets.\n"
|
|
384
409
|
pattern = re.compile(rf"^\[{re.escape(name)}\]\n(?:(?!\[).*\n?)*", re.MULTILINE)
|
|
@@ -394,12 +419,13 @@ class Job:
|
|
|
394
419
|
model: dict
|
|
395
420
|
pack: packs_mod.Pack
|
|
396
421
|
repeat: int
|
|
397
|
-
tests: list # the tests this tier runs
|
|
422
|
+
tests: list # the tests this tier runs (or the ones picked)
|
|
398
423
|
out_path: str
|
|
399
424
|
sampling: dict
|
|
400
425
|
tier: str = "certify"
|
|
401
426
|
settings: dict = dataclasses.field(default_factory=dict) # sampling + serving identity (fingerprinted)
|
|
402
427
|
merge: bool = True # keep matching earlier results and only run what's missing
|
|
428
|
+
redo: bool = False # rerun the picked tests, keeping the pack's other answers
|
|
403
429
|
status: str = "waiting" # waiting, loading, running, done, failed, skipped
|
|
404
430
|
note: str = ""
|
|
405
431
|
discards: list = dataclasses.field(default_factory=list) # [(what, answers, why)] set aside on start
|
|
@@ -504,6 +530,115 @@ def server_error(log, start):
|
|
|
504
530
|
return line.split("]: ", 1)[-1]
|
|
505
531
|
return line.removeprefix("error: ")
|
|
506
532
|
|
|
533
|
+
def missing_program(cmd, cwd=None):
|
|
534
|
+
"""The program a server command runs, if it isn't installed (not on PATH, or no such file); else None."""
|
|
535
|
+
exe = next((a for a in cmd if a != "env" and not re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", a)), None)
|
|
536
|
+
if not exe:
|
|
537
|
+
return None
|
|
538
|
+
if "/" in exe:
|
|
539
|
+
return None if os.path.isfile(os.path.join(cwd or "", expand(exe))) else exe
|
|
540
|
+
return None if shutil.which(exe) else exe
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def install_hint(program):
|
|
544
|
+
"""How to get a server program, for the ones tuieval's templates use."""
|
|
545
|
+
if os.path.basename(program) == "llama-server":
|
|
546
|
+
return ("install llama.cpp (macOS: brew install llama.cpp; others: a release from "
|
|
547
|
+
"https://github.com/ggml-org/llama.cpp/releases) so llama-server is on PATH")
|
|
548
|
+
return "install it, or fix the server's cmd in models.toml"
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def probe_models(base_url, timeout=2.0, headers=None):
|
|
552
|
+
"""The model ids an OpenAI-compatible server lists at base_url, or None when nothing answers."""
|
|
553
|
+
req = urllib.request.Request(base_url.rstrip("/").removesuffix("/v1") + "/v1/models", headers=headers or {})
|
|
554
|
+
try:
|
|
555
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
556
|
+
return [str(x.get("id", "")) for x in json.load(r).get("data", []) if isinstance(x, dict)]
|
|
557
|
+
except (OSError, ValueError, AttributeError):
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# Where local OpenAI-compatible servers usually listen. models.toml [defaults] detect_ports
|
|
562
|
+
# replaces the list (e.g. a server on a custom port, or [] to never look).
|
|
563
|
+
DETECT_PORTS = {1234: "LM Studio", 11434: "Ollama", 8080: "llama-server", 8000: "vLLM"}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
SERVER_NAMES = {1234: "lmstudio", 11434: "ollama", 8080: "llamacpp", 8000: "vllm"} # for new [servers.*] entries
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def detect_ports(cfg=None):
|
|
570
|
+
"""Ports to look for running servers on: $TUIEVAL_DETECT_PORTS (comma-separated, empty = none),
|
|
571
|
+
else models.toml [defaults] detect_ports, else the usual ones."""
|
|
572
|
+
env = os.environ.get("TUIEVAL_DETECT_PORTS")
|
|
573
|
+
if env is not None:
|
|
574
|
+
return [int(p) for p in env.replace(" ", "").split(",") if p]
|
|
575
|
+
return [int(p) for p in ((cfg or {}).get("defaults") or {}).get("detect_ports", list(DETECT_PORTS))]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _norm_url(url):
|
|
579
|
+
return (url or "").rstrip("/").removesuffix("/v1").replace("localhost", "127.0.0.1")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def server_for_url(cfg, url):
|
|
583
|
+
"""The models.toml server already pointing at url (a server tuieval doesn't start), or None."""
|
|
584
|
+
return next((n for n, s in cfg["servers"].items() if not s.get("cmd") and _norm_url(s.get("url")) == _norm_url(url)),
|
|
585
|
+
None)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def ensure_server(models_path, url, port=None):
|
|
589
|
+
"""The name of a models.toml server for an already-running server at url, adding a
|
|
590
|
+
[servers.<name>] block when there's none (named after the app that usually uses the port)."""
|
|
591
|
+
cfg = load_config(models_path)
|
|
592
|
+
name = server_for_url(cfg, url)
|
|
593
|
+
if name:
|
|
594
|
+
return name
|
|
595
|
+
base = SERVER_NAMES.get(port, f"local-{port}" if port else "local-server")
|
|
596
|
+
name = base if base not in cfg["servers"] else f"{base}-{port}"
|
|
597
|
+
with open(models_path) as f:
|
|
598
|
+
original = f.read()
|
|
599
|
+
with open(models_path, "w") as f:
|
|
600
|
+
f.write(original.rstrip("\n") + f'\n\n[servers.{name}]\nurl = "{_norm_url(url)}"\n')
|
|
601
|
+
try:
|
|
602
|
+
load_config(models_path)
|
|
603
|
+
except BaseException:
|
|
604
|
+
with open(models_path, "w") as f:
|
|
605
|
+
f.write(original)
|
|
606
|
+
raise
|
|
607
|
+
return name
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def url_fix(cfg, name):
|
|
611
|
+
"""How to point a server at another URL in models.toml (a built-in one has no section there yet)."""
|
|
612
|
+
if name in cfg.get("builtin_servers", ()):
|
|
613
|
+
return f'add [servers.{name}] with url = "http://127.0.0.1:<port>" to models.toml'
|
|
614
|
+
return f"fix `url` under [servers.{name}] in models.toml"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def find_running(cfg, model):
|
|
618
|
+
"""Running servers (detect_servers) that list this model id, best match first."""
|
|
619
|
+
found = detect_servers(detect_ports(cfg))
|
|
620
|
+
exact = [f for f in found if model in f["models"]]
|
|
621
|
+
return exact or [f for f in found if serves(f["models"], model)]
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def detect_servers(ports, timeout=1.0):
|
|
625
|
+
"""[{url, port, usual, models}] for each local port with an OpenAI-compatible server answering.
|
|
626
|
+
usual: the app that normally uses that port (a hint, not a check)."""
|
|
627
|
+
found = []
|
|
628
|
+
for port in ports:
|
|
629
|
+
url = f"http://127.0.0.1:{port}"
|
|
630
|
+
ids = probe_models(url, timeout)
|
|
631
|
+
if ids is not None:
|
|
632
|
+
found.append({"url": url, "port": port, "usual": DETECT_PORTS.get(port), "models": ids})
|
|
633
|
+
return found
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def serves(ids, name):
|
|
637
|
+
"""Does a server listing these model ids serve this model (names match loosely: aliases, paths)?"""
|
|
638
|
+
want = name.lower()
|
|
639
|
+
return any(want == i.lower() or want in i.lower() or (i and i.lower() in want) for i in ids)
|
|
640
|
+
|
|
641
|
+
|
|
507
642
|
def pack_min_context(pack):
|
|
508
643
|
"""Rough context a pack needs: its longest prompt (~4 chars/token) plus room to answer."""
|
|
509
644
|
return max(len(t["input"]) for t in pack.tests) // 4 + 4096
|
|
@@ -614,6 +749,15 @@ class Engine:
|
|
|
614
749
|
raise ConfigError(f"couldn't read {name}'s model list ({e})") from None
|
|
615
750
|
return self._catalogues[name]
|
|
616
751
|
|
|
752
|
+
def has_results(self):
|
|
753
|
+
"""Has any model run anything yet (smoke runs included)?"""
|
|
754
|
+
for base in (self.results_dir, os.path.join(self.results_dir, "smoke")):
|
|
755
|
+
for label in os.listdir(base) if os.path.isdir(base) else []:
|
|
756
|
+
d = os.path.join(base, label)
|
|
757
|
+
if os.path.isdir(d) and any(f.endswith(".json") for f in os.listdir(d)):
|
|
758
|
+
return True
|
|
759
|
+
return False
|
|
760
|
+
|
|
617
761
|
def _add_remote_models(self):
|
|
618
762
|
"""Add the models named at run time this session, and ones that have results from an
|
|
619
763
|
earlier session (their result files record the model), to cfg["models"]."""
|
|
@@ -868,7 +1012,7 @@ class Engine:
|
|
|
868
1012
|
if job.merge:
|
|
869
1013
|
data = self._result(job.out_path)
|
|
870
1014
|
if data and data["pack"]["fingerprint"] == job.pack.fingerprint and settings_match(data, job.settings):
|
|
871
|
-
kept = data["results"]
|
|
1015
|
+
kept = self._kept(job, data["results"])
|
|
872
1016
|
if kept:
|
|
873
1017
|
earlier = self._file_sittings(data["run"])
|
|
874
1018
|
head, new = self._partial_lines(job.out_path + ".partial.jsonl")
|
|
@@ -877,6 +1021,14 @@ class Engine:
|
|
|
877
1021
|
saved = self._saved_sittings(job)
|
|
878
1022
|
return kept, new, earlier + saved + self._rebuild_sittings(new, saved + earlier)
|
|
879
1023
|
|
|
1024
|
+
@staticmethod
|
|
1025
|
+
def _kept(job, rows):
|
|
1026
|
+
"""A finished file's rows that stay: all of them, less the picked tests' when redoing them."""
|
|
1027
|
+
if not job.redo:
|
|
1028
|
+
return rows
|
|
1029
|
+
redo = {t["id"] for t in job.tests}
|
|
1030
|
+
return [r for r in rows if r["test"] not in redo]
|
|
1031
|
+
|
|
880
1032
|
def _fill_progress(self, job):
|
|
881
1033
|
"""Set a job's done/passed/failed and earlier sittings from what's stored."""
|
|
882
1034
|
kept, new, job.earlier = self._stored(job)
|
|
@@ -927,11 +1079,13 @@ class Engine:
|
|
|
927
1079
|
return not current and header_matches(head, pack.fingerprint, settings)
|
|
928
1080
|
|
|
929
1081
|
# ---- planning
|
|
930
|
-
def plan(self, labels, pack_names, repeat=None, tier="screen", force=False):
|
|
1082
|
+
def plan(self, labels, pack_names, repeat=None, tier="screen", force=False, tests=None):
|
|
931
1083
|
"""Jobs for models x packs. tier: smoke (3 tests), screen (a spread sample), certify (all
|
|
932
1084
|
tests). repeat: times each question is asked; None = the tier default (1 for smoke and
|
|
933
|
-
screen, the pack's certification repeats for certify).
|
|
934
|
-
|
|
1085
|
+
screen, the pack's certification repeats for certify). tests: {pack: [test ids]} runs just
|
|
1086
|
+
those tests of a pack instead of the tier's sample (unknown ids raise PackError). Earlier
|
|
1087
|
+
matching results are kept and only missing answers are run, unless force; force with
|
|
1088
|
+
picked tests reruns those and keeps the pack's other answers."""
|
|
935
1089
|
if tier not in TIERS:
|
|
936
1090
|
raise ValueError(f"tier must be one of {TIERS}")
|
|
937
1091
|
jobs = []
|
|
@@ -940,8 +1094,10 @@ class Engine:
|
|
|
940
1094
|
for name in pack_names:
|
|
941
1095
|
pack = self.packs[name]
|
|
942
1096
|
reps = repeat or self.default_repeat(pack, tier)
|
|
943
|
-
|
|
944
|
-
|
|
1097
|
+
picked = (tests or {}).get(name)
|
|
1098
|
+
job = Job(m, pack, reps, pack.pick(picked) if picked else pack.select(tier),
|
|
1099
|
+
self.result_path(label, name, tier), effective_sampling(self.cfg, m), tier=tier,
|
|
1100
|
+
merge=not force or bool(picked), redo=force and bool(picked), settings=self.settings(m))
|
|
945
1101
|
sv = self.serving(m)
|
|
946
1102
|
status = self.result_status(label, name, tier)
|
|
947
1103
|
job.discards = self._discards(job)
|
|
@@ -958,7 +1114,7 @@ class Engine:
|
|
|
958
1114
|
elif missing:
|
|
959
1115
|
job.status, job.note = "skipped", f"{', '.join(missing)} not installed in tuieval's Python"
|
|
960
1116
|
elif status and status.startswith("outdated"):
|
|
961
|
-
job.merge, job.note = False, status.split(": ", 1)[-1] + ", rerunning"
|
|
1117
|
+
job.merge, job.redo, job.note = False, False, status.split(": ", 1)[-1] + ", rerunning"
|
|
962
1118
|
if not force: # a rerun under the new settings already under way resumes
|
|
963
1119
|
self._fill_progress(job)
|
|
964
1120
|
if job.done:
|
|
@@ -1387,6 +1543,9 @@ class Engine:
|
|
|
1387
1543
|
raise ModelFailed(f"mmproj file not found: {expand(m['mmproj'])}")
|
|
1388
1544
|
if cwd and not os.path.isdir(cwd):
|
|
1389
1545
|
raise ModelFailed(f"server directory not found: {cwd}")
|
|
1546
|
+
missing = missing_program(cmd, cwd)
|
|
1547
|
+
if missing:
|
|
1548
|
+
raise ModelFailed(f"{missing} isn't installed: {install_hint(missing)}")
|
|
1390
1549
|
if port_in_use(port):
|
|
1391
1550
|
raise ModelFailed(f"port {port} is already in use; stop the server running there")
|
|
1392
1551
|
os.makedirs(os.path.join(self.log_dir, "server"), exist_ok=True)
|
|
@@ -1411,7 +1570,7 @@ class Engine:
|
|
|
1411
1570
|
key_env = server.get("api_key_env")
|
|
1412
1571
|
if key_env and not os.environ.get(key_env):
|
|
1413
1572
|
raise ModelFailed(f"{key_env} is not set; export it in the shell you start tuieval from")
|
|
1414
|
-
where = f"
|
|
1573
|
+
where = f"using the server at {base_url}"
|
|
1415
1574
|
if server.get("pin_endpoint"):
|
|
1416
1575
|
ep = self.endpoint(m)
|
|
1417
1576
|
if not ep or not ep.get("tag"):
|
|
@@ -1421,6 +1580,14 @@ class Engine:
|
|
|
1421
1580
|
self.emit("model_loading", label=m["label"], command=[f"({where})"], log="")
|
|
1422
1581
|
t0 = time.time()
|
|
1423
1582
|
deadline = time.time() + defaults["ready_timeout_s"]
|
|
1583
|
+
if srv is None and not server.get("health") and probe_models(base_url, 5, self.request_headers(m)) is None:
|
|
1584
|
+
# a server we don't start: if nothing answers now, waiting won't help
|
|
1585
|
+
time.sleep(3)
|
|
1586
|
+
if probe_models(base_url, 5, self.request_headers(m)) is None:
|
|
1587
|
+
raise ModelFailed(
|
|
1588
|
+
f"nothing is answering at {base_url}. Start that server, or {url_fix(self.cfg, m['server'])} "
|
|
1589
|
+
"(common local ports: LM Studio 1234, Ollama 11434, llama-server 8080, vLLM 8000); "
|
|
1590
|
+
"`tuieval doctor` checks them all")
|
|
1424
1591
|
while True:
|
|
1425
1592
|
self._check()
|
|
1426
1593
|
if srv is not None and srv.poll() is not None:
|
|
@@ -1442,9 +1609,8 @@ class Engine:
|
|
|
1442
1609
|
raise ModelFailed(f"server not ready after {defaults['ready_timeout_s']}s")
|
|
1443
1610
|
time.sleep(0.5)
|
|
1444
1611
|
info["load_s"] = time.time() - t0 if srv is not None else None
|
|
1445
|
-
want = m["served_name"].lower()
|
|
1446
1612
|
# A health-checked server we started ourselves serves exactly the model we gave it.
|
|
1447
|
-
if not (server.get("health") and srv is not None) and not
|
|
1613
|
+
if not (server.get("health") and srv is not None) and not serves(ids, m["served_name"]):
|
|
1448
1614
|
raise ModelFailed(f"server reports {ids}, expected {m['served_name']!r}; "
|
|
1449
1615
|
"not running to avoid mislabelled results")
|
|
1450
1616
|
if log and server.get("log_facts"):
|
|
@@ -1656,7 +1822,7 @@ class Engine:
|
|
|
1656
1822
|
data = self._result(job.out_path)
|
|
1657
1823
|
if data and data["pack"]["fingerprint"] == job.pack.fingerprint and \
|
|
1658
1824
|
settings_match(data, job.settings):
|
|
1659
|
-
kept = data["results"]
|
|
1825
|
+
kept = self._kept(job, data["results"])
|
|
1660
1826
|
if kept:
|
|
1661
1827
|
job.earlier = self._file_sittings(data["run"])
|
|
1662
1828
|
new = []
|
|
@@ -1789,18 +1955,19 @@ class Engine:
|
|
|
1789
1955
|
job.note = f"{job.passed}/{job.done} passed"
|
|
1790
1956
|
self.emit("job_done", job=job)
|
|
1791
1957
|
|
|
1792
|
-
def _archive(self, job):
|
|
1793
|
-
"""Keep a result file that is about to be replaced (rerun or outdated) in history
|
|
1958
|
+
def _archive(self, job, keep=False):
|
|
1959
|
+
"""Keep a result file that is about to be replaced (rerun or outdated) in history/; keep:
|
|
1960
|
+
copy it (some of its answers stay in the new file)."""
|
|
1794
1961
|
if job.tier == "smoke" or not os.path.isfile(job.out_path):
|
|
1795
1962
|
return
|
|
1796
1963
|
hist = os.path.join(os.path.dirname(job.out_path), "history")
|
|
1797
1964
|
os.makedirs(hist, exist_ok=True)
|
|
1798
1965
|
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(job.out_path)))
|
|
1799
|
-
os.replace(job.out_path, os.path.join(hist, f"{job.pack.name}-{stamp}.json"))
|
|
1966
|
+
(shutil.copy2 if keep else os.replace)(job.out_path, os.path.join(hist, f"{job.pack.name}-{stamp}.json"))
|
|
1800
1967
|
|
|
1801
1968
|
def _write_result(self, job, records, run_info):
|
|
1802
|
-
if not job.merge:
|
|
1803
|
-
self._archive(job)
|
|
1969
|
+
if not job.merge or job.redo:
|
|
1970
|
+
self._archive(job, keep=job.redo)
|
|
1804
1971
|
m = job.model
|
|
1805
1972
|
data = {
|
|
1806
1973
|
"format": RESULT_FORMAT,
|