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.
- package/CHANGELOG.md +139 -0
- package/README.md +217 -21
- package/bin/oc-harness.mjs +177 -10
- package/dist/index.js +54 -6
- package/dist/profile.d.ts +2 -1
- package/docs/KNOWN-LIMITS.md +22 -2
- package/package.json +1 -1
- package/profiles/beam-free.json +2 -3
- package/profiles/deepseek-v4.1-flash-free.json +1 -2
- package/profiles/fleet-all.json +6 -14
- package/profiles/fleet-safe.json +8 -19
- package/profiles/glm-5.3-flash-free.json +1 -2
- package/profiles/index.json +4 -11
- package/profiles/mimo-v2.6-flash-free.json +1 -2
- package/profiles/minimax-m3.1-flash-free.json +1 -2
- package/profiles/nemotron-3.5-lightning-free.json +2 -3
- package/profiles/nemotron-3.5-super-vl-free.json +1 -2
- package/profiles/schema.json +8 -0
- package/retention.yml +0 -9
- package/src/profile.js +14 -1
- package/src/prune.ts +90 -5
- package/src/research.js +462 -0
- package/profiles/longcat-2.5-preview-free.json +0 -45
package/src/research.js
ADDED
|
@@ -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
|
-
}
|