tuieval 0.2.0.dev90__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.
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/CHANGELOG.md +2 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/PKG-INFO +1 -1
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/models.md +1 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/_version.py +2 -2
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/builtin.toml +1 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/client.py +24 -5
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/engine.py +3 -1
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/tui.py +31 -1
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/tests/mock_server.py +17 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/tests/test_tuieval.py +72 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/.gitignore +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/LICENSE +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/README.md +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/RELEASING.md +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/README.md +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/favicon.ico +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/readme-header.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/social-preview.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-1024.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-128.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-16.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-16.svg +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-256.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-32.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-48.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-512.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-64.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon-animated.svg +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/brand/tuieval-icon.svg +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/tui-answer-reasoning.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/tui-run-reasoning.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/images/tui-setup.png +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/docs/writing-packs.md +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/pyproject.toml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/__init__.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/__main__.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/cli.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/compare.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/doctor.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/export.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/graders/__init__.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/graders/answer.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/graders/code.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/graders/rag.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/graders/reply.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/graders/tool_call.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/logs.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/machines.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/onboard.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/packs.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/profiles.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/pta.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/remove.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/run_evals.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/scaffold.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/selftest.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/models.toml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/answer/pack.toml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/answer/system.txt +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/code/pack.toml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/code/system.txt +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/code/tests.yaml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/rag/pack.toml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/rag/system.txt +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/reply/pack.toml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/reply/system.txt +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/tune.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/verdict.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/watch_proxy.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/workspace.py +0 -0
- {tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/yamlout.py +0 -0
|
@@ -2,6 +2,8 @@
|
|
|
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.
|
|
5
7
|
- Results, Per question: the side-by-side summary shows any number of models (it stopped at 8).
|
|
6
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.
|
|
7
9
|
- The run screen's queue keeps the running model in view, unless you scroll it yourself (scrolling back to it follows again).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tuieval
|
|
3
|
-
Version: 0.2.0.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2, 0, '
|
|
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
|
|
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
|
|
151
|
-
|
|
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["
|
|
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
|
-
|
|
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):
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/system.txt
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/tests.yaml
RENAMED
|
File without changes
|
{tuieval-0.2.0.dev90 → tuieval-0.2.0.dev92}/src/tuieval/templates/packs/tool_call/tools.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|