tuieval 0.2.0.dev89__tar.gz → 0.2.0.dev92__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.dev89 → tuieval-0.2.0.dev92}/CHANGELOG.md +3 -0
  2. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/models.md +1 -0
  4. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/builtin.toml +1 -0
  6. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/client.py +24 -5
  7. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/engine.py +3 -1
  8. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/tui.py +33 -4
  9. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/tests/mock_server.py +17 -0
  10. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/tests/test_tuieval.py +72 -0
  11. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/.gitignore +0 -0
  12. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/LICENSE +0 -0
  13. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/README.md +0 -0
  14. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/RELEASING.md +0 -0
  15. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/README.md +0 -0
  16. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/favicon.ico +0 -0
  17. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/readme-header.png +0 -0
  18. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/social-preview.png +0 -0
  19. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-1024.png +0 -0
  20. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-128.png +0 -0
  21. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-16.png +0 -0
  22. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-16.svg +0 -0
  23. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-256.png +0 -0
  24. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-32.png +0 -0
  25. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-48.png +0 -0
  26. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-512.png +0 -0
  27. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-64.png +0 -0
  28. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  29. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon.svg +0 -0
  30. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/tui-answer-reasoning.png +0 -0
  31. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/tui-run-reasoning.png +0 -0
  32. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/images/tui-setup.png +0 -0
  33. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/docs/writing-packs.md +0 -0
  34. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/pyproject.toml +0 -0
  35. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/__init__.py +0 -0
  36. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/__main__.py +0 -0
  37. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/cli.py +0 -0
  38. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/compare.py +0 -0
  39. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/doctor.py +0 -0
  40. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/export.py +0 -0
  41. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/graders/__init__.py +0 -0
  42. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/graders/answer.py +0 -0
  43. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/graders/code.py +0 -0
  44. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/graders/rag.py +0 -0
  45. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/graders/reply.py +0 -0
  46. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/graders/tool_call.py +0 -0
  47. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/logs.py +0 -0
  48. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/machines.py +0 -0
  49. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/onboard.py +0 -0
  50. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/packs.py +0 -0
  51. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/profiles.py +0 -0
  52. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/pta.py +0 -0
  53. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/run_evals.py +0 -0
  55. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev89 → tuieval-0.2.0.dev92}/src/tuieval/yamlout.py +0 -0
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A server that stops sending tokens mid-answer for 5 minutes (`stall_timeout_ms`) is a server error: retried, never counted against the model. Keep-alive pings no longer stretch a request past `request_timeout_ms`.
6
+ - The run screen's queue shows what each running model is doing: waiting, thinking or answering with ~tokens so far, and how long it's been quiet, so a model you aren't streaming doesn't look stuck.
7
+ - Results, Per question: the side-by-side summary shows any number of models (it stopped at 8).
5
8
  - `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.
6
9
  - The run screen's queue keeps the running model in view, unless you scroll it yourself (scrolling back to it follows again).
7
10
  - 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev89
3
+ Version: 0.2.0.dev92
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
@@ -191,6 +191,7 @@ A workspace's `models.toml` only needs what differs from these. `[defaults]` fil
191
191
  [defaults]
192
192
  repeat = 3
193
193
  request_timeout_ms = 1800000 # per request; long thinking runs need it
194
+ stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
194
195
  ready_timeout_s = 1200 # how long a model may take to load before giving up
195
196
  model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
196
197
 
@@ -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.dev89'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev89')
21
+ __version__ = version = '0.2.0.dev92'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev92')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -5,6 +5,7 @@
5
5
  [defaults]
6
6
  repeat = 3
7
7
  request_timeout_ms = 1800000 # per request; long thinking runs need it
8
+ stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
8
9
  ready_timeout_s = 1200 # how long a model may take to load before giving up
9
10
  model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
10
11
 
@@ -99,10 +99,17 @@ def count_tokens(base_url, text):
99
99
  return None
100
100
 
101
101
 
102
- def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, headers=None):
102
+ def _stalled(stall_s):
103
+ return f"server error: stalled, no tokens for {stall_s:.0f} s after it started answering"
104
+
105
+
106
+ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, headers=None, stall_s=None):
103
107
  """POST {base_url}/v1/chat/completions with streaming. Returns a dict (see module docstring).
104
108
 
105
109
  on_delta(kind, text) is called for "reasoning" and "answer" text as it arrives.
110
+ timeout bounds the whole request, keep-alive comments (OpenRouter's ": OPENROUTER PROCESSING")
111
+ included; running out counts against the model, like any slow answer. stall_s: once tokens have
112
+ started, this long without one is the server stalling (a server error, never the model's answer).
106
113
  """
107
114
  stream = stream or Stream()
108
115
  body = dict(body, stream=True, stream_options={"include_usage": True})
@@ -111,7 +118,7 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
111
118
  out = {"answer": "", "reasoning": "", "tool_calls": [], "finish": None, "usage": {}, "timings": {},
112
119
  "ttft_s": None, "total_s": None, "error": None}
113
120
  reasoning, answer, tools = [], [], {}
114
- start = time.time()
121
+ start = last = time.time()
115
122
  try:
116
123
  conn.request("POST", prefix + "/v1/chat/completions", body=json.dumps(body),
117
124
  headers={"Content-Type": "application/json", "Authorization": "Bearer none", **(headers or {})})
@@ -123,6 +130,13 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
123
130
  out["error"] = f"server returned HTTP {r.status}: {r.read()[:300].decode('utf-8', 'replace')}"
124
131
  return out
125
132
  for raw in r:
133
+ now = time.time()
134
+ if now - start > timeout:
135
+ out["error"] = f"connection error: timed out after {timeout:.0f} s in all"
136
+ break
137
+ if stall_s and out["ttft_s"] is not None and now - last > stall_s:
138
+ out["error"] = _stalled(stall_s)
139
+ break
126
140
  line = raw.decode("utf-8", "replace").strip()
127
141
  if not line.startswith("data:"):
128
142
  continue
@@ -147,8 +161,12 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
147
161
  fn = tc.get("function") or {}
148
162
  slot["name"] += fn.get("name") or ""
149
163
  slot["arguments"] += fn.get("arguments") or ""
150
- if (think or text or delta.get("tool_calls")) and out["ttft_s"] is None:
151
- out["ttft_s"] = time.time() - start
164
+ if think or text or delta.get("tool_calls"):
165
+ last = time.time()
166
+ if out["ttft_s"] is None:
167
+ out["ttft_s"] = last - start
168
+ if stall_s: # a server that goes silent mid-answer: don't wait out the whole timeout
169
+ stream.sock.settimeout(min(stall_s, timeout))
152
170
  if think:
153
171
  reasoning.append(think)
154
172
  on_delta and on_delta("reasoning", think)
@@ -158,7 +176,8 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
158
176
  except (OSError, http.client.HTTPException, ValueError) as e:
159
177
  if stream.cancelled:
160
178
  raise Cancelled() from None
161
- out["error"] = f"connection error: {e!r}"
179
+ stalled = stall_s and out["ttft_s"] is not None and isinstance(e, TimeoutError)
180
+ out["error"] = _stalled(stall_s) if stalled else f"connection error: {e!r}"
162
181
  finally:
163
182
  conn.close()
164
183
  out["total_s"] = time.time() - start
@@ -2229,6 +2229,7 @@ class Engine:
2229
2229
  self.emit("job_started", job=job, resumed=len(done_keys),
2230
2230
  earlier_s=sum(s.get("wall_s") or 0 for s in job.earlier), sittings=len(job.earlier))
2231
2231
  timeout = self.cfg["defaults"]["request_timeout_ms"] / 1000
2232
+ stall_s = (self.cfg["defaults"].get("stall_timeout_ms") or 0) / 1000 or None
2232
2233
  headers = self.request_headers(job.model)
2233
2234
  # a hosted API has no /tokenize to split reasoning from answer tokens (it reports the split)
2234
2235
  tokenize_url = None if self.cfg["servers"][job.model["server"]].get("api_key_env") else base_url
@@ -2253,7 +2254,8 @@ class Engine:
2253
2254
  body = self._body(job, test, rep)
2254
2255
  res = client.stream_chat(base_url, body,
2255
2256
  lambda kind, text: self.emit("delta", stream=kind, text=text, label=job.label),
2256
- timeout=timeout, stream=self._stream, headers=headers)
2257
+ timeout=timeout, stream=self._stream, headers=headers,
2258
+ stall_s=stall_s)
2257
2259
  except client.Cancelled:
2258
2260
  self._check()
2259
2261
  raise Cancelled()
@@ -2104,6 +2104,7 @@ class RunScreen(Screen):
2104
2104
  self.follow = True
2105
2105
  self.buf = {"reasoning": [], "answer": []}
2106
2106
  self.buf_lock = threading.Lock()
2107
+ self.req_live = {} # model label -> its request now: phase, ~tokens, started, last token
2107
2108
  self.started = time.time()
2108
2109
  self.req_started = None
2109
2110
  self.req_tokens = 0
@@ -2235,7 +2236,20 @@ class RunScreen(Screen):
2235
2236
  return # the app is shutting down; the UI thread may be waiting on the engine
2236
2237
  if kind in ("request_started", "model_loading") and self.watch not in self.active():
2237
2238
  self.watch = d["job"].label if "job" in d else d["label"] # follow the next model streaming
2239
+ if kind in ("request_started", "server_retry"): # here, not in handle(): deltas follow at once
2240
+ with self.buf_lock:
2241
+ self.req_live[d["job"].label if "job" in d else d["label"]] = {
2242
+ "phase": "waiting", "tokens": 0, "started": time.time(), "last": None}
2243
+ elif kind == "request_done":
2244
+ with self.buf_lock:
2245
+ self.req_live.pop(d["job"].label, None)
2238
2246
  if kind == "delta": # very frequent: buffer, flushed every 100 ms on the UI thread
2247
+ with self.buf_lock:
2248
+ lv = self.req_live.get(d.get("label"))
2249
+ if lv:
2250
+ lv["phase"] = "thinking" if d["stream"] == "reasoning" else "answering"
2251
+ lv["tokens"] += max(1, len(d["text"]) // 4)
2252
+ lv["last"] = time.time()
2239
2253
  if d.get("label", self.watch) != self.watch:
2240
2254
  return # another model running alongside: v switches to it
2241
2255
  with self.buf_lock:
@@ -2439,7 +2453,8 @@ class RunScreen(Screen):
2439
2453
  elapsed = (j.finished or time.time()) - j.started if j.started else 0
2440
2454
  elapsed += sum(s.get("wall_s") or 0 for s in j.earlier) # a resumed pack: its earlier sittings too
2441
2455
  # update_width: columns start at their header's width; "12" under ✓ would otherwise show as "1"
2442
- for col, value in (("status", f"[{style}]{j.status}[/{style}]"),
2456
+ status = f"[{style}]{j.status}[/{style}]" + (self.live_note(j.label) if j.status == "running" else "")
2457
+ for col, value in (("status", status),
2443
2458
  ("progress", f"{j.done}/{j.total}" if j.status != "skipped" or j.done else ""),
2444
2459
  ("pass", f"[green]{j.passed}[/green]" if j.passed else ""),
2445
2460
  ("fail", f"[red]{j.failed}[/red]" if j.failed else ""),
@@ -2447,6 +2462,21 @@ class RunScreen(Screen):
2447
2462
  ("note", short_note(j.note))):
2448
2463
  q.update_cell(j.key, col, value, update_width=True)
2449
2464
 
2465
+ def live_note(self, label):
2466
+ """What a running model's request is doing now, so a slow model doesn't look stuck: waiting for
2467
+ its first token, or thinking/answering with ~tokens so far, and how long it's been quiet."""
2468
+ with self.buf_lock:
2469
+ lv = dict(self.req_live.get(label) or {})
2470
+ if not lv:
2471
+ return ""
2472
+ now = time.time()
2473
+ if lv["last"] is None:
2474
+ return f" [dim]· waiting {fmt_secs(now - lv['started'])}[/dim]"
2475
+ note = f" [dim]· {lv['phase']} ~{lv['tokens']:,} tok[/dim]"
2476
+ quiet = now - lv["last"]
2477
+ return note + (f" [yellow]· quiet {fmt_secs(quiet)}[/yellow]" if quiet >= self.QUIET_S else "")
2478
+
2479
+ QUIET_S = 30 # a running model's row says how long it's been without a token after this long
2450
2480
  LIVE_ANSWERS = 5 # a pack's own times this session take over from its history after this many
2451
2481
 
2452
2482
  def eta_seconds(self):
@@ -2983,10 +3013,9 @@ class ResultsScreen(Screen):
2983
3013
  table.clear(columns=True)
2984
3014
  title = self.query_one("#pq-compare-title", Static)
2985
3015
  shared = [t for t in tests if all(m in t["cells"] for m in models)]
2986
- if len(models) < 2 or len(models) > 8:
3016
+ if len(models) < 2:
2987
3017
  table.display = False
2988
- title.update("[dim]Pick 2 to 8 models (Models button) for a side-by-side summary.[/dim]"
2989
- if len(models) > 8 else "")
3018
+ title.update("")
2990
3019
  return
2991
3020
  table.display = True
2992
3021
  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
+ --stall S: after the first token, S seconds of nothing but keep-alive comments (": OPENROUTER
13
+ PROCESSING"), or of silence with --silent; --stall-before S: the same before the first token.
12
14
  An answer longer than the request's max_tokens (one token per word) is cut off with finish
13
15
  "length". --max-output N also serves an OpenRouter-style endpoints list with that output limit.
14
16
  """
@@ -50,6 +52,7 @@ def reply_for(test, mode):
50
52
 
51
53
  class Handler(BaseHTTPRequestHandler):
52
54
  answers, mode, model, delay, max_output = {}, "oracle", "mock", 0.0, None
55
+ stall, stall_before, silent = 0.0, 0.0, False
53
56
 
54
57
  def log_message(self, *a):
55
58
  pass
@@ -98,7 +101,17 @@ class Handler(BaseHTTPRequestHandler):
98
101
  self.wfile.write(f"data: {json.dumps(obj)}\n\n".encode())
99
102
  self.wfile.flush()
100
103
 
104
+ def wait(secs):
105
+ end = time.time() + secs
106
+ while time.time() < end:
107
+ if not self.silent:
108
+ self.wfile.write(b": OPENROUTER PROCESSING\n\n")
109
+ self.wfile.flush()
110
+ time.sleep(0.2)
111
+
112
+ wait(self.stall_before)
101
113
  send({"choices": [{"delta": {"reasoning_content": "Thinking it over. "}}]})
114
+ wait(self.stall)
102
115
  time.sleep(self.delay or 0.005)
103
116
  for i in range(0, len(text), 12):
104
117
  send({"choices": [{"delta": {"content": text[i:i + 12]}}]})
@@ -128,9 +141,13 @@ def main(argv=None):
128
141
  p.add_argument("--model", default="mock")
129
142
  p.add_argument("--delay", type=float, default=0.0, help="seconds each answer takes")
130
143
  p.add_argument("--max-output", type=int)
144
+ p.add_argument("--stall", type=float, default=0.0)
145
+ p.add_argument("--stall-before", type=float, default=0.0)
146
+ p.add_argument("--silent", action="store_true")
131
147
  a, _ = p.parse_known_args(argv) # other flags (e.g. speed knobs under test) are accepted and ignored
132
148
  Handler.answers, Handler.mode, Handler.model, Handler.delay = load_answers(a.packs), a.mode, a.model, a.delay
133
149
  Handler.max_output = a.max_output
150
+ Handler.stall, Handler.stall_before, Handler.silent = a.stall, a.stall_before, a.silent
134
151
  srv = Server(("127.0.0.1", a.port), Handler)
135
152
  print(f"mock server on 127.0.0.1:{a.port} ({a.mode}, {len(Handler.answers)} known questions)", flush=True)
136
153
  try:
@@ -2373,5 +2373,77 @@ class Logs(unittest.TestCase):
2373
2373
  self.assertEqual(out, "── chat ──\nchat line\n── coder-q4 ──\nq4 server started\n")
2374
2374
 
2375
2375
 
2376
+ class StallGuard(unittest.TestCase):
2377
+ """A server that stops sending tokens mid-answer (keep-alive comments or silence) is a server error,
2378
+ not the model's answer; keep-alives can't stretch a request past its overall timeout."""
2379
+
2380
+ @classmethod
2381
+ def setUpClass(cls):
2382
+ cls.tmp = tempfile.TemporaryDirectory()
2383
+ os.makedirs(os.path.join(cls.tmp.name, "packs"))
2384
+
2385
+ @classmethod
2386
+ def tearDownClass(cls):
2387
+ cls.tmp.cleanup()
2388
+
2389
+ def ask(self, *flags, **kw):
2390
+ from tuieval import client
2391
+ mock = Mock(os.path.join(self.tmp.name, "packs"), "fixed", None, 0.0, *flags)
2392
+ try:
2393
+ t = time.time()
2394
+ res = client.stream_chat(f"http://127.0.0.1:{mock.port}/v1", {"model": "mock", "messages": [
2395
+ {"role": "user", "content": "q"}]}, **kw)
2396
+ return res, time.time() - t
2397
+ finally:
2398
+ mock.stop()
2399
+
2400
+ def test_keepalives_after_first_token_stall(self):
2401
+ from tuieval import client
2402
+ res, took = self.ask("--stall", "5", timeout=30, stall_s=1)
2403
+ self.assertTrue(res["error"].startswith("server error: stalled"), res["error"])
2404
+ self.assertTrue(client.is_server_error(res["error"]))
2405
+ self.assertLess(took, 4)
2406
+
2407
+ def test_silence_after_first_token_stalls(self):
2408
+ from tuieval import client
2409
+ res, took = self.ask("--stall", "5", "--silent", timeout=30, stall_s=1)
2410
+ self.assertTrue(client.is_server_error(res["error"]), res["error"])
2411
+ self.assertIn("stalled", res["error"])
2412
+ self.assertLess(took, 4)
2413
+
2414
+ def test_long_wait_before_first_token_is_not_a_stall(self):
2415
+ res, _ = self.ask("--stall-before", "1.5", timeout=30, stall_s=1)
2416
+ self.assertIsNone(res["error"])
2417
+ self.assertIn("42", res["answer"])
2418
+
2419
+ def test_keepalives_dont_outlast_the_timeout(self):
2420
+ from tuieval import client
2421
+ res, took = self.ask("--stall-before", "5", timeout=1, stall_s=1)
2422
+ self.assertIn("timed out", res["error"])
2423
+ self.assertFalse(client.is_server_error(res["error"])) # slow, like any timeout: counts
2424
+ self.assertLess(took, 4)
2425
+
2426
+ def test_run_screen_shows_each_running_models_progress(self):
2427
+ from types import SimpleNamespace
2428
+ from tuieval import tui
2429
+ calls = []
2430
+ fake = SimpleNamespace(app=SimpleNamespace(closing=False, call_from_thread=lambda *a: calls.append(a)),
2431
+ watch="a", active=lambda: ["a", "b"], buf={"reasoning": [], "answer": []},
2432
+ buf_lock=threading.Lock(), req_live={}, QUIET_S=30, handle=None)
2433
+ fake.live_note = lambda label: tui.RunScreen.live_note(fake, label)
2434
+ ev = lambda kind, **d: tui.RunScreen.on_engine_event(fake, kind, **d)
2435
+ ev("request_started", job=SimpleNamespace(label="b"), test="t")
2436
+ self.assertIn("waiting", fake.live_note("b"))
2437
+ ev("delta", stream="reasoning", text="x" * 400, label="b") # b isn't streamed: still counted
2438
+ self.assertEqual(fake.buf["reasoning"], [])
2439
+ self.assertIn("thinking ~100 tok", fake.live_note("b"))
2440
+ ev("delta", stream="answer", text="y" * 40, label="b")
2441
+ self.assertIn("answering ~110 tok", fake.live_note("b"))
2442
+ fake.req_live["b"]["last"] -= 90
2443
+ self.assertIn("quiet 1m30s", fake.live_note("b"))
2444
+ ev("request_done", job=SimpleNamespace(label="b"), record={})
2445
+ self.assertEqual(fake.live_note("b"), "")
2446
+
2447
+
2376
2448
  if __name__ == "__main__":
2377
2449
  unittest.main()
File without changes
File without changes
File without changes