tuieval 0.2.0.dev87__tar.gz → 0.2.0.dev90__tar.gz

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.
Files changed (78) hide show
  1. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/writing-packs.md +3 -0
  4. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/engine.py +12 -5
  6. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/packs.py +14 -7
  7. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/answer/pack.toml +1 -0
  8. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/tui.py +6 -4
  9. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/tests/mock_server.py +12 -2
  10. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/tests/test_tuieval.py +106 -2
  11. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/.gitignore +0 -0
  12. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/LICENSE +0 -0
  13. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/README.md +0 -0
  14. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/RELEASING.md +0 -0
  15. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/README.md +0 -0
  16. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/favicon.ico +0 -0
  17. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/readme-header.png +0 -0
  18. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/social-preview.png +0 -0
  19. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-1024.png +0 -0
  20. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-128.png +0 -0
  21. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-16.png +0 -0
  22. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-16.svg +0 -0
  23. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-256.png +0 -0
  24. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-32.png +0 -0
  25. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-48.png +0 -0
  26. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-512.png +0 -0
  27. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-64.png +0 -0
  28. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  29. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/brand/tuieval-icon.svg +0 -0
  30. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/tui-answer-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/tui-run-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/images/tui-setup.png +0 -0
  33. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/docs/models.md +0 -0
  34. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/pyproject.toml +0 -0
  35. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/__init__.py +0 -0
  36. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/__main__.py +0 -0
  37. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/builtin.toml +0 -0
  38. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/cli.py +0 -0
  39. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/client.py +0 -0
  40. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/compare.py +0 -0
  41. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/doctor.py +0 -0
  42. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/export.py +0 -0
  43. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/graders/__init__.py +0 -0
  44. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/graders/answer.py +0 -0
  45. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/graders/code.py +0 -0
  46. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/graders/rag.py +0 -0
  47. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/graders/reply.py +0 -0
  48. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/graders/tool_call.py +0 -0
  49. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/logs.py +0 -0
  50. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/machines.py +0 -0
  51. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/onboard.py +0 -0
  52. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/profiles.py +0 -0
  53. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/pta.py +0 -0
  54. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/remove.py +0 -0
  55. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/run_evals.py +0 -0
  56. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/scaffold.py +0 -0
  57. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/selftest.py +0 -0
  58. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/models.toml +0 -0
  59. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev87 → tuieval-0.2.0.dev90}/src/tuieval/yamlout.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Results, Per question: the side-by-side summary shows any number of models (it stopped at 8).
6
+ - `max_tokens` in pack.toml sets one pack's output budget; changing it reruns only that pack. Models whose context or endpoint can't fit it are skipped for that pack.
5
7
  - The run screen's queue keeps the running model in view, unless you scroll it yourself (scrolling back to it follows again).
6
8
  - Start while a run is going skips models and packs already running or queued (it says which); picking only those adds nothing. Retest and picked questions still go through.
7
9
  - Fix: something queued just as a run ended (while its server was stopping) now starts instead of waiting forever.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev87
3
+ Version: 0.2.0.dev90
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -35,6 +35,7 @@ order = 30 # position in the list
35
35
  tests = ["tests.yaml"] # optional; default: tests.* first, then every other .yaml/.csv file
36
36
  screen = 20 # tests in a Screen run (spread across categories, one variant per group)
37
37
  stop_early = true # stop a run of this pack once its FAIL is certain (default false; see below)
38
+ max_tokens = 32768 # optional: this pack's output budget, instead of models.toml [sampling] max_tokens
38
39
 
39
40
  [certify]
40
41
  repeat = 3 # repeats in a Certify run (default: models.toml [defaults] repeat)
@@ -53,6 +54,8 @@ min_consistency = 0.95 # share of variant groups where every variant p
53
54
 
54
55
  `needs` entries other than `vision`, `tools` and `long_context` name Python modules the pack's grading needs (for example `pandas`, when your hidden tests use it). The pack is skipped, with a note, where that module isn't installed in tuieval's Python environment.
55
56
 
57
+ `max_tokens` suits packs whose answers are much longer (or shorter) than the rest. Thinking counts toward it. It's part of the pack's fingerprint, so changing it reruns only this pack. A model is skipped for the pack, with a note, when its context can't hold the longest prompt plus `max_tokens`, or when its pinned provider endpoint allows fewer output tokens.
58
+
56
59
  ## Tests (YAML)
57
60
 
58
61
  ```yaml
@@ -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.dev87'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev87')
21
+ __version__ = version = '0.2.0.dev90'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev90')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -646,8 +646,9 @@ def serves(ids, name):
646
646
 
647
647
 
648
648
  def pack_min_context(pack):
649
- """Rough context a pack needs: its longest prompt (~4 chars/token) plus room to answer."""
650
- return max(len(t["input"]) for t in pack.tests) // 4 + 4096
649
+ """Rough context a pack needs: its longest prompt (~4 chars/token) plus room to answer (the
650
+ pack's own max_tokens when it sets one)."""
651
+ return max(len(t["input"]) for t in pack.tests) // 4 + (pack.max_tokens or 4096)
651
652
 
652
653
 
653
654
  class Cancelled(Exception):
@@ -872,7 +873,8 @@ class Engine:
872
873
  needs_tools = m.get("tools") is not False and any("tools" in p.needs for p in self.packs.values())
873
874
  ep = choose_endpoint(eps, m["model"], effective_sampling(self.cfg, m), needs_tools, previous)
874
875
  ep = ep and {"tag": ep["tag"], "provider": ep.get("provider_name"),
875
- "quantization": ep.get("quantization") or "unknown"}
876
+ "quantization": ep.get("quantization") or "unknown",
877
+ "max_output": ep.get("max_completion_tokens")}
876
878
  except (OSError, ValueError):
877
879
  # offline: assume the endpoint earlier results used (the run itself will need the network)
878
880
  ep = {"tag": previous, "provider": None, "quantization": previous_quant} if previous else None
@@ -1196,6 +1198,9 @@ class Engine:
1196
1198
  elif (sv.ctx or 10**9) < pack_min_context(pack):
1197
1199
  job.status, job.note = "skipped", \
1198
1200
  f"needs ~{pack_min_context(pack) // 1024}k context, {sv.ctx // 1024}k fits on {sv.machine.id}"
1201
+ elif pack.max_tokens and pack.max_tokens > ((self.endpoint(m) or {}).get("max_output") or 10**9):
1202
+ job.status, job.note = "skipped", \
1203
+ f"needs {pack.max_tokens:,} output tokens, endpoint allows {self.endpoint(m)['max_output']:,}"
1199
1204
  elif missing:
1200
1205
  job.status, job.note = "skipped", f"{', '.join(missing)} not installed in tuieval's Python"
1201
1206
  elif status and status.startswith("outdated"):
@@ -2037,7 +2042,8 @@ class Engine:
2037
2042
  def _body(self, job, test, rep=0):
2038
2043
  s = job.sampling
2039
2044
  body = {"model": job.model["served_name"], "messages": self._messages(job, test),
2040
- "temperature": s["temperature"], "top_p": s["top_p"], "max_tokens": s["max_tokens"]}
2045
+ "temperature": s["temperature"], "top_p": s["top_p"],
2046
+ "max_tokens": job.pack.max_tokens or s["max_tokens"]}
2041
2047
  for k in ("top_k", "min_p", "presence_penalty", "repeat_penalty", "seed", "reasoning_effort"):
2042
2048
  if k in s:
2043
2049
  body[k] = s[k]
@@ -2380,7 +2386,8 @@ class Engine:
2380
2386
  "model": {**{k: m.get(k) for k in ("label", "served_name", "server", "model", "tags", "vision", "mmproj")},
2381
2387
  **{k: m[k] for k in ("tools", "max_context", "thinking") if k in m}},
2382
2388
  "pack": {"name": job.pack.name, "label": job.pack.label, "group": job.pack.group,
2383
- "fingerprint": job.pack.fingerprint, "tests": len(job.pack.tests)},
2389
+ "fingerprint": job.pack.fingerprint, "tests": len(job.pack.tests),
2390
+ **({"max_tokens": job.pack.max_tokens} if job.pack.max_tokens else {})},
2384
2391
  "settings": job.sampling,
2385
2392
  "serving": job.settings.get("serving"),
2386
2393
  "settings_fingerprint": settings_fingerprint(job.settings),
@@ -50,6 +50,7 @@ class Pack:
50
50
  screen: int # tests in a screening run
51
51
  certify_repeat: int # repeats in a certification run (0 = models.toml default)
52
52
  stop_early: bool = False # stop the pack once its FAIL is certain (pack.toml stop_early)
53
+ max_tokens: int = 0 # output budget for this pack's answers (0 = [sampling] max_tokens)
53
54
 
54
55
  @property
55
56
  def modules(self):
@@ -135,12 +136,15 @@ NOT_FINGERPRINTED = {"reference", "wrong", "description", "category", "critical"
135
136
  DIFFICULTIES = ("easy", "medium", "hard")
136
137
 
137
138
 
138
- def _fingerprint(system, tools, grader, tests, images):
139
- """Hash of what the model sees and what decides pass/fail."""
139
+ def _fingerprint(system, tools, grader, tests, images, max_tokens=0):
140
+ """Hash of what the model sees and what decides pass/fail. A pack's own max_tokens counts
141
+ (it decides what gets cut off); packs without one keep the fingerprint they always had."""
140
142
  h = hashlib.sha256()
141
- h.update(json.dumps({"system": system, "tools": tools, "grader": grader,
142
- "tests": [{k: v for k, v in t.items() if k not in NOT_FINGERPRINTED} for t in tests]},
143
- sort_keys=True, default=str).encode())
143
+ what = {"system": system, "tools": tools, "grader": grader,
144
+ "tests": [{k: v for k, v in t.items() if k not in NOT_FINGERPRINTED} for t in tests]}
145
+ if max_tokens:
146
+ what["max_tokens"] = max_tokens
147
+ h.update(json.dumps(what, sort_keys=True, default=str).encode())
144
148
  for path in sorted(images):
145
149
  with open(path, "rb") as f:
146
150
  h.update(f.read())
@@ -217,13 +221,16 @@ def load_pack(path):
217
221
  if tools and "tools" not in needs:
218
222
  needs = list(needs) + ["tools"]
219
223
  gate = {"min_accuracy": 0.8, **meta.get("gate", {})}
224
+ max_tokens = meta.get("max_tokens", 0)
225
+ if "max_tokens" in meta and (isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or max_tokens < 1):
226
+ raise PackError(f"{meta_path}: max_tokens must be a whole number above 0, not {max_tokens!r}")
220
227
  return Pack(name=name, path=path, label=meta.get("label", name), group=meta.get("group", "Other"),
221
228
  description=meta.get("description", ""), grader=meta.get("grader", "answer"),
222
229
  system=system, needs=list(needs), tools=tools, tests=tests,
223
- fingerprint=_fingerprint(system, tools, meta.get("grader", "answer"), tests, images),
230
+ fingerprint=_fingerprint(system, tools, meta.get("grader", "answer"), tests, images, max_tokens),
224
231
  gate=gate, screen=int(meta.get("screen", 10)),
225
232
  certify_repeat=int(meta.get("certify", {}).get("repeat", 0)),
226
- stop_early=bool(meta.get("stop_early", False)))
233
+ stop_early=bool(meta.get("stop_early", False)), max_tokens=max_tokens)
227
234
 
228
235
 
229
236
  def load_packs(packs_dir=None, errors=None):
@@ -6,6 +6,7 @@ grader = "answer" # how answers are scored (answer, rag, reply, t
6
6
  system = "system.txt" # the system prompt sent with every question
7
7
  order = 100 # position in the list
8
8
  screen = 6 # tests in a Screen run
9
+ # max_tokens = 32768 # this pack's output budget, instead of models.toml [sampling] max_tokens
9
10
 
10
11
  [certify]
11
12
  repeat = 3 # times each question is asked in a Certify run
@@ -531,7 +531,8 @@ class AnswerScreen(ModalScreen):
531
531
  partial = " [cyan](pack still running or unfinished: not in scores yet)[/cyan]" if entry.get("partial") else ""
532
532
  quant = f" [magenta]{rec['quantization']}[/magenta]" if rec.get("quantization") else ""
533
533
  finish = rec.get("finish") or "?"
534
- finish = f"[bold red]{finish} (cut off at max_tokens)[/]" if finish == "length" else finish
534
+ limit = f"this pack's max_tokens, {pack.max_tokens:,}" if pack and pack.max_tokens else "max_tokens"
535
+ finish = f"[bold red]{finish} (cut off at {limit})[/]" if finish == "length" else finish
535
536
  toks = rec.get("completion_tokens")
536
537
  split = ", ".join(f"{k} {rec[k + '_tokens']:,}" for k in ("reasoning", "answer")
537
538
  if rec.get(k + "_tokens") is not None)
@@ -709,6 +710,8 @@ class SetupScreen(Screen):
709
710
  count = (f"[b cyan]{len(picked)}/{len(pk.tests)} tests picked[/]" if picked
710
711
  else f"[dim]{len(pk.tests)} tests[/dim]")
711
712
  needs = f" [magenta]{', '.join(pk.needs)}[/magenta]" if pk.needs else ""
713
+ if pk.max_tokens:
714
+ needs += f" [dim]max_tokens {pk.max_tokens:,}[/dim]"
712
715
  mix = {lvl: sum(t.get("difficulty") == lvl for t in pk.tests) for lvl in ("easy", "medium", "hard")}
713
716
  mixed = " ".join(f"[{DIFF_STYLE[l]}]{n}{l[0].upper()}[/]" for l, n in mix.items() if n)
714
717
  packs.add_option(Selection(f"[dim]{pk.group} ·[/dim] {pk.label} {count} {mixed}{needs}",
@@ -2980,10 +2983,9 @@ class ResultsScreen(Screen):
2980
2983
  table.clear(columns=True)
2981
2984
  title = self.query_one("#pq-compare-title", Static)
2982
2985
  shared = [t for t in tests if all(m in t["cells"] for m in models)]
2983
- if len(models) < 2 or len(models) > 8:
2986
+ if len(models) < 2:
2984
2987
  table.display = False
2985
- title.update("[dim]Pick 2 to 8 models (Models button) for a side-by-side summary.[/dim]"
2986
- if len(models) > 8 else "")
2988
+ title.update("")
2987
2989
  return
2988
2990
  table.display = True
2989
2991
  if not shared:
@@ -9,6 +9,8 @@ fixed always "ANSWER: 42"
9
9
  unavailable HTTP 404 "No endpoints found" for every chat request
10
10
 
11
11
  Streams like llama.cpp: reasoning_content then content deltas, usage and timings at the end.
12
+ An answer longer than the request's max_tokens (one token per word) is cut off with finish
13
+ "length". --max-output N also serves an OpenRouter-style endpoints list with that output limit.
12
14
  """
13
15
  import argparse
14
16
  import json
@@ -47,7 +49,7 @@ def reply_for(test, mode):
47
49
 
48
50
 
49
51
  class Handler(BaseHTTPRequestHandler):
50
- answers, mode, model, delay = {}, "oracle", "mock", 0.0
52
+ answers, mode, model, delay, max_output = {}, "oracle", "mock", 0.0, None
51
53
 
52
54
  def log_message(self, *a):
53
55
  pass
@@ -63,6 +65,9 @@ class Handler(BaseHTTPRequestHandler):
63
65
  def do_GET(self):
64
66
  if self.path.rstrip("/") in ("/v1/models", "/models"):
65
67
  return self._json({"data": [{"id": self.model}]})
68
+ if self.max_output and self.path.rstrip("/").endswith("/endpoints"):
69
+ return self._json({"data": {"endpoints": [{"tag": "mock/fp16", "quantization": "fp16", "status": 0,
70
+ "max_completion_tokens": self.max_output}]}})
66
71
  if self.path == "/health":
67
72
  return self._json({"status": "ok", "model": self.model})
68
73
  self._json({"error": "not found"}, 404)
@@ -82,6 +87,9 @@ class Handler(BaseHTTPRequestHandler):
82
87
  if self.mode == "unavailable": # like OpenRouter with no provider serving the model
83
88
  return self._json({"error": {"message": f"No endpoints found for {self.model}.", "code": 404}}, 404)
84
89
  text, calls = reply_for(test, self.mode)
90
+ finish = "tool_calls" if calls else "stop"
91
+ if req.get("max_tokens") and len(text.split()) > req["max_tokens"]:
92
+ text, finish = " ".join(text.split()[:req["max_tokens"]]), "length"
85
93
  self.send_response(200)
86
94
  self.send_header("Content-Type", "text/event-stream")
87
95
  self.end_headers()
@@ -98,7 +106,7 @@ class Handler(BaseHTTPRequestHandler):
98
106
  send({"choices": [{"delta": {"tool_calls": [{"index": i, "function": {
99
107
  "name": c["name"], "arguments": json.dumps(c.get("arguments") or {})}}]}}]})
100
108
  words = max(1, len(text.split()) + 3)
101
- send({"choices": [{"delta": {}, "finish_reason": "tool_calls" if calls else "stop"}],
109
+ send({"choices": [{"delta": {}, "finish_reason": finish}],
102
110
  "usage": {"prompt_tokens": len(str(content).split()), "completion_tokens": words},
103
111
  "timings": {"predicted_n": words, "predicted_per_second": 50.0, "prompt_n": 10,
104
112
  "prompt_per_second": 500.0, "cache_n": 0}})
@@ -119,8 +127,10 @@ def main(argv=None):
119
127
  p.add_argument("--mode", choices=("oracle", "wrong", "fixed", "unavailable"), default="oracle")
120
128
  p.add_argument("--model", default="mock")
121
129
  p.add_argument("--delay", type=float, default=0.0, help="seconds each answer takes")
130
+ p.add_argument("--max-output", type=int)
122
131
  a, _ = p.parse_known_args(argv) # other flags (e.g. speed knobs under test) are accepted and ignored
123
132
  Handler.answers, Handler.mode, Handler.model, Handler.delay = load_answers(a.packs), a.mode, a.model, a.delay
133
+ Handler.max_output = a.max_output
124
134
  srv = Server(("127.0.0.1", a.port), Handler)
125
135
  print(f"mock server on 127.0.0.1:{a.port} ({a.mode}, {len(Handler.answers)} known questions)", flush=True)
126
136
  try:
@@ -49,10 +49,11 @@ def free_port():
49
49
 
50
50
 
51
51
  class Mock:
52
- def __init__(self, packs, mode, port=None, delay=0.0):
52
+ def __init__(self, packs, mode, port=None, delay=0.0, *args):
53
53
  self.port = port or free_port()
54
54
  self.proc = subprocess.Popen([sys.executable, os.path.join(HERE, "mock_server.py"), "--port", str(self.port),
55
- "--packs", packs, "--mode", mode, "--delay", str(delay)], stdout=subprocess.DEVNULL)
55
+ "--packs", packs, "--mode", mode, "--delay", str(delay), *args],
56
+ stdout=subprocess.DEVNULL)
56
57
  for _ in range(100):
57
58
  try:
58
59
  urllib.request.urlopen(f"http://127.0.0.1:{self.port}/v1/models", timeout=1)
@@ -1815,6 +1816,109 @@ class FirstPack(unittest.TestCase):
1815
1816
  self.assertIn("→ fix the expected answer", out)
1816
1817
 
1817
1818
 
1819
+ class PackMaxTokens(unittest.TestCase):
1820
+ """A pack's own max_tokens: sent with its requests, part of its fingerprint only, and packs that
1821
+ can't fit it (context, a hosted endpoint's output limit) are skipped."""
1822
+ LONG = " ".join(["word"] * 40) + "\nANSWER: 7" # ~42 tokens at the mock's one token per word
1823
+
1824
+ def setUp(self):
1825
+ self.tmp = tempfile.TemporaryDirectory()
1826
+ self.ws = os.path.join(self.tmp.name, "ws")
1827
+ tuieval(self.tmp.name, "init", self.ws)
1828
+ for name, extra in (("short", "max_tokens = 20\n"), ("roomy", "max_tokens = 100\n"), ("plain", "")):
1829
+ self.pack(name, extra)
1830
+
1831
+ def tearDown(self):
1832
+ self.tmp.cleanup()
1833
+
1834
+ def pack(self, name, extra):
1835
+ d = os.path.join(self.ws, "packs", name)
1836
+ os.makedirs(d, exist_ok=True)
1837
+ write(os.path.join(d, "pack.toml"), f'label = "{name}"\n{extra}[gate]\nmin_accuracy = 0.1\n')
1838
+ write(os.path.join(d, "tests.yaml"), f"- id: q\n input: {name} question\n expected: 7\n"
1839
+ f" difficulty: easy\n reference: {json.dumps(self.LONG)}\n")
1840
+
1841
+ def engine(self, code):
1842
+ return subprocess.run([sys.executable, "-c", "from tuieval import engine, packs; e = engine.Engine(); " + code],
1843
+ capture_output=True, text=True, env=dict(os.environ, TUIEVAL_HOME=self.ws))
1844
+
1845
+ def result(self, label, pack):
1846
+ path = os.path.join(self.ws, "results", label, f"{pack}.json")
1847
+ return json.loads(read(path)) if os.path.isfile(path) else None
1848
+
1849
+ def test_load_and_fingerprint(self):
1850
+ from tuieval import packs
1851
+ d = os.path.join(self.ws, "packs", "plain")
1852
+ before = packs.load_pack(d)
1853
+ self.assertEqual(before.max_tokens, 0)
1854
+ self.pack("plain", "") # rewritten unchanged: same fingerprint as before the feature
1855
+ self.assertEqual(packs.load_pack(d).fingerprint, before.fingerprint)
1856
+ self.pack("plain", "max_tokens = 4096\n")
1857
+ p = packs.load_pack(d)
1858
+ self.assertEqual(p.max_tokens, 4096)
1859
+ self.assertNotEqual(p.fingerprint, before.fingerprint)
1860
+ self.pack("plain", "max_tokens = 8192\n")
1861
+ self.assertNotEqual(packs.load_pack(d).fingerprint, p.fingerprint)
1862
+ for bad in ("0", "-5", '"lots"', "1.5", "true"):
1863
+ self.pack("plain", f"max_tokens = {bad}\n")
1864
+ with self.assertRaises(packs.PackError, msg=bad):
1865
+ packs.load_pack(d)
1866
+ self.assertIn("max_tokens must be", tuieval(self.ws, "selftest", check=False).stdout)
1867
+
1868
+ def test_requests_use_the_packs_budget(self):
1869
+ mock = Mock(os.path.join(self.ws, "packs"), "oracle")
1870
+ try:
1871
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
1872
+ f.write(f'\n[servers.m]\nurl = "http://127.0.0.1:{mock.port}"\n')
1873
+ tuieval(self.ws, "add", "mock", "--server", "m", "--label", "m")
1874
+ tuieval(self.ws, "run", "--tier", "certify")
1875
+ res = {p: self.result("m", p) for p in ("short", "roomy", "plain")}
1876
+ self.assertEqual(res["short"]["results"][0]["finish"], "length") # 42 tokens > 20
1877
+ self.assertIn("TRUNCATED", res["short"]["results"][0]["reason"])
1878
+ self.assertTrue(res["roomy"]["results"][0]["pass"])
1879
+ self.assertTrue(res["plain"]["results"][0]["pass"]) # [sampling] max_tokens
1880
+ self.assertEqual(res["short"]["pack"]["max_tokens"], 20)
1881
+ self.assertNotIn("max_tokens", res["plain"]["pack"])
1882
+ self.assertEqual(res["short"]["settings"], res["plain"]["settings"]) # model settings untouched
1883
+
1884
+ # changing one pack's budget makes only that pack's results outdated
1885
+ self.pack("short", "max_tokens = 50\n")
1886
+ out = self.engine("print(e.result_status('m', 'short')); print(e.result_status('m', 'plain'))").stdout
1887
+ self.assertEqual(out.splitlines(), ["outdated: questions changed", "certified"])
1888
+ tuieval(self.ws, "run", "--tier", "certify")
1889
+ self.assertTrue(self.result("m", "short")["results"][0]["pass"])
1890
+ finally:
1891
+ mock.stop()
1892
+
1893
+ def test_skipped_when_the_context_is_too_small(self):
1894
+ self.pack("short", "max_tokens = 16000\n")
1895
+ mock = Mock(os.path.join(self.ws, "packs"), "oracle")
1896
+ try:
1897
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
1898
+ f.write(f'\n[servers.m]\nurl = "http://127.0.0.1:{mock.port}"\n'
1899
+ '\n[[models]]\nlabel = "small"\nserver = "m"\nmodel = "mock"\nmax_context = 8192\n')
1900
+ out = tuieval(self.ws, "run", "--tier", "certify").stdout
1901
+ finally:
1902
+ mock.stop()
1903
+ self.assertIsNone(self.result("small", "short"))
1904
+ self.assertIn("needs ~15k context", out)
1905
+ self.assertIsNotNone(self.result("small", "plain"))
1906
+
1907
+ def test_skipped_when_the_endpoint_allows_fewer_output_tokens(self):
1908
+ mock = Mock(os.path.join(self.ws, "packs"), "oracle", None, 0.0, "--max-output", "50")
1909
+ try:
1910
+ with open(os.path.join(self.ws, "models.toml"), "a") as f:
1911
+ f.write(f'\n[servers.hosted]\nurl = "http://127.0.0.1:{mock.port}/v1"\npin_endpoint = true\n'
1912
+ '\n[[models]]\nlabel = "h"\nserver = "hosted"\nmodel = "mock"\n')
1913
+ out = tuieval(self.ws, "run", "--tier", "certify").stdout
1914
+ finally:
1915
+ mock.stop()
1916
+ self.assertIsNone(self.result("h", "roomy"))
1917
+ self.assertIn("needs 100 output tokens, endpoint allows 50", out)
1918
+ self.assertIsNotNone(self.result("h", "short"))
1919
+ self.assertIsNotNone(self.result("h", "plain"))
1920
+
1921
+
1818
1922
  def fake_gguf(path, embedding=5120, size=0, kv_heads=4):
1819
1923
  """A GGUF header with just the keys machines.read_gguf reads, padded to size bytes. kv_heads: a
1820
1924
  number, or one per layer (a list, as some GGUFs store it)."""
File without changes
File without changes
File without changes