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 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.dev14'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev14')
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
- tuieval open the TUI
6
- tuieval init [DIR] create a workspace (models.toml, packs/, graders/)
7
- tuieval new-pack NAME a new pack from a starter template (--grader answer|rag|reply|tool_call|code)
8
- tuieval add PATH|ID register a model in models.toml (a GGUF, or a model id)
9
- tuieval scan list GGUFs in model_dirs that aren't in models.toml yet
10
- tuieval list the models tuieval knows, hidden ones separately (--packs: the packs)
11
- tuieval remove MODEL remove models, or every hidden one (--hidden); archived in removed/
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 regrade re-score stored answers after changing a grader
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 this 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 and fill in each model's defaults."""
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"already running at {base_url}"
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 any(want == i.lower() or want in i.lower() or (i and i.lower() in want) for i in ids):
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: