opencode-harness-kit 0.4.4 → 0.4.6

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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,57 @@ is not happening.
12
12
 
13
13
  ---
14
14
 
15
+ ## [0.4.6] — 2026-10-21
16
+
17
+ Published. The tag is what cut it: `git tag v0.4.6 && git push origin main
18
+ v0.4.6`, and the publish job watches for exactly that.
19
+
20
+ ### Fixed
21
+
22
+ - **The model measurement table said `0 null`** when a model that publishes
23
+ a `low` effort level failed every request for that variant. A row of
24
+ zeroes reads like a measurement of nothing; a row that says the request
25
+ failed says what happened.
26
+
27
+ ## [0.4.5] — 2026-10-21
28
+
29
+ Published. The tag is what cut it: `git tag v0.4.5 && git push origin main
30
+ v0.4.5`, and the publish job watches for exactly that.
31
+
32
+ This release is the measurement side of the kit: the numbers in the routing
33
+ table were taken once, by hand, and could not be taken again. Now they can
34
+ be, and the page that carries them says which measurement it is holding.
35
+
36
+ ### Added
37
+
38
+ - **`eval/models.mjs`** - measures every model the shipped profiles use, one
39
+ line each, the same prompt for all of them, with the `low` effort variant
40
+ as well. It is what the routing table in `docs/ROUTING.md` is measured
41
+ with, and it did not exist: the numbers in that table had been taken once,
42
+ by hand, and could not be taken again.
43
+
44
+ ### Changed
45
+
46
+ - **`docs/ROUTING.md` is measured again**, on 2026-10-21, and the two sets
47
+ of numbers are now labelled for what they measure. The install tables are
48
+ the telemetry of a working session; the new one is a direct call, from the
49
+ process start, which is the wait a lane would actually cost. Beam is still
50
+ the fastest of the ten, but by under a second over the next three, so the
51
+ reason to pick it is the 1M context as much as the speed. GLM is level
52
+ with Nemotron 3 Ultra for review, so the safe ceiling costs nothing
53
+ there. LongCat is twice Beam on the same one-line question.
54
+
55
+ - **The 0.4.4 A/B table above was corrected.** It was written from the run
56
+ the grader mis-scored, and it said the kit was worse. The shipped
57
+ measurement is a tie.
58
+
59
+ ### Fixed
60
+
61
+ - **`eval/models.mjs` accepted a mistyped `--reps`** and measured nothing
62
+ while reporting success - the same class of bug this kit keeps finding in
63
+ other places. It also ignored `--reps=5`, did not say why a model failed,
64
+ and left a scratch directory behind when it was killed.
65
+
15
66
  ## [0.4.4] — 2026-10-21
16
67
 
17
68
  Published. The tag is what cut it: `git tag v0.4.4 && git push origin main
@@ -115,15 +166,6 @@ The field in the log was renamed from `savedBytes` to `savedChars`, and
115
166
  the log is project-local and persistent: every project that upgraded had
116
167
  the old records and read them as zero. The old name is still read.
117
168
 
118
- **The published type declarations did not resolve for a consumer.**
119
-
120
- They import a type from `@opencode-ai/plugin`, which was a devDependency, so
121
- a consumer that installed the package type-checked against a type it did
122
- not have. The subpath exports - `opencode-harness-kit/profile` and the rest
123
- - declared no types at all, even though the declarations were built and
124
- shipped. The plugin is now an optional peer dependency and every subpath
125
- declares its types.
126
-
127
169
  **`oc-harness cost` reported characters as bytes.**
128
170
 
129
171
  The pruner counts characters, and the number it printed said "bytes", which
@@ -301,9 +343,11 @@ improvement from a NULL average.
301
343
  parts UNPRUNED - the pruner rewrites the outgoing request, not the history
302
344
  - so the stored parts of a real session are exactly what the pruner was
303
345
  given, and replaying the real pruner over them measures exactly what it
304
- removed, on the same messages with and without. On this install: **80.5% of
305
- the context across 60 real sessions**, 6,608 parts stubbed - the number
306
- `npm run measure:prune` prints.
346
+ removed, on the same messages with and without. On this install, measured on
347
+ 2026-10-20: **80.5% of the context across 60 real sessions**, 6,608 parts
348
+ stubbed. The number moves with the sessions, so it is a measurement of one
349
+ install at one moment - run `npm run measure:prune` for the number on
350
+ yours.
307
351
 
308
352
  The measurement scripts also run without the sqlite3 CLI now. python3's
309
353
  built-in sqlite3 module does the same job, and python3 is on more machines
@@ -469,23 +513,33 @@ improvement from a NULL average.
469
513
  check is not a result. The new suite runs against the kit's own tree at a
470
514
  pinned commit, so `git clone && git checkout <sha>` reproduces it exactly.
471
515
 
472
- Ten reps per arm, 120 agent runs, and the result is not flattering:
516
+ Ten reps per arm, 120 agent runs, on the same model in both arms:
473
517
 
474
518
  | metric (median) | kit on | kit off |
475
519
  |---|---|---|
476
- | **correct** | **52/60** | **59/59** |
477
- | tool calls | 11.5 | 3 |
478
- | input tokens | 51,449 | 4,551 |
479
- | final context | 39,603 | 10,855 |
480
- | wall seconds | 43.6 | 32.5 |
520
+ | **correct** | **54/60** | **55/60** |
521
+ | tool calls | 12.5 | 8 |
522
+ | input tokens | 56,205 | 10,540 |
523
+ | final context | 39,928 | 12,718 |
524
+ | wall seconds | 43 | 21 |
481
525
  | paths written outside the target | 0 | 0 |
482
526
 
483
527
  **On a task that fits in one read, the kit is a cost.** Its instructions
484
- cost eleven times the input tokens and four times the tool calls, the run
485
- takes a third longer, and the answers are worse. The rule that says *locate
486
- before you read* is a waste on a one-file question, and the tool counts show
487
- it. The two edit tasks are a tie at 10/10, and the guard stopped no writes
488
- in either arm.
528
+ cost over five times the input tokens and half again as many tool calls,
529
+ and the run takes twice as long. The answers are a tie - 54 against 55,
530
+ and a second run of the same 120 runs gave 55 against 54, the other way.
531
+ The rule that says *locate before you read* is a waste on a one-file
532
+ question, and the tool counts show it. The two edit tasks are a tie at
533
+ 10/10, and the guard stopped no writes in either arm.
534
+
535
+ **The first version of this table said the kit was worse - 52/60 against
536
+ 59/59 - and the grader was why.** It could not read a citation written as
537
+ `**File:** x` on one line and `**Line:** 105` on the next, which is the
538
+ shape the kit's own instructions produce, while the kit-off arm answered
539
+ in prose and was graded by a pattern that handled prose. Re-scoring the
540
+ same 120 answers with the fixed grader puts the kit ahead on the lookup
541
+ tasks. A harness that measures the harness is worse than one that measures
542
+ nothing.
489
543
 
490
544
  The value the kit does have is in long sessions - the pruner removes 80.5%
491
545
  of the context across real ones - and in process, which a grader that
package/CONTRIBUTING.md CHANGED
@@ -69,6 +69,13 @@ config, run the layers individually and say so in the PR.
69
69
  not part of any check, and a PR does not need it. `npm run measure` does not
70
70
  spawn an agent either — it queries the install database read-only.
71
71
 
72
+ `eval/models.mjs` spawns real agents too, one line each, against the live
73
+ tier: it is what the routing table in `docs/ROUTING.md` is measured with,
74
+ and it costs a request per model per rep. It is not part of any check
75
+ either. If you change a lane, a model or a threshold, run it and put the
76
+ new numbers in the same commit — a routing table with numbers from a
77
+ different day is a table nobody can check.
78
+
72
79
  ## House rules
73
80
 
74
81
  - Node 22 or newer. The CLI uses `node:` prefixed builtins, so an older Node
package/README.md CHANGED
@@ -80,16 +80,22 @@ about your prompts.
80
80
 
81
81
  | lane | job | model | measured |
82
82
  |---|---|---|---|
83
- | `build` | write and verify the change | MiniMax M3.1 Flash | 3.1 s |
84
- | `plan` | turn a request into a plan | Beam | **2.6 s** |
85
- | `reviewer` | read a change in two passes | Nemotron 3 Ultra | 4.7 s (34 s cold) |
86
- | `locate` | find where something lives | Nemotron 3.5 Lightning | 3.7 s |
87
- | `verify` | run the project's checks | Big Pickle | 6.5 s |
88
- | `vision` | read an image or a screenshot | MiMo-V2.6 Flash | 19.2 s |
89
- | `doc` | read a long document or a spec | GLM 5.3 Flash | 5.4 s |
90
- | `schema` | produce JSON or a JSON schema | DeepSeek V4.1 Flash | 3.4 s |
91
- | `sweep` | sweep a lot of code mechanically | Nemotron 3.5 Super VL | 4.2 s |
92
- | `longform` | read a megabyte, or write alternatives | LongCat 2.5 Preview | 9.4 s |
83
+ | `build` | write and verify the change | MiniMax M3.1 Flash | 6.9 s |
84
+ | `plan` | turn a request into a plan | Beam | **6.1 s** |
85
+ | `reviewer` | read a change in two passes | Nemotron 3 Ultra | 7.8 s (34 s cold) |
86
+ | `locate` | find where something lives | Nemotron 3.5 Lightning | 6.9 s |
87
+ | `verify` | run the project's checks | Big Pickle | 10.4 s |
88
+ | `vision` | read an image or a screenshot | MiMo-V2.6 Flash | 9.3 s (19 s with an image) |
89
+ | `doc` | read a long document or a spec | GLM 5.3 Flash | 7.4 s |
90
+ | `schema` | produce JSON or a JSON schema | DeepSeek V4.1 Flash | 10.7 s |
91
+ | `sweep` | sweep a lot of code mechanically | Nemotron 3.5 Super VL | 8.4 s |
92
+ | `longform` | read a megabyte, or write alternatives | LongCat 2.5 Preview | 12.0 s |
93
+
94
+ The `measured` column is the median of five one-line requests to that model
95
+ on 2026-10-21 - the same prompt for every model, from the process start, so
96
+ it is the wait a lane would cost.
97
+ [`docs/ROUTING.md`](docs/ROUTING.md) has the method, the install telemetry
98
+ beside it, and the command that produced it.
93
99
 
94
100
  Each lane is an OpenCode agent with its own model, its own context window and
95
101
  its own effort level. You do not switch models: you hand the job to the lane
@@ -103,11 +109,12 @@ agent=build modelID=minimax-m3.1-flash-free
103
109
  ```
104
110
 
105
111
  Why ten lanes and not one model: the free tier is genuinely uneven, and the
106
- difference is measurable. Beam answers in 2.6 seconds with a 1M window;
107
- MiMo reads an image in 19 and MiniMax reads the same image in 8; Nemotron
108
- Super VL is the only model on the tier that publishes a `none` effort level,
109
- which makes "reasoning off" a job of its own. Putting each on the job it is
110
- good at is worth more than any single "best" model.
112
+ difference is measurable. Beam answers in 6.1 seconds with a 1M window, and
113
+ the four fastest models are inside a second of each other; MiMo reads an
114
+ image in 19 and MiniMax reads the same image in 8; Nemotron Super VL is the
115
+ only model on the tier that publishes a `none` effort level, which makes
116
+ "reasoning off" a job of its own. Putting each on the job it is good at is
117
+ worth more than any single "best" model.
111
118
 
112
119
  The full table, with the measurements behind every choice and the three
113
120
  capabilities the tier turns out not to have, is in
@@ -324,15 +331,16 @@ tier cannot do.
324
331
  | Node | **22 or newer** — `engines` in package.json, and the CLI uses `node:` prefixed builtins. Node 20 fails at import time, not with a friendly message. |
325
332
  | npm | 10 or newer, for `npm ci` when installing from a checkout |
326
333
  | OS | macOS, Linux and Windows. The tests run on `ubuntu-latest`. |
327
- | Shell | none needed to *use* the kit — no `bash`, `zsh` or `fish` setup. Two commands shell out to external binaries: `doctor` and `refresh` call `curl` to read models.dev, and `baseline` and the measurement scripts call `sqlite3`. Everything else, including `install`, `use` and `verify`, works offline. |
334
+ | Shell | none needed to *use* the kit — no `bash`, `zsh` or `fish` setup. `doctor` and `refresh` call `curl` to read models.dev. `baseline` and the measurement scripts read the install database with `sqlite3`, and fall back to python3's own `sqlite3` module when that binary is not installed — so python3 is the one thing to have. Everything else, including `install`, `use` and `verify`, works offline. |
328
335
  | Network | only `doctor` and `refresh` need it, and only to read the live model catalogue. `list` is offline: it reads the shipped profiles. Everything else works offline. |
329
336
  | Disk | `use` and `install` write into `~/.config/opencode/` and nothing else. `doctor` writes nothing. `refresh` rewrites a profile in the kit's own
330
337
  directory - `node_modules` for an npm install - and keeps a backup of the
331
338
  one it replaced. |
332
339
 
333
340
  `~/.config/opencode/` is your live agent config. `use` and `install` write there,
334
- `verify` only reads, and `use --dryrun` writes nothing at all. Every write keeps
335
- a `.bak` next to the file, including under `--yes`.
341
+ `verify` only reads, and `use --dryrun` writes nothing at all. Every write
342
+ keeps a dated copy in `~/.config/opencode/backups/`, including under
343
+ `--yes`, and `oc-harness restore <file>` puts one back.
336
344
 
337
345
  ## Commands
338
346
 
@@ -362,6 +370,17 @@ oc-harness baseline measure the current install
362
370
  oc-harness run drive .space/tasks.md to done with the build agent
363
371
  ```
364
372
 
373
+ The measurement scripts are in `eval/`, and each one says what it measures
374
+ at the top of the file:
375
+
376
+ ```
377
+ node eval/run.mjs the pruner and the allowlist
378
+ node eval/measure-prune.mjs the pruner, on real sessions
379
+ node eval/measure-install.mjs before and after the install
380
+ node eval/models.mjs every model the profiles use, one line each
381
+ node eval/ab.mjs the A/B: the same task with and without the kit
382
+ ```
383
+
365
384
  Every flag accepts both spellings: `--profile fleet-safe` and
366
385
  `--profile=fleet-safe` are the same flag.
367
386
 
@@ -434,18 +453,14 @@ Ten lanes is a hypothesis. `oc-harness lanes` counts the requests:
434
453
  ```
435
454
  $ oc-harness lanes
436
455
 
437
- lane reqs avg ms model
438
- ----------------------------------------------------------------
439
- plan 7 2624 beam-free
440
- build 21 10104 minimax-m3.1-flash-free
441
- reviewer 2 4716 nemotron-3-ultra-free
442
- locate 4 3680 nemotron-3.5-lightning-free
443
- verify 2 6546 big-pickle
444
- vision 2 19167 mimo-v2.6-flash-free
445
- doc 2 5421 glm-5.3-flash-free
446
- schema 1 3390 deepseek-v4.1-flash-free
447
- sweep 3 4221 nemotron-3.5-super-vl-free
448
- longform 2 9382 longcat-2.5-preview-free
456
+ lane reqs avg ms avg ctx model
457
+ ----------------------------------------------------------------------
458
+ plan 0 - - deepseek-v4.1-flash-free never used
459
+ build 87 9935 - minimax-m3.1-flash-free, deepseek-v4.1-flash-free
460
+ reviewer 0 - - glm-5.3-flash-free never used
461
+ ...
462
+ 9 lane(s) were never used cut candidates, not defects
463
+ one request is one assistant message; avg ms covers the requests that finished
449
464
  ```
450
465
 
451
466
  A lane that is never used is a description the model reads on every turn and
@@ -519,8 +534,10 @@ the number that is.
519
534
  parts unpruned — the pruner rewrites the outgoing request, not the history —
520
535
  so the stored parts of a real session are exactly what the pruner was given,
521
536
  and replaying the real pruner over them measures exactly what it removed, on
522
- the same messages with and without. On one install: **80.5% of the context
523
- across 60 real sessions**.
537
+ the same messages with and without. On one install, on 2026-10-20:
538
+ **80.5% of the context across 60 real sessions**. The number moves with the
539
+ sessions, because it is measured over the ones this machine happened to
540
+ have - so run the command for the number on yours.
524
541
 
525
542
  ## Memory
526
543
 
@@ -798,7 +815,8 @@ same 120 runs with the fixed grader gives 34/40 against 30/40 on the lookup
798
815
  tasks - the kit ahead, where the old grader had it behind. The full account is in [eval/RESULTS.md](eval/RESULTS.md).
799
816
 
800
817
  What the kit buys is in the two places that suite cannot see: **long
801
- sessions**, where the pruner removes 80.5% of the context, and **process** -
818
+ sessions**, where the pruner removes 65-80% of the context depending on the
819
+ sessions it is measured over, and **process** -
802
820
  a reviewer that reviews and does not edit, a verify that runs the checks and
803
821
  does not fix them, a publish decision that belongs to a human.
804
822
 
@@ -1157,7 +1157,7 @@ async function cmdUse(id) {
1157
1157
  // `--yes` skips the question, it does not skip the safety net. Gating the
1158
1158
  // backup on `--yes` meant the one path that overwrites without asking was
1159
1159
  // also the one path with nothing to fall back to.
1160
- if (!flag("--yes") && interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a .bak backup is kept)`))) {
1160
+ if (!flag("--yes") && interactive() && !(await confirm(`overwrite ${CONFIG_FILE}? (a dated backup is kept in backups/)`))) {
1161
1161
  fail("left the existing config alone")
1162
1162
  }
1163
1163
  const kept = keepBackup(readFileSync(CONFIG_FILE, "utf8"))
@@ -1462,7 +1462,7 @@ function applyLiveMetadata(p, live) {
1462
1462
  /**
1463
1463
  * Put back the config the last `use` replaced.
1464
1464
  *
1465
- * `use` keeps one `.bak` next to the config, and it OVERWRITES it on the
1465
+ * `use` keeps a dated copy of the old config in `backups/`, and it OVERWRITES it on the
1466
1466
  * next run. So there is no history, and the backup does not say which
1467
1467
  * profile it was - so "go back to what I had" means "remember the old
1468
1468
  * profile id and run use again", which is not something a person can do at
@@ -2630,6 +2630,9 @@ try {
2630
2630
  --check-models doctor: one run, non-zero when a shipped model is gone
2631
2631
  --json doctor, show, lanes: print the machine-readable form, and
2632
2632
  nothing else
2633
+ --hook install: also install the pre-commit hook into this
2634
+ project. It is the project's call, so it has to be asked
2635
+ for.
2633
2636
 
2634
2637
  run options
2635
2638
  --role <agent> which agent does the work (default: build)
@@ -1,12 +1,14 @@
1
1
  # Exit codes
2
2
 
3
3
  Every command that can fail says so in its exit code, so a script can tell
4
- "the kit says no" from "the kit broke". A command that succeeded exits 0 and
5
- prints nothing to stderr.
4
+ "the kit says no" from "the kit broke". A command that succeeded exits 0.
5
+ Its errors go to stderr; a notice about what it did - a backup it kept, a
6
+ warning about retention - also goes there, so a script that only reads
7
+ stdout gets the answer and nothing else.
6
8
 
7
9
  ## `oc-harness run`
8
10
 
9
- The unattended loop. It walks `space-tasks.md`, asks the agent for one task
11
+ The unattended loop. It walks `.space/tasks.md`, asks the agent for one task
10
12
  at a time, and re-runs the task's own verify command before believing it.
11
13
 
12
14
  | Code | Meaning |
@@ -15,7 +17,7 @@ at a time, and re-runs the task's own verify command before believing it.
15
17
  | 1 | There is no task file. The loop needs a plan to walk. |
16
18
  | 2 | Stopped early and a person is needed: a task is blocked, a task has no verify command, the round cap was reached, or the time budget ran out. |
17
19
  | 3 | The agent itself failed. The reason is the agent's own last words, and the task is marked blocked with them. |
18
- | 4 | The verify command failed. The task is marked blocked and the real output is in `space-tasks.md` - not in the exit code, which is no place for a stack trace. |
20
+ | 4 | The verify command failed. The task is marked blocked and the real output is in `.space/tasks.md` - not in the exit code, which is no place for a stack trace. |
19
21
 
20
22
  Code 2 is the "asked for something impossible" code: nothing about it is a
21
23
  bug, and the reason is printed. Codes 3 and 4 mean the work did not land.
@@ -24,11 +26,17 @@ bug, and the reason is printed. Codes 3 and 4 mean the work did not land.
24
26
 
25
27
  | Code | Meaning |
26
28
  |---|---|
27
- | 0 | Everything checks out. |
28
- | 1 | Something does not. The problems are listed, one per line. |
29
+ | 0 | The install is consistent, and no profile has drifted. |
30
+ | 1 | The install is broken: no config, no profile recorded in it, a profile that cannot be loaded, or a field that drifted from the profile. The problems are listed, one per line. |
29
31
 
30
- `--check-models` exits 1 as soon as a profile has drifted from the live
31
- catalogue. `--watch` never exits: it polls, and reports only what CHANGED.
32
+ **A profile that has drifted from the live catalogue is not a broken
33
+ install.** A provider changed a context window; the install is fine and the
34
+ metadata is stale. That exits 0, and the drift is reported. `--strict` is
35
+ the flag that makes it fail, for a CI job that wants to know.
36
+
37
+ `--check-models` exits 1 when a shipped model has **left** the catalogue -
38
+ not when it has drifted. `--watch` never exits: it polls, and reports only
39
+ what CHANGED.
32
40
 
33
41
  A failed catalogue fetch is not a finding. It is reported, the models are not
34
42
  announced as withdrawn, and the poll carries on - a catalogue that cannot be
@@ -36,7 +44,8 @@ read says nothing about the models in it.
36
44
 
37
45
  With `--json` the report is the only thing on stdout: a script that pipes it
38
46
  into a parser gets JSON, and a failure is a JSON object with an `error` in
39
- it, not a parse error.
47
+ it, not a parse error. `--json` and `--watch` do not combine: the watch
48
+ prints what changed, in prose, as it happens.
40
49
 
41
50
  ## The measurement gates
42
51
 
@@ -47,6 +56,9 @@ it, not a parse error.
47
56
  | 0 | Every measurement that could be compared is inside its threshold. |
48
57
  | 1 | One is not. The report names the threshold and the number. |
49
58
 
59
+ `node eval/models.mjs` exits 1 when any model could not be measured: a
60
+ table with a hole in it is not a measurement of the tier.
61
+
50
62
  A measurement that could NOT be compared - the two windows do not hold the
51
63
  same work, so the difference is confounded - is reported as a NOTE and NOT
52
64
  gated. The attributable measurement of the same thing, the pruner replayed
@@ -60,8 +72,8 @@ attributable measurement carries the gate.
60
72
 
61
73
  | Code | Meaning |
62
74
  |---|---|
63
- | 0 | The install is in place, or the change was confirmed. |
64
- | 1 | The install was refused, left alone, or a step failed. |
75
+ | 0 | The install is in place, or the change was confirmed. Something may have been left alone - a file that was already there, a prompt that was not answered - and the reason is printed. |
76
+ | 1 | The install was refused, or a step failed. |
65
77
 
66
78
  `doctor` also exits 1 when there is no config, or no profile recorded in
67
79
  it: a kit that is installed but points at nothing is a broken install, and
@@ -288,7 +288,7 @@ From one install's 20-day history, free model slots turned over in days:
288
288
  three days. Zen states free models are available "for a limited time" with no
289
289
  date. Treat profiles as perishable.
290
290
 
291
- The README's [Free models are perishable](../../README.md#free-models-are-perishable-so-check-them)
291
+ The README's [Free models are perishable](../README.md#free-models-are-perishable-so-check-them)
292
292
  section has the full list of withdrawn models, the four commands that check
293
293
  for drift, and what `refresh` will and will not rewrite. The short version:
294
294
  run `oc-harness doctor` before every release and whenever a lane starts
package/docs/RESEARCH.md CHANGED
@@ -193,7 +193,14 @@ out.** Re-measured on 2026-10-20, lane by lane, over live calls: GLM is
193
193
  4.4-5.8 seconds and not 16-27 (the old figure came from a much larger
194
194
  prompt); MiMo reads an image in 19.2 seconds and not 8; and Nemotron Super VL
195
195
  does **not** hang on a tool read, only on a file attached to the prompt, which
196
- means the sweep lane is sound. The measurement also showed the safe fleet
196
+ means the sweep lane is sound.
197
+
198
+ Those were the install's numbers, measured inside a working session. A
199
+ DIRECT measurement on 2026-10-21 - one line, the same prompt for every
200
+ model, from the process start - puts GLM at 7.4 seconds and Beam at 6.1, so
201
+ the two are not comparable and the routing page now says which is which.
202
+ The image figures are still the install's: a one-line prompt is not an
203
+ image. The measurement also showed the safe fleet
197
204
  giving up more than it needed to: two lanes were on a model the ceiling had
198
205
  forced them onto rather than the best one available for that job. The numbers,
199
206
  and what changed because of them, are in [`ROUTING.md`](ROUTING.md).
@@ -288,3 +295,6 @@ and quietly replacing it.
288
295
  session
289
296
  - The live free tier, measured 2026-10-20: one request per lane on both
290
297
  fleets, ten lanes on each, through real delegations
298
+ - The live free tier, measured 2026-10-21, directly: `node eval/models.mjs
299
+ --reps 5 --effort` - the same one-line prompt to every model the profiles
300
+ use, from the process start, with the `low` variant as well
package/docs/ROUTING.md CHANGED
@@ -4,9 +4,26 @@ The fleet is the point of this kit. One session, ten lanes, ten models, and a
4
4
  human who never switches a model by hand.
5
5
 
6
6
  This page records what the lanes are, which model serves each one, and the
7
- measurements behind those choices. Everything in the tables below was measured
8
- against the live tier on **2026-10-20** with OpenCode **1.19.0**, and is
9
- reproducible with `oc-harness doctor` and `oc-harness lanes`.
7
+ measurements behind those choices.
8
+
9
+ There are **two** sets of numbers here, and they measure different things:
10
+
11
+ - The **install tables** are the telemetry of a real install: `oc-harness
12
+ lanes` over the requests a working session actually made, on
13
+ **2026-10-20**. They are the wait a person really had, and the `build` row
14
+ is not comparable with the others, because it counts the turns that did the
15
+ delegating.
16
+ - The **direct table** is `node eval/models.mjs` - one line, one model, five
17
+ requests, whole-process wall time, on **2026-10-21**. It is the same
18
+ prompt for every model, so it compares models with each other, and it
19
+ includes the process start, which the install tables do not.
20
+
21
+ Neither is the answer on its own. The install tables say what happened; the
22
+ direct table says what a lane would wait for if the lane were all there was.
23
+
24
+ Both are reproducible: `oc-harness doctor` for the catalogue, `oc-harness
25
+ lanes` for the install, and `node eval/models.mjs --reps 5 --effort` for the
26
+ direct table.
10
27
 
11
28
  ## The lanes
12
29
 
@@ -88,11 +105,67 @@ that did the delegating, so they count the work of the other nine lanes. A
88
105
  one-line `build` reply on its own measured **3130 ms** and **21137 ms** on a
89
106
  cold start.
90
107
 
108
+ ## Measured directly, one line, one model
109
+
110
+ node eval/models.mjs --reps 5 --effort
111
+
112
+ Every model gets the same prompt - one line, one word back - and the same
113
+ five requests. `wall` is the whole `opencode run`, so it includes the
114
+ process start and the plugin load. `reasoning` is the tokens the model
115
+ spent thinking, which on a prompt this small is close to nothing for most of
116
+ them: the difference between a tier and no tier shows up on real work, not
117
+ on a one-line reply.
118
+
119
+ | model | wall avg | median | max | reasoning | wall @low | reasoning @low |
120
+ |---|---|---|---|---|---|---|
121
+ | `beam-free` | 7,800 | **6,118** | 13,782 | 24 | 6,308 | 28 |
122
+ | `minimax-m3.1-flash-free` | 7,114 | 6,903 | 8,448 | 0 | **6,292** | 0 |
123
+ | `nemotron-3.5-lightning-free` | 8,140 | 6,882 | 13,554 | 86 | — | — |
124
+ | `glm-5.3-flash-free` | 10,604 | 7,408 | 21,002 | 0 | 10,718 | 12 |
125
+ | `nemotron-3-ultra-free` | 9,282 | 7,847 | 15,402 | 36 | — | — |
126
+ | `nemotron-3.5-super-vl-free` | 10,404 | 8,353 | 19,763 | 15 | 10,988 | 17 |
127
+ | `mimo-v2.6-flash-free` | 8,875 | 9,345 | 9,635 | 8 | — | — |
128
+ | `big-pickle` | 9,983 | 10,438 | 11,372 | 8 | — | — |
129
+ | `deepseek-v4.1-flash-free` | 15,840 | 10,659 | 25,805 | 0 | 14,193 | 46 |
130
+ | `longcat-2.5-preview-free` | 13,099 | 11,996 | 16,991 | 16 | — | — |
131
+
132
+ **What this says that the install tables do not.** The margin between the
133
+ fastest model and the fourth is under a second - 6.1 s for Beam against 7.4 s
134
+ for GLM - so a plan that has to be quick does not need the fastest model, it
135
+ needs any of the first four. The spread at the other end is real: LongCat
136
+ costs twice what Beam costs, on the same one-line question.
137
+
138
+ **And the `low` column is not a reason to ask for `low` on a small job.**
139
+ On a prompt this small the difference is inside the noise, and on one of them
140
+ it is the wrong way round. The tier earns its keep on real work, and that
141
+ is the measurement below, on the install's own messages.
142
+
143
+ A dash in the `@low` columns means the model publishes **no `low` level**,
144
+ so there is nothing to ask for: it runs at its natural rate. The script
145
+ reads the catalogue to know which is which, rather than asking every model
146
+ and reporting an answer it did not use.
147
+
148
+ The model ids are shown without their provider; the script prints
149
+ `opencode/beam-free`, and either spelling works with `--model`.
150
+
151
+ **The free tier is noisy, and the table says so.** Five requests is five
152
+ requests: `deepseek` had a 25.8 s outlier in a 10.7 s median, and Beam had a
153
+ 13.8 s one in a 6.1 s median. The median is the number to read, and a
154
+ single request is not a measurement of anything.
155
+
91
156
  **What the safe ceiling costs, measured rather than assumed.** Planning is the
92
- lane that pays: 2624 ms on Beam against 5106 ms on DeepSeek, about twice the
93
- wait. Reviewing barely pays at all: 4716 ms on Nemotron 3 Ultra against 5757
94
- ms on GLM. Locating and verifying get *faster*, because DeepSeek publishes a
95
- `low` effort level and Big Pickle publishes none at all.
157
+ lane that pays: the install measured 2624 ms on Beam against 5106 ms on
158
+ DeepSeek, and the direct measurement says the same thing a different way -
159
+ 6.1 s against 10.7 s, about three quarters more.
160
+
161
+ Reviewing does not pay at all. The install measured 4716 ms on Nemotron 3
162
+ Ultra against 5757 ms on GLM, and the direct measurement has them level, with
163
+ GLM marginally ahead: 7.4 s against 7.8 s. Either way, the model built for
164
+ reviewing is not faster than the one the ceiling puts in its place, and
165
+ neither is slow.
166
+
167
+ Locating and verifying get *faster*, because DeepSeek publishes a `low` effort
168
+ level and Big Pickle publishes none at all.
96
169
 
97
170
  **What a tier is worth, measured on this install rather than assumed.** The
98
171
  effort level is the cheapest lever there is: DeepSeek measures 320 reasoning
@@ -125,16 +198,48 @@ choice anyway.
125
198
 
126
199
  | model | measured | why it owns that lane |
127
200
  |---|---|---|
128
- | minimax-m3.1-flash-free | 3.1 s for a one-line reply, 512k output cap, reads an image in 7.7 s | The widest output cap on the tier and a stable tool call, so the lane that writes code fits, and it is also the fastest measured image reader. |
129
- | beam-free | **2.6 s average over 7 requests, the fastest on the tier**, 1M context, structured output | Planning is a reading-and-reasoning job with a long context and a structured answer. Nothing else on the tier does it faster. |
130
- | nemotron-3-ultra-free | 4.7 s average, but **34 s on a cold first request** | The largest open MoE available, which is what a two-pass review needs. The cold start is worth knowing about before blaming the route. |
131
- | nemotron-3.5-lightning-free | 3.7 s average over 4 requests, 5.5 s at worst, no selectable effort | No effort level, so it spends nothing on reasoning, and it can emit a file larger than 128k tokens. |
132
- | big-pickle | 6.5 s average over 2 requests, structured output, no effort levels, no images | Stable, and it answers in a shape a program can read. |
133
- | mimo-v2.6-flash-free | **19.2 s average with an image**, 23.8 s at worst - the slowest lane on the tier | It reads images correctly, which is the job. It publishes audio input too, and that path is unreachable (see below). |
134
- | glm-5.3-flash-free | 4.4-5.8 s, structured output, 3 effort levels | Careful and quick enough. A previous measurement put it at 16-27 s on a larger prompt; on the work a lane actually does it is five seconds. |
135
- | deepseek-v4.1-flash-free | 3.4-5.2 s, structured output, reasoning + agentic coding | The natural lane for JSON a program has to parse, and the best all-rounder inside a zero-retention ceiling. |
136
- | nemotron-3.5-super-vl-free | 4.2 s average over 3 requests, **the only model publishing a `none` effort level** | A lane that turns reasoning off entirely is a job of its own, and no other model can do it. |
137
- | longcat-2.5-preview-free | 9.4-13.2 s, 1M context, reads images, temperature on | The long-context lane. A preview endpoint: it can be withdrawn without a rename. |
201
+ | minimax-m3.1-flash-free | 6.9 s median for a one-line reply, 512k output cap - the widest on the tier, reads an image in 7.7 s | The widest output cap on the tier and a stable tool call, so the lane that writes code fits, and it is also the fastest measured image reader. |
202
+ | beam-free | **6.1 s median, the fastest of the ten**, 1M context, structured output | Planning is a reading-and-reasoning job with a long context and a structured answer. It is the fastest measured, but only by under a second over the next three - so the reason to pick it is the 1M context and the structured output as much as the speed. |
203
+ | nemotron-3-ultra-free | 7.8 s median, but **34 s on a cold first request** | The largest open MoE available, which is what a two-pass review needs. The cold start is worth knowing about before blaming the route. |
204
+ | nemotron-3.5-lightning-free | 6.9 s median, 13.6 s at worst, no selectable effort | No effort level, so it spends nothing on reasoning, and it can emit a file larger than 128k tokens. |
205
+ | big-pickle | 10.4 s median, structured output, no effort levels, no images | Stable, and it answers in a shape a program can read. |
206
+ | mimo-v2.6-flash-free | 9.3 s median for a line of text, **19.2 s average with an image** | It reads images correctly, which is the job. It publishes audio input too, and that path is unreachable (see below). |
207
+ | glm-5.3-flash-free | 7.4 s median, structured output, 3 effort levels | Careful and quick enough. A previous measurement put it at 16-27 s on a larger prompt; on the work a lane actually does it is about seven seconds. |
208
+ | deepseek-v4.1-flash-free | 10.7 s median, structured output, reasoning + agentic coding | The natural lane for JSON a program has to parse, and the best all-rounder inside a zero-retention ceiling. It was the slowest of the ten on the one-line measurement - the tier is free, and free is not fast. |
209
+ | nemotron-3.5-super-vl-free | 8.4 s median, **the only model publishing a `none` effort level** | A lane that turns reasoning off entirely is a job of its own, and no other model can do it. |
210
+ | longcat-2.5-preview-free | 12.0 s median - the slowest of the ten, 1M context, reads images, temperature on | The long-context lane. A preview endpoint: it can be withdrawn without a rename. |
211
+
212
+ ## What each model publishes
213
+
214
+ From the live catalogue, 2026-10-21. `oc-harness doctor` re-checks all of
215
+ it, and a model that is withdrawn says so rather than being measured.
216
+
217
+ | model | effort levels | context | output cap | reads |
218
+ |---|---|---|---|---|
219
+ | `beam-free` | low, medium, high, xhigh, max | 1M | 131k | text |
220
+ | `big-pickle` | **none** | 200k | 32k | text |
221
+ | `deepseek-v4.1-flash-free` | low, high, max | 200k | 32k | text, image |
222
+ | `minimax-m3.1-flash-free` | low, medium, high, xhigh, max | 1M | **512k** | text, image, video |
223
+ | `nemotron-3-ultra-free` | **none** | 1M | 128k | text |
224
+ | `nemotron-3.5-lightning-free` | **none** | 262k | **262k** | text |
225
+ | `mimo-v2.6-flash-free` | **none** | 200k | 32k | text, image, audio, video |
226
+ | `glm-5.3-flash-free` | low, high, max | 200k | 32k | text, image, video, pdf |
227
+ | `nemotron-3.5-super-vl-free` | **none**, minimal, low, medium, high, xhigh | 1M | 131k | text, image |
228
+ | `longcat-2.5-preview-free` | **none** | 1M | 131k | text, image |
229
+
230
+ Two things fall out of this table that the prose above rests on.
231
+
232
+ **Only one model can turn reasoning off entirely**, and it is
233
+ `nemotron-3.5-super-vl-free`. The `sweep` lane is a bulk mechanical search,
234
+ so reasoning on it is pure waste - and the model that could switch it off
235
+ publishes `none` and the four zero-retention models do not, so under the
236
+ ceiling that lane cannot ask for it. It runs on DeepSeek, which publishes
237
+ `low`, and that is the closest thing available.
238
+
239
+ **A model that publishes no effort level still spends what it spends.** It
240
+ runs at its natural rate. That is not a defect: it is the reason Big Pickle
241
+ is cheap to use, and the reason the kit asks for a tier only where the tier
242
+ is worth having.
138
243
 
139
244
  ## What the tier cannot do
140
245
 
@@ -232,19 +337,27 @@ is to count the requests:
232
337
  ```
233
338
  $ oc-harness lanes
234
339
 
235
- lane reqs avg ms model
236
- ----------------------------------------------------------------
237
- plan 7 2624 beam-free
238
- build 21 10104 minimax-m3.1-flash-free
239
- reviewer 2 4716 nemotron-3-ultra-free
340
+ lane reqs avg ms avg ctx model
341
+ ----------------------------------------------------------------------
342
+ plan 0 - - deepseek-v4.1-flash-free never used
343
+ build 87 9935 - minimax-m3.1-flash-free, deepseek-v4.1-flash-free
344
+ reviewer 0 - - glm-5.3-flash-free never used
240
345
  ...
241
- 8 lane(s) were never used cut candidates, not defects
346
+ 9 lane(s) were never used cut candidates, not defects
347
+ one request is one assistant message; avg ms covers the requests that finished
242
348
  ```
243
349
 
244
350
  A lane that is never used is a description the model reads on every turn and a
245
351
  free model doing nothing. Cut it from the profile's `roles`, and the lane
246
352
  leaves the config with it.
247
353
 
354
+ That is also the counter that says a lane is NOT earning its place, and it
355
+ is the honest one to act on: it counts the requests a working session
356
+ actually made. `node eval/models.mjs` answers the other question - how long
357
+ a lane would wait - and the two together are what a routing decision needs.
358
+ The counter alone would cut a lane that is slow but used, and the
359
+ measurement alone would keep a lane nobody calls.
360
+
248
361
  The counter reads `.space/telemetry.jsonl` in the project, written by the
249
362
  plugin: one record per assistant message, with the lane, the model and the
250
363
  duration. Two things about it are worth knowing, because both were wrong at
@@ -295,5 +408,7 @@ directory is exactly as dangerous as one in the plural one.
295
408
  config, so it is sized by the **narrowest** lane: 24,000 tokens for
296
409
  `fleet-safe`, whose narrowest lane has a 200k window, and 19,200 for
297
410
  `fleet-all`, whose narrowest lane is Big Pickle at a 160k input cap.
411
+ (Big Pickle's CATALOGUE context is 200k; the profile pins its input cap to
412
+ 160k, and the cap is the number that governs.)
298
413
  Sizing it by the widest model instead would reserve 125,829 tokens and
299
414
  leave every 200k lane with 74k usable.
package/eval/RESULTS.md CHANGED
@@ -306,6 +306,12 @@ not a model that answered - four kit-off runs hit the 600-second ceiling
306
306
  with no output at all, and a timed-out run has zero tools and zero context,
307
307
  so it entered the medians as a fast, empty, wrong answer.
308
308
 
309
+ That is why the older result below is quoted as "52/60 against **59/59**":
310
+ one of the sixty kit-off runs produced no answer at all and is excluded
311
+ from every number. Four of the kit-off runs timed out in the later run
312
+ described below, which is why the same suite cannot be compared run to
313
+ run.
314
+
309
315
  | metric (median) | kit on | kit off |
310
316
  | --- | --- | --- |
311
317
  | **correct** | **54/60** | **55/60** |
@@ -361,10 +367,17 @@ some of the answers took the two-field markdown shape - the shape the
361
367
  grader could not read. The kit-off arm had no such instructions, mostly
362
368
  answered in prose, and was graded by a pattern that handled prose.
363
369
 
364
- Re-scoring the SAME run with the fixed grader - the same 120 agent runs,
370
+ Re-scoring the EARLIER run with the fixed grader - the same 120 agent runs,
365
371
  the same answers, only the grader changed - gives **34/40 against 30/40** on
366
- the four lookup tasks, the kit ahead. A harness that measures the harness
367
- is worse than one that measures nothing.
372
+ the four lookup tasks, the kit ahead. The run in the table above, with the
373
+ same grader, scores 34/40 against 35/40 on the same four tasks.
374
+
375
+ The re-score is reproducible: the raw streams of a run are kept, and
376
+
377
+ node eval/ab.mjs --rescore <the stream dir> --target <subject> --suite eval/tasks-self.json
378
+
379
+ grades them again without spending a token. A harness that measures the
380
+ harness is worse than one that measures nothing.
368
381
 
369
382
  ### What this does not say
370
383
 
@@ -404,7 +417,8 @@ small - only the floor is enforced.
404
417
 
405
418
  ## Not measured here
406
419
 
407
- - Whether the agent's *answers* got better in general. One task, six runs.
420
+ - Whether the agent's *answers* got better in general. Six tasks, 120 runs,
421
+ and a tie on correctness - which is not the same as a measurement of it.
408
422
  - Whether the numbers in Results 1 and 2 were caused by the kit. Result 3 shows
409
423
  a mechanism that would produce them; the A/B points the same way on a
410
424
  different task. Neither window separates the kit from the work.
@@ -318,6 +318,14 @@ if (failures.length) {
318
318
  console.log(` FAIL ${failures.join("; ")}`)
319
319
  } else if (minNarrow !== null || minCtxDrop !== null) {
320
320
  console.log("")
321
- console.log(` PASS narrow reads and context per request both held.`)
321
+ // The PASS line has to say WHICH gates held. "Both held" is a lie when
322
+ // one of them was reported and not gated, and the NOTE above it says so.
323
+ const held = []
324
+ if (minNarrow !== null && !skipped.includes("narrow reads")) held.push("narrow reads")
325
+ if (minCtxDrop !== null && !skipped.includes("context per request")) held.push("context per request")
326
+ console.log(
327
+ ` PASS ${held.length ? held.join(" and ") : "nothing"} held.` +
328
+ (skipped.length ? ` ${skipped.join(" and ")} was reported but not gated.` : ""),
329
+ )
322
330
  }
323
331
  process.exit(failures.length ? 1 : 0)
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ // models.mjs - measure each model on the live tier.
3
+ //
4
+ // The routing table in docs/ROUTING.md says how fast each lane's model is,
5
+ // and a number in a table that nobody can reproduce is a number nobody can
6
+ // check. This is the script that produced the current one.
7
+ //
8
+ // node eval/models.mjs the table, for the models the profiles use
9
+ // node eval/models.mjs --reps 3 more samples per model
10
+ // node eval/models.mjs --effort also measure the `low` effort variant
11
+ // node eval/models.mjs --json machine-readable
12
+ // node eval/models.mjs --model <id> one model
13
+ //
14
+ // What it measures:
15
+ //
16
+ // wall ms the whole `opencode run`, so it includes the process start
17
+ // and the round trip. A lane is a round trip, so this is the
18
+ // number a person waits for.
19
+ // reasoning the tokens the model spent thinking. A model that reasons
20
+ // for 400 tokens on a question that needs none is a model
21
+ // being asked the wrong way.
22
+ //
23
+ // The prompt is one line and the same for every model, so the comparison is
24
+ // between models and not between prompts. It is not a benchmark of the
25
+ // models: it is a measurement of what a lane would wait for.
26
+
27
+ import { execFileSync, spawnSync } from "node:child_process"
28
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
29
+ import { tmpdir } from "node:os"
30
+ import { join } from "node:path"
31
+
32
+ import { median } from "./metrics.js"
33
+ import { countFlag, stringFlag } from "./flags.mjs"
34
+
35
+ const args = process.argv.slice(2)
36
+ const wantJson = args.includes("--json")
37
+ // The shared flag parser, so `--reps=5` means what `--reps 5` means and a
38
+ // mistyped value is an error rather than a measurement of nothing.
39
+ let reps
40
+ let only
41
+ try {
42
+ reps = countFlag("--reps", args) ?? 3
43
+ only = stringFlag("--model", args)
44
+ } catch (err) {
45
+ console.error(err instanceof Error ? err.message : String(err))
46
+ process.exit(1)
47
+ }
48
+ const withEffort = args.includes("--effort")
49
+
50
+ const PROMPT = "Reply with exactly the word OK and nothing else."
51
+
52
+ // The models the shipped profiles use. The catalogue turns over in days, so
53
+ // this is read from the profiles rather than written down here: a model that
54
+ // has been withdrawn is reported as gone instead of measured.
55
+ async function modelsInUse() {
56
+ const { listProfileIds, loadProfile, modelsInPlay } = await import("../src/profile.js")
57
+ const seen = new Map()
58
+ for (const id of listProfileIds()) {
59
+ let p
60
+ try {
61
+ p = loadProfile(id)
62
+ } catch {
63
+ continue
64
+ }
65
+ for (const m of modelsInPlay(p) ?? []) {
66
+ // The provider is part of the model id as opencode knows it.
67
+ const full = m.includes("/") ? m : `${p.provider}/${m}`
68
+ if (!seen.has(full)) seen.set(full, [])
69
+ seen.get(full).push(id)
70
+ }
71
+ }
72
+ return [...seen.entries()].map(([model, profiles]) => ({ model, profiles }))
73
+ }
74
+
75
+ /**
76
+ * One request to one model.
77
+ *
78
+ * @param {string} model
79
+ * @param {string | null} variant
80
+ * @returns {{ ok: boolean, ms: number, reasoning: number, output: number, input: number, error: string | null }}
81
+ */
82
+ function measure(model, variant) {
83
+ const dir = mkdtempSync(join(tmpdir(), "ock-model-"))
84
+ const argv = ["run", "--format", "json", "--model", model, "--dir", dir]
85
+ if (variant) argv.push("--variant", variant)
86
+ argv.push(PROMPT)
87
+ const t0 = Date.now()
88
+ let res
89
+ try {
90
+ res = spawnSync("opencode", argv, {
91
+ cwd: dir,
92
+ encoding: "utf8",
93
+ maxBuffer: 64 * 1024 * 1024,
94
+ timeout: 180_000,
95
+ killSignal: "SIGKILL",
96
+ })
97
+ } finally {
98
+ // The directory holds a whole opencode project. A run that is killed
99
+ // leaves it behind, and a scratch directory that is never cleaned up is
100
+ // a scratch directory nobody can find again.
101
+ try {
102
+ rmSync(dir, { recursive: true, force: true })
103
+ } catch {}
104
+ }
105
+ const ms = Date.now() - t0
106
+ const out = res.stdout ?? ""
107
+ let reasoning = 0
108
+ let output = 0
109
+ let input = 0
110
+ let finished = false
111
+ for (const line of out.split("\n")) {
112
+ if (!line.trim().startsWith("{")) continue
113
+ let ev
114
+ try {
115
+ ev = JSON.parse(line)
116
+ } catch {
117
+ continue
118
+ }
119
+ if (ev.type !== "step_finish") continue
120
+ finished = true
121
+ const t = ev.part?.tokens ?? {}
122
+ reasoning += Number(t.reasoning ?? 0)
123
+ output += Number(t.output ?? 0)
124
+ input += Number(t.input ?? 0) + Number(t.cache?.read ?? 0)
125
+ }
126
+ if (!finished) {
127
+ // The reason, in the order it is useful: the spawn failed (opencode is
128
+ // not installed), the process was killed (the ceiling), and only then
129
+ // whatever the provider said. "no reply" on its own says nothing about
130
+ // which of the three it was.
131
+ const reason = res.error
132
+ ? `could not run opencode: ${res.error.message}`
133
+ : res.signal
134
+ ? `killed after the ${Math.round(ms / 1000)}s ceiling (${res.signal})`
135
+ : ((res.stderr ?? "").split("\n").find((l) => l.trim()) ?? "no reply")
136
+ return { ok: false, ms, reasoning, output, input, error: reason }
137
+ }
138
+ return { ok: true, ms, reasoning, output, input, error: null }
139
+ }
140
+
141
+ /**
142
+ * The effort levels each model publishes, from the live catalogue.
143
+ *
144
+ * A model that publishes no `low` level cannot be asked for one: the
145
+ * request is ignored and the model runs at its natural rate. Measuring that
146
+ * and putting it in a column called "wall @low" would say a model is
147
+ * faster at a tier it does not have.
148
+ *
149
+ * @returns {Promise<Map<string, string[]>>}
150
+ */
151
+ async function publishedEffort() {
152
+ const out = new Map()
153
+ const url = process.env.OPENCODE_HARNESS_CATALOG_URL ?? "https://models.dev/api.json"
154
+ try {
155
+ const body = url.startsWith("file://")
156
+ ? readFileSync(url.slice("file://".length), "utf8")
157
+ : execFileSync("curl", ["-sL", "--fail", "--max-time", "40", url], {
158
+ encoding: "utf8",
159
+ // The catalogue is over a megabyte, and the default buffer is one
160
+ // megabyte: without this the fetch fails with ENOBUFS, the catch
161
+ // swallows it, and every model looks like it publishes no effort
162
+ // level at all.
163
+ maxBuffer: 64 * 1024 * 1024,
164
+ })
165
+ const catalog = JSON.parse(body)
166
+ for (const [provider, p] of Object.entries(catalog ?? {})) {
167
+ for (const [id, m] of Object.entries(p?.models ?? {})) {
168
+ const levels = []
169
+ for (const ro of m?.reasoning_options ?? []) {
170
+ if (ro?.type === "effort") levels.push(...(ro?.values ?? []))
171
+ }
172
+ out.set(`${provider}/${id}`, levels)
173
+ }
174
+ }
175
+ } catch (err) {
176
+ // A catalogue that cannot be read is not a reason to refuse to measure.
177
+ // The levels are used to mark a column, not to decide anything - but the
178
+ // reason is printed, because a silent failure here marks every model as
179
+ // publishing no effort level, which is a false statement about them.
180
+ console.error(
181
+ ` could not read the catalogue: ${err instanceof Error ? err.message : String(err)}`,
182
+ )
183
+ console.error(" the effort levels are unknown, so the @low column will read n/a.")
184
+ }
185
+ return out
186
+ }
187
+
188
+ const effort = await publishedEffort()
189
+
190
+ const rows = []
191
+ for (const { model, profiles } of await modelsInUse()) {
192
+ // The docs name a model by its bare id, so both spellings work.
193
+ if (only && model !== only && model !== `opencode/${only}` && only !== model.split("/").pop()) continue
194
+ const samples = []
195
+ for (let i = 0; i < reps; i++) {
196
+ let r = measure(model, null)
197
+ // One retry. The free tier drops requests, and a model that failed once
198
+ // is not a model that is gone - reporting it as one would hide the
199
+ // difference between "withdrawn" and "the provider was busy".
200
+ if (!r.ok) r = measure(model, null)
201
+ if (!r.ok) {
202
+ samples.length = 0
203
+ rows.push({ model, profiles, ok: false, error: r.error })
204
+ break
205
+ }
206
+ samples.push(r)
207
+ }
208
+ if (!samples.length) continue
209
+ const ms = samples.map((s) => s.ms)
210
+ // The `low` variant is only measured for a model that PUBLISHES `low`.
211
+ // A model with no effort levels runs at its natural rate whatever is
212
+ // asked for, and a column that calls that "@low" is a lie.
213
+ const levels = effort.get(model) ?? []
214
+ const publishesLow = levels.includes("low")
215
+ const low = []
216
+ if (withEffort && publishesLow) {
217
+ for (let i = 0; i < reps; i++) {
218
+ const r = measure(model, "low")
219
+ if (r.ok) low.push(r)
220
+ }
221
+ }
222
+ rows.push({
223
+ model,
224
+ profiles,
225
+ ok: true,
226
+ requests: samples.length,
227
+ avg_ms: Math.round(ms.reduce((a, b) => a + b, 0) / ms.length),
228
+ median_ms: Math.round(median(ms)),
229
+ max_ms: Math.max(...ms),
230
+ reasoning: Math.round(median(samples.map((s) => s.reasoning))),
231
+ output: Math.round(median(samples.map((s) => s.output))),
232
+ input: Math.round(median(samples.map((s) => s.input))),
233
+ effort_levels: levels,
234
+ publishes_low: publishesLow,
235
+ low_requests: low.length,
236
+ low_reasoning: low.length ? Math.round(median(low.map((r) => r.reasoning))) : null,
237
+ low_avg_ms: low.length ? Math.round(low.reduce((a, r) => a + r.ms, 0) / low.length) : null,
238
+ })
239
+ process.stderr.write(
240
+ ` measured ${model} (${samples.length} request(s), median ${rows.at(-1).median_ms} ms)\n`,
241
+ )
242
+ }
243
+
244
+ const failed = rows.filter((r) => !r.ok).length
245
+ if (wantJson) {
246
+ console.log(
247
+ JSON.stringify(
248
+ { when: new Date().toISOString(), reps, prompt: PROMPT, ok: failed === 0, failed, rows },
249
+ null,
250
+ 2,
251
+ ),
252
+ )
253
+ process.exit(failed === 0 ? 0 : 1)
254
+ }
255
+
256
+ console.log("")
257
+ console.log(`MEASURED ON THE LIVE TIER ${new Date().toISOString().slice(0, 10)}`)
258
+ console.log(` ${reps} request(s) per model, one-line prompt, whole-process wall time`)
259
+ console.log("")
260
+ const head = ["model", "reqs", "avg ms", "median", "max", "reasoning", "output"]
261
+ console.log(" " + head.map((h, i) => (i === 0 ? h.padEnd(30) : h.padStart(10))).join(""))
262
+ console.log(" " + "-".repeat(30 + 10 * (head.length - 1)))
263
+ for (const r of rows) {
264
+ if (!r.ok) {
265
+ console.log(" " + r.model.padEnd(30) + ` FAILED: ${String(r.error).slice(0, 70)}`)
266
+ continue
267
+ }
268
+ console.log(
269
+ " " +
270
+ r.model.padEnd(30) +
271
+ String(r.requests).padStart(10) +
272
+ String(r.avg_ms).padStart(10) +
273
+ String(r.median_ms).padStart(10) +
274
+ String(r.max_ms).padStart(10) +
275
+ String(r.reasoning).padStart(10) +
276
+ String(r.output).padStart(10),
277
+ )
278
+ if (r.publishes_low) {
279
+ if (!r.low_requests) {
280
+ // The model publishes `low` and every request for it failed. Saying
281
+ // "0 null" reads like a measurement of nothing; saying it failed says
282
+ // what happened.
283
+ console.log(
284
+ " " +
285
+ `${r.model} @low`.padEnd(30) +
286
+ "no request for the low variant succeeded".padStart(10),
287
+ )
288
+ } else {
289
+ console.log(
290
+ " " +
291
+ `${r.model} @low`.padEnd(30) +
292
+ String(r.low_requests).padStart(10) +
293
+ String(r.low_avg_ms).padStart(10) +
294
+ "".padStart(10) +
295
+ "".padStart(10) +
296
+ String(r.low_reasoning).padStart(10),
297
+ )
298
+ }
299
+ } else if (withEffort) {
300
+ console.log(
301
+ " " +
302
+ `${r.model} @low`.padEnd(30) +
303
+ "n/a".padStart(10) +
304
+ " (publishes no low level - it runs at its natural rate)",
305
+ )
306
+ }
307
+ }
308
+ console.log("")
309
+
310
+ if (failed) {
311
+ console.error(` ${failed} model(s) could not be measured - the numbers above are not the whole tier.`)
312
+ process.exit(1)
313
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-harness-kit",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Harness kit for OpenCode: capability profiles, a fleet of job lanes with one free model each, role-based reasoning effort, context pruning, and measured baselines.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/pham-canh/space-harness#readme",