opencode-harness-kit 0.5.8 → 0.6.0

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.
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Research the live tier: what could the kit use, and what has it lost.
3
+ *
4
+ * `doctor` answers one half of the question. It compares the profiles this
5
+ * kit ships against models.dev and reports the ones that have drifted. It says
6
+ * nothing about what has come to replace them, so a model that is withdrawn
7
+ * leaves a hole that only a person can fill, by hand, out of a catalogue
8
+ * dump.
9
+ *
10
+ * This module is the other half. It reads the live catalogue and reports:
11
+ *
12
+ * - the free models on the tier that are still ALIVE
13
+ * - the ones the kit does not use yet, and which lane each could serve
14
+ * - the ones the kit DOES use that have been withdrawn
15
+ *
16
+ * Two facts about the catalogue drive the whole design.
17
+ *
18
+ * **Withdrawn models stay in the catalogue.** models.dev does not delete a
19
+ * model the provider pulls: it keeps the record and marks it
20
+ * `status: "deprecated"`. So "is it still in the catalogue" and "is it still
21
+ * alive" are two different questions, and only the second one is the one a
22
+ * lane has to care about. A chain check that asks the first question reports
23
+ * a withdrawn model as a healthy fallback.
24
+ *
25
+ * **Free is a price, not a name.** The tier is the set of models the provider
26
+ * charges nothing for. `big-pickle` is on it and does not end in `-free`, so
27
+ * the suffix is a naming convention and not the test.
28
+ *
29
+ * This module writes nothing, on purpose. `retention.yml` is a human
30
+ * judgement about a provider's terms, and `refresh` already draws the line: a
31
+ * script rewrites what a machine can derive and never touches what a person
32
+ * decided. What comes out of here is a shortlist, not a decision.
33
+ *
34
+ * The one exception is `pruneWithdrawnChains`, which DOES rewrite a profile.
35
+ * It is the narrow exception the line allows: removing a model the provider
36
+ * has withdrawn is a derivation from live metadata, and it only ever REMOVES.
37
+ * A script never puts a model into a lane - that changes what the lane does,
38
+ * and that is a person's call.
39
+ */
40
+
41
+ /** The provider the kit's free tier lives on. */
42
+ export const TIER_PROVIDER = "opencode"
43
+
44
+ /**
45
+ * @typedef {object} ResearchReport
46
+ * @property {{ provider: string, total: number, live: number, withdrawn: number }} tier
47
+ * @property {{ count: number, gone: { id: string, provider: string, asBase: string[], inChains: Record<string, string[]>, present: boolean, reason: string }[] }} shipped
48
+ * @property {{ lane: string, dead: string[], live: string[] }[]} lanes
49
+ * @property {{ id: string, model: string, provider: string, context: number | null, output: number | null, effort: string[], capabilities: { toolCall: boolean, attachment: boolean, temperature: boolean }, retention: string, fits: string[], blockers: string[] }[]} candidates
50
+ */
51
+
52
+ /**
53
+ * A model the provider has pulled.
54
+ *
55
+ * The catalogue keeps the record, so this is the only way to tell a model
56
+ * that is gone from a model that was never there.
57
+ *
58
+ * @param {any} model a catalogue model record
59
+ * @returns {boolean}
60
+ */
61
+ export function isWithdrawn(model) {
62
+ return model?.status === "deprecated"
63
+ }
64
+
65
+ /**
66
+ * A model the tier charges nothing for.
67
+ *
68
+ * @param {any} model a catalogue model record
69
+ * @returns {boolean}
70
+ */
71
+ export function isFree(model) {
72
+ const cost = model?.cost
73
+ if (!cost) return false
74
+ return (
75
+ (cost.input ?? 0) === 0 &&
76
+ (cost.output ?? 0) === 0 &&
77
+ (cost.cache_read ?? 0) === 0
78
+ )
79
+ }
80
+
81
+ /**
82
+ * The effort levels a model publishes.
83
+ *
84
+ * A model with no effort levels is not missing a capability the lane needs -
85
+ * it simply does not do effort control, and the kit runs it at its default.
86
+ * That is why `fitsLane` treats "publishes no levels" as a match rather than
87
+ * as a failure.
88
+ *
89
+ * @param {any} model a catalogue model record
90
+ * @returns {string[]}
91
+ */
92
+ export function effortLevels(model) {
93
+ const options = model?.reasoning_options
94
+ if (!Array.isArray(options)) return []
95
+ const effort = options.find((o) => o?.type === "effort")
96
+ return Array.isArray(effort?.values) ? effort.values : []
97
+ }
98
+
99
+ /**
100
+ * Every model on the free tier, live or withdrawn.
101
+ *
102
+ * @param {any} catalog the models.dev catalogue
103
+ * @param {string} [provider]
104
+ * @returns {{ id: string, model: any, withdrawn: boolean }[]}
105
+ */
106
+ export function tierModels(catalog, provider = TIER_PROVIDER) {
107
+ const models = catalog?.[provider]?.models ?? {}
108
+ return Object.entries(models)
109
+ .filter(([, m]) => isFree(m))
110
+ .map(([id, m]) => ({ id, model: m, withdrawn: isWithdrawn(m) }))
111
+ }
112
+
113
+ /**
114
+ * What a lane needs from a model.
115
+ *
116
+ * Three rules, and each one is a fact about the lane rather than a tunable:
117
+ *
118
+ * - every lane calls tools, so every lane needs `tool_call`
119
+ * - `vision` reads an image, so it needs `attachment`
120
+ * - a lane whose role asks for an effort level needs a model that publishes
121
+ * that level
122
+ *
123
+ * @param {string} lane
124
+ * @param {string | null} role the effort level the lane asks for
125
+ * @returns {{ tools: boolean, attachment: boolean, effort: string | null }}
126
+ */
127
+ export function laneNeeds(lane, role) {
128
+ return { tools: true, attachment: lane === "vision", effort: role ?? null }
129
+ }
130
+
131
+ /**
132
+ * Could this model serve this lane?
133
+ *
134
+ * @param {any} model a catalogue model record
135
+ * @param {string} lane
136
+ * @param {string | null} role the effort level the lane asks for
137
+ * @returns {boolean}
138
+ */
139
+ export function fitsLane(model, lane, role) {
140
+ const needs = laneNeeds(lane, role)
141
+ if (needs.tools && !model?.tool_call) return false
142
+ if (needs.attachment && !model?.attachment) return false
143
+ if (needs.effort) {
144
+ const levels = effortLevels(model)
145
+ // A model that publishes no effort levels has nothing to mismatch, and
146
+ // the kit already puts such models in the chains of lanes that ask for
147
+ // one. A model that DOES publish levels has to publish this one.
148
+ if (levels.length && !levels.includes(needs.effort)) return false
149
+ }
150
+ return true
151
+ }
152
+
153
+ /**
154
+ * Every model a profile puts to work: its own, every rung of every lane
155
+ * chain, the utility route and the fallback queue.
156
+ *
157
+ * A model that appears in only one chain is still in use - it is a fallback,
158
+ * and a fallback nobody checks is a fallback that fails on the day it is
159
+ * needed.
160
+ *
161
+ * @param {any} p a normalised profile
162
+ * @returns {Set<string>}
163
+ */
164
+ export function shippedModels(p) {
165
+ const out = new Set()
166
+ if (p?.model) out.add(p.model)
167
+ if (p?.utility?.model) out.add(p.utility.model)
168
+ for (const model of p?.fallback ?? []) out.add(model)
169
+ for (const rm of Object.values(p?.roleModels ?? {})) {
170
+ if (rm?.model) out.add(rm.model)
171
+ for (const rung of rm?.chain ?? []) out.add(rung)
172
+ }
173
+ return out
174
+ }
175
+
176
+ /**
177
+ * The models the kit uses, across every profile.
178
+ *
179
+ * @param {any[]} profiles normalised profiles
180
+ * @returns {Map<string, { asBase: string[], inChains: Record<string, string[]> }>}
181
+ */
182
+ export function shippedAcross(profiles) {
183
+ /** @type {Map<string, { asBase: string[], inChains: Record<string, string[]> }>} */
184
+ const out = new Map()
185
+ /**
186
+ * @param {string} model
187
+ */
188
+ const entry = (model) => {
189
+ let e = out.get(model)
190
+ if (!e) {
191
+ e = { asBase: [], inChains: {} }
192
+ out.set(model, e)
193
+ }
194
+ return e
195
+ }
196
+ for (const p of profiles ?? []) {
197
+ if (p?.model) entry(p.model).asBase.push(p.id)
198
+ for (const [lane, rm] of Object.entries(p?.roleModels ?? {})) {
199
+ const chain = rm?.chain?.length ? rm.chain : rm?.model ? [rm.model] : []
200
+ for (const rung of chain) {
201
+ const e = entry(rung)
202
+ e.inChains[p.id] = e.inChains[p.id] ?? []
203
+ if (!e.inChains[p.id].includes(lane)) e.inChains[p.id].push(lane)
204
+ }
205
+ }
206
+ }
207
+ return out
208
+ }
209
+
210
+ /**
211
+ * Research the tier against what the kit ships.
212
+ *
213
+ * @param {object} input
214
+ * @param {any} input.catalog the models.dev catalogue
215
+ * @param {any[]} input.profiles normalised profiles
216
+ * @param {(model: string) => string} [input.retentionOf] the retention verdict for a model id
217
+ * @param {string} [input.provider]
218
+ * @param {string | null} [input.defaultProfileId] the profile the lane view is about
219
+ * @returns {ResearchReport} the report
220
+ */
221
+ export function researchModels({
222
+ catalog,
223
+ profiles = [],
224
+ retentionOf = () => "unknown",
225
+ provider = TIER_PROVIDER,
226
+ defaultProfileId = null,
227
+ }) {
228
+ const tier = tierModels(catalog, provider)
229
+ const live = tier.filter((t) => !t.withdrawn)
230
+ const shipped = shippedAcross(profiles)
231
+
232
+ // The lane view is about the profile the kit ships as the default, not
233
+ // about whichever profile happens to come first in the directory. The
234
+ // default is the one every install lands on, so it is the one whose chains
235
+ // a withdrawal actually empties.
236
+ const fleet =
237
+ (defaultProfileId ? profiles.find((p) => p?.id === defaultProfileId) : null) ??
238
+ profiles.find((p) => p?.roleModels && Object.keys(p.roleModels).length)
239
+ const roles = fleet?.roles ?? {}
240
+
241
+ /** @type {{ lane: string, dead: string[], live: string[] }[]} */
242
+ const lanes = []
243
+ if (fleet) {
244
+ for (const [lane, rm] of Object.entries(fleet.roleModels ?? {})) {
245
+ const chain = rm?.chain?.length ? rm.chain : rm?.model ? [rm.model] : []
246
+ const dead = []
247
+ const alive = []
248
+ for (const rung of chain) {
249
+ const record = catalog?.[provider]?.models?.[rung]
250
+ if (!record || isWithdrawn(record)) dead.push(rung)
251
+ else alive.push(rung)
252
+ }
253
+ lanes.push({ lane, dead, live: alive })
254
+ }
255
+ }
256
+
257
+ // A candidate is a live, free model the kit does not use yet. It is a
258
+ // shortlist, so it carries what a person needs to decide: what the model
259
+ // can do, which lanes it could serve, and what is still undecided.
260
+ const candidates = live
261
+ .filter((t) => !shipped.has(t.id))
262
+ .map((t) => {
263
+ const fits = Object.entries(roles)
264
+ .filter(([lane, role]) => fitsLane(t.model, lane, role ?? null))
265
+ .map(([lane]) => lane)
266
+ const retention = retentionOf(t.id)
267
+ /** @type {string[]} */
268
+ const blockers = []
269
+ if (retention === "unknown") {
270
+ blockers.push(
271
+ "no retention verdict in retention.yml - a person has to read the provider's terms",
272
+ )
273
+ }
274
+ return {
275
+ id: t.id,
276
+ model: t.id,
277
+ provider,
278
+ context: t.model?.limit?.context ?? null,
279
+ output: t.model?.limit?.output ?? null,
280
+ effort: effortLevels(t.model),
281
+ capabilities: {
282
+ toolCall: Boolean(t.model?.tool_call),
283
+ attachment: Boolean(t.model?.attachment),
284
+ temperature: Boolean(t.model?.temperature),
285
+ },
286
+ retention,
287
+ fits,
288
+ blockers,
289
+ }
290
+ })
291
+ .sort((a, b) => b.fits.length - a.fits.length || (b.context ?? 0) - (a.context ?? 0))
292
+
293
+ // The models the kit uses that the provider has pulled. This is the part
294
+ // that has to be actioned: a withdrawn model in a chain is a fallback that
295
+ // fails on the day it is needed.
296
+ const gone = []
297
+ for (const [model, use] of shipped) {
298
+ const record = catalog?.[provider]?.models?.[model]
299
+ if (!record || isWithdrawn(record)) {
300
+ gone.push({
301
+ id: model,
302
+ provider,
303
+ asBase: [...new Set(use.asBase)],
304
+ inChains: use.inChains,
305
+ present: Boolean(record),
306
+ reason: record
307
+ ? "withdrawn from the provider - the catalogue keeps the record and marks it deprecated"
308
+ : "not in the catalogue at all",
309
+ })
310
+ }
311
+ }
312
+
313
+ return {
314
+ tier: {
315
+ provider,
316
+ total: tier.length,
317
+ live: live.length,
318
+ withdrawn: tier.length - live.length,
319
+ },
320
+ shipped: { count: shipped.size, gone },
321
+ lanes,
322
+ candidates,
323
+ }
324
+ }
325
+
326
+ /**
327
+ * @typedef {object} PruneReport
328
+ * @property {boolean} dead the profile's own model is gone, so there is nothing left to repair
329
+ * @property {string[]} pruned the models that were removed, deduplicated
330
+ * @property {{ lane: string, from: string[], to: string[], removed: string[] }[]} lanes
331
+ * @property {{ from: string[], to: string[], removed: string[] } | null} fallback
332
+ * @property {string | null} utilityMoved the utility route, when it pointed at a withdrawn model
333
+ * @property {string[]} emptied lanes left with no model at all
334
+ */
335
+
336
+ /**
337
+ * Remove the rungs a lane can no longer use.
338
+ *
339
+ * A chain names the models that could serve a lane, best first. A model the
340
+ * provider has withdrawn is not one of them any more: it cannot run, so it is
341
+ * dead weight in a chain whose entire job is to survive a withdrawal.
342
+ *
343
+ * Pruning is a derivation from live metadata, so a script may do it. The
344
+ * human chose WHICH models a lane lists and in what order; a script can only
345
+ * remove the ones that are gone. A script never ADDS a rung, because putting
346
+ * a model into a lane it was not designed for changes what the lane does -
347
+ * that is a person's call, and `research` is where it gets made.
348
+ *
349
+ * The profile is repaired in place and the report says what changed:
350
+ *
351
+ * - a lane whose primary model was withdrawn takes the next live rung as
352
+ * its primary, because a chain has to start with its model
353
+ * - the fallback queue loses its dead entries
354
+ * - the utility route moves to a live model the profile still reaches
355
+ *
356
+ * A profile whose OWN model has been withdrawn cannot be repaired this way:
357
+ * there is no chain behind it to fall back to. That is reported as `dead`,
358
+ * because the honest repair is to retire the profile, and retiring it is a
359
+ * person's decision.
360
+ *
361
+ * @param {any} p a profile object, repaired in place
362
+ * @param {any} catalog the models.dev catalogue
363
+ * @param {string} [provider]
364
+ * @returns {PruneReport}
365
+ */
366
+ export function pruneWithdrawnChains(p, catalog, provider = TIER_PROVIDER) {
367
+ const models = catalog?.[provider]?.models ?? {}
368
+ /** @param {string} id */
369
+ const alive = (id) => {
370
+ const record = models[id]
371
+ return Boolean(record) && !isWithdrawn(record)
372
+ }
373
+
374
+ /** @type {PruneReport} */
375
+ const report = {
376
+ dead: false,
377
+ pruned: [],
378
+ lanes: [],
379
+ fallback: null,
380
+ utilityMoved: null,
381
+ emptied: [],
382
+ }
383
+
384
+ // The profile's own model has no chain behind it. If it is gone, the
385
+ // profile is gone, and pretending otherwise would write a profile that
386
+ // points every lane at a model that cannot run.
387
+ if (p?.model && !alive(p.model)) {
388
+ report.dead = true
389
+ report.pruned.push(p.model)
390
+ return report
391
+ }
392
+
393
+ const removed = new Set()
394
+
395
+ for (const [lane, rm] of Object.entries(p?.roleModels ?? {})) {
396
+ if (!rm) continue
397
+ const chain = /** @type {string[]} */ (
398
+ Array.isArray(rm.chain) && rm.chain.length ? rm.chain : rm.model ? [rm.model] : []
399
+ )
400
+ const kept = chain.filter((rung) => {
401
+ if (alive(rung)) return true
402
+ removed.add(rung)
403
+ return false
404
+ })
405
+ if (kept.length === chain.length) continue
406
+
407
+ report.lanes.push({
408
+ lane,
409
+ from: [...chain],
410
+ to: kept,
411
+ removed: chain.filter((rung) => !kept.includes(rung)),
412
+ })
413
+
414
+ if (!kept.length) {
415
+ // Nothing left to run this lane on. Recorded rather than papered over.
416
+ report.emptied.push(lane)
417
+ continue
418
+ }
419
+ rm.chain = kept
420
+ // A chain has to start with its model, so a lane whose primary was
421
+ // withdrawn takes the next live rung as its primary.
422
+ if (rm.model !== kept[0]) rm.model = kept[0]
423
+ }
424
+
425
+ if (Array.isArray(p?.fallback)) {
426
+ const before = [...p.fallback]
427
+ const kept = before.filter((model) => {
428
+ if (alive(model)) return true
429
+ removed.add(model)
430
+ return false
431
+ })
432
+ if (kept.length !== before.length) {
433
+ report.fallback = {
434
+ from: before,
435
+ to: kept,
436
+ removed: before.filter((m) => !kept.includes(m)),
437
+ }
438
+ p.fallback = kept
439
+ }
440
+ }
441
+
442
+ // The utility route has to be a model the profile still reaches, or a
443
+ // failover leaves opencode's short auxiliary calls pointing outside the
444
+ // fleet. If it pointed at a withdrawn model, it moves to one that is still
445
+ // in a chain.
446
+ if (p?.utility?.model && !alive(p.utility.model)) {
447
+ removed.add(p.utility.model)
448
+ const reachable = new Set()
449
+ for (const rm of Object.values(p?.roleModels ?? {})) {
450
+ if (rm?.model) reachable.add(rm.model)
451
+ for (const rung of rm?.chain ?? []) reachable.add(rung)
452
+ }
453
+ const next = [...reachable].find((model) => alive(model))
454
+ if (next) {
455
+ report.utilityMoved = p.utility.model
456
+ p.utility.model = next
457
+ }
458
+ }
459
+
460
+ report.pruned = [...removed]
461
+ return report
462
+ }
@@ -1,45 +0,0 @@
1
- {
2
- "$comment": "Metadata verified against models.dev on 2026-10-19 and confirmed by a live call the same day. Regenerate the capability fields with `oc-harness refresh longcat-2.5-preview-free`.",
3
- "id": "longcat-2.5-preview-free",
4
- "title": "LongCat 2.5 Preview Free",
5
- "provider": "opencode",
6
- "model": "longcat-2.5-preview-free",
7
- "family": "longcat",
8
- "verified": "2026-10-21",
9
- "retention": "zero-retention",
10
- "notes": "Meituan. 1M context with image input, and a reasoning toggle rather than a tier, so no variant is written. It read a screenshot correctly in a live call, which is what makes it the zero-retention fallback for the vision lane. Measured at 9.4-13.2s. A preview endpoint: it can be withdrawn without a rename.",
11
- "limits": {
12
- "context": 1000000,
13
- "output": 131072
14
- },
15
- "supports": {
16
- "reasoning": true,
17
- "temperature": false,
18
- "toolCall": true,
19
- "structuredOutput": false,
20
- "attachment": true
21
- },
22
- "effort": {
23
- "levels": [],
24
- "default": null,
25
- "reserveShare": 0.12
26
- },
27
- "roles": {
28
- "plan": "max",
29
- "build": "max",
30
- "reviewer": "max",
31
- "locate": "low",
32
- "verify": "low"
33
- },
34
- "prune": {
35
- "enabled": true,
36
- "keepRecentToolCalls": 6,
37
- "keepRecentTurns": 3,
38
- "keepUserMessages": true
39
- },
40
- "fallback": [
41
- "minimax-m3.1-flash-free",
42
- "deepseek-v4.1-flash-free",
43
- "beam-free"
44
- ]
45
- }