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 +77 -23
- package/CONTRIBUTING.md +7 -0
- package/README.md +51 -33
- package/bin/oc-harness.mjs +5 -2
- package/docs/EXIT-CODES.md +23 -11
- package/docs/KNOWN-LIMITS.md +1 -1
- package/docs/RESEARCH.md +11 -1
- package/docs/ROUTING.md +138 -23
- package/eval/RESULTS.md +18 -4
- package/eval/measure-install.mjs +9 -1
- package/eval/models.mjs +313 -0
- package/package.json +1 -1
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
|
|
305
|
-
the context across 60 real sessions**, 6,608 parts
|
|
306
|
-
|
|
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,
|
|
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** | **
|
|
477
|
-
| tool calls |
|
|
478
|
-
| input tokens |
|
|
479
|
-
| final context | 39,
|
|
480
|
-
| wall seconds | 43
|
|
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
|
|
485
|
-
takes
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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 |
|
|
84
|
-
| `plan` | turn a request into a plan | Beam | **
|
|
85
|
-
| `reviewer` | read a change in two passes | Nemotron 3 Ultra |
|
|
86
|
-
| `locate` | find where something lives | Nemotron 3.5 Lightning |
|
|
87
|
-
| `verify` | run the project's checks | Big Pickle |
|
|
88
|
-
| `vision` | read an image or a screenshot | MiMo-V2.6 Flash |
|
|
89
|
-
| `doc` | read a long document or a spec | GLM 5.3 Flash |
|
|
90
|
-
| `schema` | produce JSON or a JSON schema | DeepSeek V4.1 Flash |
|
|
91
|
-
| `sweep` | sweep a lot of code mechanically | Nemotron 3.5 Super VL | 4
|
|
92
|
-
| `longform` | read a megabyte, or write alternatives | LongCat 2.5 Preview |
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
|
335
|
-
a
|
|
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
|
|
438
|
-
|
|
439
|
-
plan
|
|
440
|
-
build
|
|
441
|
-
reviewer
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
|
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
|
|
package/bin/oc-harness.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
package/docs/EXIT-CODES.md
CHANGED
|
@@ -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
|
|
5
|
-
|
|
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
|
|
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
|
|
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 |
|
|
28
|
-
| 1 |
|
|
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
|
-
|
|
31
|
-
|
|
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,
|
|
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
|
package/docs/KNOWN-LIMITS.md
CHANGED
|
@@ -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](
|
|
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.
|
|
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.
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 |
|
|
129
|
-
| beam-free | **
|
|
130
|
-
| nemotron-3-ultra-free |
|
|
131
|
-
| nemotron-3.5-lightning-free |
|
|
132
|
-
| big-pickle |
|
|
133
|
-
| mimo-v2.6-flash-free |
|
|
134
|
-
| glm-5.3-flash-free |
|
|
135
|
-
| deepseek-v4.1-flash-free |
|
|
136
|
-
| nemotron-3.5-super-vl-free | 4
|
|
137
|
-
| longcat-2.5-preview-free |
|
|
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
|
|
236
|
-
|
|
237
|
-
plan
|
|
238
|
-
build
|
|
239
|
-
reviewer
|
|
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
|
-
|
|
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
|
|
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.
|
|
367
|
-
|
|
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.
|
|
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.
|
package/eval/measure-install.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/eval/models.mjs
ADDED
|
@@ -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.
|
|
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",
|