tuieval 0.2.0.dev14__py3-none-any.whl → 0.2.0.dev25__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 +150 -4
- tuieval/graders/code.py +1 -0
- tuieval/onboard.py +132 -0
- tuieval/packs.py +8 -2
- tuieval/run_evals.py +54 -4
- tuieval/scaffold.py +145 -8
- tuieval/selftest.py +11 -7
- tuieval/templates/models.toml +13 -132
- tuieval/tui.py +206 -20
- tuieval/verdict.py +57 -0
- {tuieval-0.2.0.dev14.dist-info → tuieval-0.2.0.dev25.dist-info}/METADATA +18 -7
- {tuieval-0.2.0.dev14.dist-info → tuieval-0.2.0.dev25.dist-info}/RECORD +19 -16
- {tuieval-0.2.0.dev14.dist-info → tuieval-0.2.0.dev25.dist-info}/WHEEL +0 -0
- {tuieval-0.2.0.dev14.dist-info → tuieval-0.2.0.dev25.dist-info}/entry_points.txt +0 -0
- {tuieval-0.2.0.dev14.dist-info → tuieval-0.2.0.dev25.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.dev25'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0, 'dev25')
|
|
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
|
@@ -73,10 +73,26 @@ def slug(model):
|
|
|
73
73
|
return re.sub(r"[^a-z0-9._-]+", "-", name.lower()).strip("-.")
|
|
74
74
|
|
|
75
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
|
+
|
|
76
84
|
def load_config(path):
|
|
77
|
-
"""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."""
|
|
78
88
|
with open(path, "rb") as f:
|
|
79
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
|
|
80
96
|
cfg.setdefault("models", [])
|
|
81
97
|
seen = set()
|
|
82
98
|
for m in cfg["models"]:
|
|
@@ -219,6 +235,8 @@ def infer_server(cfg, model):
|
|
|
219
235
|
is_file = model.lower().endswith(".gguf")
|
|
220
236
|
choices = [n for n, s in cfg["servers"].items()
|
|
221
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
|
|
222
240
|
return choices[0] if len(choices) == 1 else None
|
|
223
241
|
|
|
224
242
|
|
|
@@ -512,6 +530,115 @@ def server_error(log, start):
|
|
|
512
530
|
return line.split("]: ", 1)[-1]
|
|
513
531
|
return line.removeprefix("error: ")
|
|
514
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
|
+
|
|
515
642
|
def pack_min_context(pack):
|
|
516
643
|
"""Rough context a pack needs: its longest prompt (~4 chars/token) plus room to answer."""
|
|
517
644
|
return max(len(t["input"]) for t in pack.tests) // 4 + 4096
|
|
@@ -622,6 +749,15 @@ class Engine:
|
|
|
622
749
|
raise ConfigError(f"couldn't read {name}'s model list ({e})") from None
|
|
623
750
|
return self._catalogues[name]
|
|
624
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
|
+
|
|
625
761
|
def _add_remote_models(self):
|
|
626
762
|
"""Add the models named at run time this session, and ones that have results from an
|
|
627
763
|
earlier session (their result files record the model), to cfg["models"]."""
|
|
@@ -1407,6 +1543,9 @@ class Engine:
|
|
|
1407
1543
|
raise ModelFailed(f"mmproj file not found: {expand(m['mmproj'])}")
|
|
1408
1544
|
if cwd and not os.path.isdir(cwd):
|
|
1409
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)}")
|
|
1410
1549
|
if port_in_use(port):
|
|
1411
1550
|
raise ModelFailed(f"port {port} is already in use; stop the server running there")
|
|
1412
1551
|
os.makedirs(os.path.join(self.log_dir, "server"), exist_ok=True)
|
|
@@ -1431,7 +1570,7 @@ class Engine:
|
|
|
1431
1570
|
key_env = server.get("api_key_env")
|
|
1432
1571
|
if key_env and not os.environ.get(key_env):
|
|
1433
1572
|
raise ModelFailed(f"{key_env} is not set; export it in the shell you start tuieval from")
|
|
1434
|
-
where = f"
|
|
1573
|
+
where = f"using the server at {base_url}"
|
|
1435
1574
|
if server.get("pin_endpoint"):
|
|
1436
1575
|
ep = self.endpoint(m)
|
|
1437
1576
|
if not ep or not ep.get("tag"):
|
|
@@ -1441,6 +1580,14 @@ class Engine:
|
|
|
1441
1580
|
self.emit("model_loading", label=m["label"], command=[f"({where})"], log="")
|
|
1442
1581
|
t0 = time.time()
|
|
1443
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")
|
|
1444
1591
|
while True:
|
|
1445
1592
|
self._check()
|
|
1446
1593
|
if srv is not None and srv.poll() is not None:
|
|
@@ -1462,9 +1609,8 @@ class Engine:
|
|
|
1462
1609
|
raise ModelFailed(f"server not ready after {defaults['ready_timeout_s']}s")
|
|
1463
1610
|
time.sleep(0.5)
|
|
1464
1611
|
info["load_s"] = time.time() - t0 if srv is not None else None
|
|
1465
|
-
want = m["served_name"].lower()
|
|
1466
1612
|
# A health-checked server we started ourselves serves exactly the model we gave it.
|
|
1467
|
-
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"]):
|
|
1468
1614
|
raise ModelFailed(f"server reports {ids}, expected {m['served_name']!r}; "
|
|
1469
1615
|
"not running to avoid mislabelled results")
|
|
1470
1616
|
if log and server.get("log_facts"):
|
tuieval/graders/code.py
CHANGED
|
@@ -53,6 +53,7 @@ def _err(e):
|
|
|
53
53
|
return last[:200]
|
|
54
54
|
try:
|
|
55
55
|
exec(compile(CANDIDATE, "candidate.py", "exec"), ns)
|
|
56
|
+
ns = dict(ns) # tests get a copy, so their imports can't rebind the candidate's globals
|
|
56
57
|
exec(compile(SETUP, "setup.py", "exec"), ns)
|
|
57
58
|
setup_error = None
|
|
58
59
|
except BaseException as e:
|
tuieval/onboard.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Getting started: find the model servers already running on this machine, add their models,
|
|
2
|
+
and (tuieval init in a terminal) walk a new workspace to its first Smoke check."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import engine
|
|
7
|
+
from . import workspace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ask(question, default="", yes=False):
|
|
11
|
+
"""input() with a default; yes (or no terminal) takes the default without asking."""
|
|
12
|
+
if yes or not sys.stdin.isatty():
|
|
13
|
+
return default
|
|
14
|
+
try:
|
|
15
|
+
answer = input(f"{question} ").strip()
|
|
16
|
+
except EOFError:
|
|
17
|
+
return default
|
|
18
|
+
return answer or default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def unregistered(cfg, found):
|
|
22
|
+
"""[(model id, detected server)] for models on running servers that models.toml doesn't have yet."""
|
|
23
|
+
out = []
|
|
24
|
+
for f in found:
|
|
25
|
+
name = engine.server_for_url(cfg, f["url"])
|
|
26
|
+
have = {m["served_name"] for m in cfg["models"] if m["server"] == name}
|
|
27
|
+
out += [(mid, f) for mid in f["models"] if mid and mid not in have]
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def where(f):
|
|
32
|
+
return f["url"] + (f" ({f['usual']})" if f["usual"] else "")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def no_servers_help(cfg):
|
|
36
|
+
"""What to do when nothing is running: the three ways to get a model."""
|
|
37
|
+
llama = next((s for s in cfg["servers"].values() if s.get("model_is_path") and s.get("cmd")), None)
|
|
38
|
+
missing = engine.missing_program([str(a) for a in llama["cmd"]]) if llama else "llama-server"
|
|
39
|
+
key = next((s.get("api_key_env") for s in cfg["servers"].values() if s.get("any_model")), None)
|
|
40
|
+
ports = ", ".join(map(str, engine.detect_ports(cfg))) or "none (detect_ports is empty)"
|
|
41
|
+
return [
|
|
42
|
+
f"No model server is running (looked on ports {ports}). Three ways to get a model:",
|
|
43
|
+
" • An app that serves models (LM Studio, Ollama, vLLM): start its server, load a model, then run",
|
|
44
|
+
" tuieval add (it finds the running server and lists its models)",
|
|
45
|
+
" • A GGUF file: tuieval add ~/path/to/model.gguf (tuieval starts llama-server for it; "
|
|
46
|
+
+ ("installed ✓)" if not missing else f"{engine.install_hint(missing)})"),
|
|
47
|
+
" • A hosted model on OpenRouter: tuieval run --only openrouter:<model id> "
|
|
48
|
+
+ (f"({key} is set ✓)" if key and os.environ.get(key) else f"(export {key or 'OPENROUTER_API_KEY'} first)"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_found(models_path, picks):
|
|
53
|
+
"""Add (model id, detected server) picks to models.toml; returns the labels added."""
|
|
54
|
+
labels = []
|
|
55
|
+
for mid, f in picks:
|
|
56
|
+
try:
|
|
57
|
+
server = engine.ensure_server(models_path, f["url"], f["port"])
|
|
58
|
+
label, _ = engine.add_model(models_path, mid, server)
|
|
59
|
+
labels.append(label)
|
|
60
|
+
print(f" added {label} ({mid} on {where(f)}, server [servers.{server}])")
|
|
61
|
+
except engine.ConfigError as e:
|
|
62
|
+
print(f" not added: {mid}: {e}")
|
|
63
|
+
return labels
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def choose(items, yes=False, default="all"):
|
|
67
|
+
"""Numbered pick from items (strings): returns the chosen indexes. Accepts 'all', 'none' or '1,3'."""
|
|
68
|
+
for i, text in enumerate(items, 1):
|
|
69
|
+
print(f" {i}) {text}")
|
|
70
|
+
answer = ask(f"Add which? numbers like 1,3 · all · none [{default}]:", default, yes).lower()
|
|
71
|
+
if answer in ("all", "a", "y", "yes"):
|
|
72
|
+
return list(range(len(items)))
|
|
73
|
+
if answer in ("none", "n", "no", ""):
|
|
74
|
+
return []
|
|
75
|
+
picked = []
|
|
76
|
+
for part in answer.replace(" ", "").split(","):
|
|
77
|
+
if part.isdigit() and 1 <= int(part) <= len(items):
|
|
78
|
+
picked.append(int(part) - 1)
|
|
79
|
+
return picked
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_detected(models_path, yes=False):
|
|
83
|
+
"""tuieval add without a model: list what's running and add the chosen models. Returns labels."""
|
|
84
|
+
cfg = engine.load_config(models_path)
|
|
85
|
+
found = engine.detect_servers(engine.detect_ports(cfg))
|
|
86
|
+
if not found:
|
|
87
|
+
print("\n".join(no_servers_help(cfg)))
|
|
88
|
+
return []
|
|
89
|
+
items = unregistered(cfg, found)
|
|
90
|
+
if not items:
|
|
91
|
+
print("Every model on the running servers is already added: " + ", ".join(where(f) for f in found))
|
|
92
|
+
return []
|
|
93
|
+
print(f"Models on running servers ({len(items)}):")
|
|
94
|
+
picks = choose([f"{mid} on {where(f)}" for mid, f in items], yes,
|
|
95
|
+
default="all" if len(items) <= 3 else "none")
|
|
96
|
+
return add_found(models_path, [items[i] for i in picks])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def guided_init(folder, yes=False):
|
|
100
|
+
"""After `tuieval init` in a terminal: add running models, offer a starter pack, run Smoke."""
|
|
101
|
+
from . import scaffold
|
|
102
|
+
os.environ[workspace.ENV] = folder
|
|
103
|
+
models_path = os.path.join(folder, "models.toml")
|
|
104
|
+
cfg = engine.load_config(models_path)
|
|
105
|
+
labels = []
|
|
106
|
+
if not cfg["models"]:
|
|
107
|
+
print("\nLooking for model servers running on this machine…")
|
|
108
|
+
labels = add_detected(models_path, yes)
|
|
109
|
+
cfg = engine.load_config(models_path)
|
|
110
|
+
packs_dir = os.path.join(folder, "packs")
|
|
111
|
+
if not any(os.path.isfile(os.path.join(packs_dir, d, "pack.toml")) for d in os.listdir(packs_dir)):
|
|
112
|
+
if ask("\nCreate a starter pack of example questions to try? [Y/n]", "y", yes).lower().startswith("y"):
|
|
113
|
+
scaffold.new_pack("starter", packs_dir=packs_dir)
|
|
114
|
+
print(" created packs/starter/ (example questions: replace them with your own later)")
|
|
115
|
+
ready = [m["label"] for m in cfg["models"]]
|
|
116
|
+
has_pack = any(os.path.isfile(os.path.join(packs_dir, d, "pack.toml")) for d in os.listdir(packs_dir))
|
|
117
|
+
shown = os.path.relpath(folder) if folder.startswith(os.getcwd()) else folder
|
|
118
|
+
cd = f"cd {shown} && " if os.path.abspath(folder) != os.getcwd() else ""
|
|
119
|
+
if ready and has_pack and ask(f"\nRun a Smoke check now (3 questions per pack, {', '.join(ready)})? [Y/n]",
|
|
120
|
+
"y", yes).lower().startswith("y"):
|
|
121
|
+
from . import run_evals
|
|
122
|
+
try:
|
|
123
|
+
run_evals.main(["--tier", "smoke", "--brief", "--only", ",".join(ready)])
|
|
124
|
+
except SystemExit as e:
|
|
125
|
+
if e.code not in (None, 0):
|
|
126
|
+
print(e.code if isinstance(e.code, str) else "the Smoke check didn't finish")
|
|
127
|
+
print(f"\nNext: {cd}tuieval (opens the TUI: r shows results, ? explains any screen)")
|
|
128
|
+
elif not ready:
|
|
129
|
+
print(f"\nNext: add a model (see above), then {cd}tuieval")
|
|
130
|
+
else:
|
|
131
|
+
print(f"\nNext: {cd}tuieval (opens the TUI; ? explains any screen)")
|
|
132
|
+
return labels
|
tuieval/packs.py
CHANGED
|
@@ -177,9 +177,15 @@ def load_pack(path):
|
|
|
177
177
|
for tf in test_files:
|
|
178
178
|
tpath = os.path.join(path, tf)
|
|
179
179
|
for i, t in enumerate(_read_tests(tpath)):
|
|
180
|
-
if not isinstance(t, dict) or "input" not in t:
|
|
181
|
-
raise PackError(f"{tpath}: test #{i + 1} has no `input`")
|
|
180
|
+
if not isinstance(t, dict) or ("input" not in t and "input_file" not in t):
|
|
181
|
+
raise PackError(f"{tpath}: test #{i + 1} has no `input` (or `input_file`)")
|
|
182
182
|
t = dict(t)
|
|
183
|
+
if "input" not in t: # the prompt lives in its own file, relative to the pack
|
|
184
|
+
src = os.path.normpath(os.path.join(path, str(t["input_file"])))
|
|
185
|
+
if not src.startswith(os.path.normpath(path) + os.sep) or not os.path.isfile(src):
|
|
186
|
+
raise PackError(f"{tpath}: test #{i + 1}: input_file {t['input_file']!r} isn't a file in the pack")
|
|
187
|
+
with open(src) as f:
|
|
188
|
+
t["input"] = f.read().strip()
|
|
183
189
|
t.setdefault("description", str(t.get("id") or t["input"])[:80])
|
|
184
190
|
t.setdefault("id", _slug(t["description"]) or f"{tf}-{i + 1}")
|
|
185
191
|
if t["id"] in seen:
|