opencode-harness-kit 0.2.1 → 0.3.3

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/GLOBAL.md +50 -13
  3. package/MIGRATION.md +140 -0
  4. package/README.md +233 -116
  5. package/bin/oc-harness.mjs +474 -96
  6. package/dist/index.js +378 -155
  7. package/docs/KNOWN-LIMITS.md +189 -4
  8. package/docs/RESEARCH.md +80 -5
  9. package/docs/ROUTING.md +254 -0
  10. package/lanes.json +136 -0
  11. package/lanes.schema.json +55 -0
  12. package/package.json +12 -8
  13. package/profiles/beam-free.json +51 -0
  14. package/profiles/big-pickle.json +45 -0
  15. package/profiles/deepseek-v4.1-flash-free.json +49 -0
  16. package/profiles/fleet-all.json +139 -0
  17. package/profiles/fleet-safe.json +139 -0
  18. package/profiles/glm-5.3-flash-free.json +49 -0
  19. package/profiles/index.json +120 -37
  20. package/profiles/longcat-2.5-preview-free.json +14 -10
  21. package/profiles/mimo-v2.6-flash-free.json +44 -0
  22. package/profiles/minimax-m3.1-flash-free.json +51 -0
  23. package/profiles/nemotron-3-ultra-free.json +44 -0
  24. package/profiles/nemotron-3.5-lightning-free.json +44 -0
  25. package/profiles/nemotron-3.5-super-vl-free.json +52 -0
  26. package/profiles/schema.json +30 -11
  27. package/{agents → prompts}/build.md +2 -35
  28. package/prompts/doc.md +42 -0
  29. package/{agents → prompts}/locate.md +1 -16
  30. package/prompts/longform.md +34 -0
  31. package/{agents → prompts}/plan.md +2 -25
  32. package/{agents → prompts}/reviewer.md +2 -22
  33. package/prompts/schema.md +30 -0
  34. package/prompts/sweep.md +29 -0
  35. package/{agents → prompts}/verify.md +6 -29
  36. package/prompts/vision.md +33 -0
  37. package/retention.yml +75 -57
  38. package/skills/space-build/SKILL.md +16 -0
  39. package/skills/space-review/SKILL.md +8 -0
  40. package/src/config.js +94 -69
  41. package/src/effort.js +59 -22
  42. package/src/index.ts +15 -2
  43. package/src/lane-usage.js +142 -0
  44. package/src/profile.js +502 -212
  45. package/src/router.ts +148 -39
  46. package/src/telemetry.ts +98 -0
  47. package/examples/muse-spark-1.3-contributor-free.json +0 -41
  48. package/profiles/deepseek-v4-flash-free.json +0 -41
  49. package/profiles/muse-split-free.json +0 -64
  50. package/profiles/space-bunny-free.json +0 -42
  51. package/profiles/x-preview-f-free.json +0 -41
package/src/router.ts CHANGED
@@ -1,12 +1,14 @@
1
- // Model fallback across a profile's queue.
1
+ // Model fallback across a profile's lanes.
2
2
  //
3
3
  // The honest version of this feature: opencode has no hook that can change a
4
4
  // live session's model. `chat.params` only sees temperature and sampling, the
5
5
  // model on `chat.message` is read-only, and `config` runs once at startup. So
6
6
  // failover here means two things, and only two:
7
7
  //
8
- // 1. At startup, if a health file marks the preferred model unhealthy, pick
9
- // the next healthy profile before the session begins.
8
+ // 1. At startup, if a health file marks a route unhealthy, the lane that
9
+ // used it moves to the next rung of its own chain. A fleet has ten lanes
10
+ // and ten different models, so one spent route must not take the other
11
+ // nine down with it.
10
12
  // 2. When a request fails, tell the agent plainly that the route is spent and
11
13
  // a new session is needed, instead of letting it retry into a wall.
12
14
  //
@@ -25,6 +27,9 @@ import {
25
27
  profileIdFromConfig,
26
28
  reservedTokens,
27
29
  resolveEffort,
30
+ resolveLaneChain,
31
+ resolveLaneModel,
32
+ resolveRoles,
28
33
  } from "./profile.js"
29
34
 
30
35
  interface Health {
@@ -53,6 +58,29 @@ function writeHealth(worktree: string, h: Health) {
53
58
  }
54
59
  }
55
60
 
61
+ /**
62
+ * The models a previous session found spent.
63
+ *
64
+ * Exported so the effort hook resolves a lane's variant against the model the
65
+ * lane actually ended up on. A lane that failed over from a model with a
66
+ * `none` effort level to one without levels has to lose its variant, or the
67
+ * config asks for an effort the new model does not publish.
68
+ *
69
+ * @param {string} worktree
70
+ * @returns {Set<string>}
71
+ */
72
+ export function unhealthyModels(worktree: string): Set<string> {
73
+ const health = readHealth(worktree)
74
+ // The health file is keyed by the qualified route, `opencode/<model>`. The
75
+ // lane chains list bare model ids, so the prefix is dropped here. Comparing
76
+ // the two directly would never match, and a spent route would look healthy.
77
+ const out = new Set<string>()
78
+ for (const key of Object.keys(health.unhealthy ?? {})) {
79
+ out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
80
+ }
81
+ return out
82
+ }
83
+
56
84
  /** Patterns that mean "this route is spent", not "this request was bad". */
57
85
  const SPENT = [
58
86
  /FreeUsageLimitError/i,
@@ -72,8 +100,8 @@ function classify(text: string): string | null {
72
100
  const WARN_TEXT =
73
101
  "[opencode-harness-kit] This session's model route is rate limited or withdrawn. " +
74
102
  "No plugin can swap a live session's model, so stop retrying and ask the human to start a " +
75
- "new session. Run `oc-harness list` to see the fallback queue, or `oc-harness doctor` to " +
76
- "check whether the profile still matches live metadata."
103
+ "new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` " +
104
+ "to check whether the profile still matches live metadata."
77
105
 
78
106
  export const RouterPlugin: Plugin = async ({ worktree }) => {
79
107
  let failedSessions = 0
@@ -85,32 +113,75 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
85
113
  try {
86
114
  const cfg = config as Record<string, any>
87
115
  const health = readHealth(worktree)
116
+ const unhealthy = unhealthyModels(worktree)
88
117
 
89
118
  // config.harness is stripped by schema validation, so the active
90
119
  // profile comes from the config file on disk.
91
120
  const activeId = cfg.harness?.profile ?? profileIdFromConfig()
92
- if (activeId) {
93
- const id: string = activeId
94
- const mark = health.unhealthy[`opencode/${id}`]
95
- if (mark) {
96
- // Walk the queue for the first profile that is not marked down.
97
- let chosen: string | null = id
121
+ if (!activeId) return
122
+ const id: string = activeId
123
+ let profile
124
+ try {
125
+ profile = loadProfile(id)
126
+ } catch {
127
+ return
128
+ }
129
+
130
+ // 1. The session's own model. When it is spent, the profile's
131
+ // fallback queue names the profiles that can take over.
132
+ //
133
+ // A fleet keeps its lanes. Every lane has its own chain, and step 2
134
+ // moves a lane down that chain when its route is spent, so
135
+ // overwriting the lanes here would collapse ten jobs onto one
136
+ // model and throw away the chain that was supposed to survive the
137
+ // outage. Only the session model moves.
138
+ const baseKey = `opencode/${profile.model}`
139
+ if (health.unhealthy?.[baseKey]) {
140
+ const queue = [id, ...(profile.fallback ?? [])]
141
+ for (const candidate of queue) {
142
+ if (health.unhealthy?.[`opencode/${candidate}`]) continue
143
+ let fallback
98
144
  try {
99
- const queue = [id, ...(loadProfile(id).fallback ?? [])]
100
- for (const candidate of queue) {
101
- if (!health.unhealthy[`opencode/${candidate}`]) {
102
- chosen = candidate
103
- break
104
- }
105
- }
145
+ fallback = loadProfile(candidate)
106
146
  } catch {
107
- // A broken profile must not stop opencode from starting.
108
- }
109
- if (chosen !== id) {
110
- applyProfileToConfig(cfg, chosen)
111
- health.active = chosen
112
- writeHealth(worktree, health)
147
+ continue
113
148
  }
149
+ const key = `opencode/${fallback.model}`
150
+ if (health.unhealthy?.[key]) continue
151
+ const fleet = isFleet(profile)
152
+ applySessionModel(cfg, fallback, {
153
+ keepLanes: fleet,
154
+ // The lanes did not change, so the numbers derived from them did
155
+ // not change either.
156
+ profileId: fleet ? profile.id : fallback.id,
157
+ reserved: fleet ? reservedTokens(profile) : undefined,
158
+ threshold: fleet ? pruneThreshold(profile) : undefined,
159
+ })
160
+ health.active = candidate
161
+ writeHealth(worktree, health)
162
+ break
163
+ }
164
+ }
165
+
166
+ // 2. Every lane. A lane whose model is spent moves to the next rung of
167
+ // its own chain, so one dead route costs one lane, not the fleet.
168
+ const roles = resolveRoles(profile, { unhealthy })
169
+ for (const [lane, level] of Object.entries(roles)) {
170
+ if (level === undefined) continue
171
+ const agent = cfg.agent?.[lane]
172
+ if (!agent) continue
173
+ const resolved = resolveLaneModel(profile, lane, { unhealthy })
174
+ if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue
175
+ const wantModel = `opencode/${resolved.model}`
176
+ if (agent.model !== wantModel) {
177
+ agent.model = wantModel
178
+ // The variant has to be resolved against the model the lane ended
179
+ // up on. A lane that failed over to a model with fewer effort
180
+ // levels has to lose the variant the old model had.
181
+ const requested = (profile.roles?.[lane] ?? null) as any
182
+ const next = resolveEffort(resolved.levels, requested)
183
+ if (next === null) delete agent.variant
184
+ else agent.variant = next
114
185
  }
115
186
  }
116
187
  } catch {
@@ -160,30 +231,68 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
160
231
  }
161
232
  }
162
233
 
163
- /** Point the config at another profile, including its derived numbers. */
164
- function applyProfileToConfig(cfg: Record<string, any>, profileId: string) {
234
+ /**
235
+ * Whether a profile runs more than one model behind its lanes.
236
+ * @param {any} profile
237
+ * @returns {boolean}
238
+ */
239
+ function isFleet(profile: any): boolean {
240
+ return Object.keys(profile?.roleModels ?? {}).length > 0
241
+ }
242
+
243
+ /**
244
+ * Move the SESSION onto another profile's model.
245
+ *
246
+ * A fleet keeps its lanes. The lanes have their own chains, and the config
247
+ * hook has already moved each one down its chain past a spent route, so
248
+ * overwriting them here would collapse ten jobs onto one model and discard
249
+ * the chain that was supposed to survive the outage.
250
+ *
251
+ * A profile with no lanes of its own is a single-model profile: there is
252
+ * nothing to keep, so the fallback takes the lanes too.
253
+ *
254
+ * The session model is published on `harness.sessionModel` so the effort hook
255
+ * - which runs after this one - pins the session to the model that was chosen
256
+ * here rather than back to the base model that is spent.
257
+ */
258
+ function applySessionModel(
259
+ cfg: Record<string, any>,
260
+ profile: any,
261
+ opts: {
262
+ keepLanes?: boolean
263
+ profileId?: string
264
+ reserved?: number
265
+ threshold?: number
266
+ } = {},
267
+ ) {
165
268
  try {
166
- const p = loadProfile(profileId)
167
- const model = `${p.provider}/${p.model}`
269
+ const model = `${profile.provider}/${profile.model}`
168
270
  cfg.model = model
169
271
  cfg.small_model = model
170
- const variant = p.effort?.default
171
- if (p.effort?.levels?.length && variant) cfg.variant = variant
272
+ const variant = profile.effort?.default
273
+ if (profile.effort?.levels?.length && variant) cfg.variant = variant
172
274
  else delete cfg.variant
173
275
 
174
- for (const [role, want] of Object.entries(p.roles ?? {})) {
175
- const agent = cfg.agent ?? (cfg.agent = {})
176
- const existing = agent[role] ?? (agent[role] = {})
177
- existing.model = model
178
- const level = resolveEffort(p.effort?.levels ?? [], want)
179
- if (level) existing.variant = level
180
- else delete existing.variant
276
+ if (!opts.keepLanes) {
277
+ for (const [lane, want] of Object.entries(profile.roles ?? {})) {
278
+ const agent = cfg.agent ?? (cfg.agent = {})
279
+ const existing = agent[lane] ?? (agent[lane] = {})
280
+ existing.model = model
281
+ const level = resolveEffort(profile.effort?.levels ?? [], want as any)
282
+ if (level) existing.variant = level
283
+ else delete existing.variant
284
+ }
181
285
  }
182
286
  if (cfg.compaction) {
183
287
  cfg.compaction.prune = true
184
- cfg.compaction.reserved = reservedTokens(p)
288
+ cfg.compaction.reserved = opts.reserved ?? reservedTokens(profile)
289
+ }
290
+ cfg.harness = {
291
+ ...(cfg.harness ?? {}),
292
+ profile: opts.profileId ?? profile.id,
293
+ threshold: opts.threshold ?? pruneThreshold(profile),
294
+ sessionModel: model,
185
295
  }
186
- cfg.harness = { ...(cfg.harness ?? {}), profile: profileId, threshold: pruneThreshold(p) }
187
296
  } catch {
188
297
  // Leave the config alone if the fallback profile cannot be read.
189
298
  }
package/src/telemetry.ts CHANGED
@@ -8,6 +8,29 @@
8
8
  // What is worth recording: context size per request (to see pruning working),
9
9
  // latency by role and effort (to see tiering paying off), tool denials (to see
10
10
  // the guard being useful rather than annoying), and provider rate limits.
11
+ //
12
+ // A fleet adds one more question, and it is the one that decides whether the
13
+ // fleet stays at ten lanes or is cut down: which lane is actually used. Ten
14
+ // lanes that are never called are ten descriptions in the model\'s context and
15
+ // ten models on the free tier doing nothing. `oc-harness lanes` reads these
16
+ // records and reports the ones that were never called.
17
+ //
18
+ // WHAT A CALL IS
19
+ //
20
+ // One assistant message is one call. opencode emits `message.updated` several
21
+ // times for a single message - created, streamed, completed - so writing a
22
+ // record per event counted every call three times. A lane record therefore
23
+ // carries the message id, and a lane is counted once per id.
24
+ //
25
+ // Two records are written per message, at most: an open one when the message
26
+ // appears, and a closed one when it completes. The closed record is the only
27
+ // one with a duration, so "how long does this lane take" and "was this lane
28
+ // called at all" are answered from the same file without guessing.
29
+ //
30
+ // The `chat.message` hook is deliberately not used for this. Measured on
31
+ // OpenCode 1.19, its `agent` field is undefined, so it cannot attribute a
32
+ // call to a lane; and a second counter that cannot be correlated with the
33
+ // first would double-count every call.
11
34
 
12
35
  import type { Plugin } from "@opencode-ai/plugin"
13
36
  import { appendFileSync, mkdirSync, existsSync, statSync, renameSync } from "node:fs"
@@ -45,6 +68,17 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
45
68
  const dir = join(worktree, ".space")
46
69
  const started = Date.now()
47
70
 
71
+ // The message id of every lane record already written, and of every one
72
+ // already written with a duration.
73
+ //
74
+ // opencode emits `message.updated` several times for a single assistant
75
+ // message: once when the message is created, once per streaming update, and
76
+ // once when it completes. Writing a record per event counted one call three
77
+ // times, which made the "cut the lanes nobody calls" counter wrong by a
78
+ // factor of three. A lane is counted once per message id instead.
79
+ const opened = new Set<string>()
80
+ const closed = new Set<string>()
81
+
48
82
  return {
49
83
  event: async ({ event }) => {
50
84
  // Events are provider-specific and opencode may add shapes we have never
@@ -56,6 +90,30 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
56
90
  // they get logged even though the event shape is provider-specific.
57
91
  if (/error|rate|limit|quota/i.test(type)) {
58
92
  write(dir, { kind: "provider-event", type, detail: truncate(safeStringify(event)) })
93
+ return
94
+ }
95
+
96
+ // An assistant message is the only place the kit can see which lane ran,
97
+ // on which model, and how long it took. That record is what makes "cut
98
+ // the lanes nobody uses" a measurement instead of a guess.
99
+ if (type === "message.updated") {
100
+ const lane = laneOf(event)
101
+ if (!lane || !lane.id) return
102
+ if (lane.ms !== undefined) {
103
+ // The completed event. Written once, and only if the open record
104
+ // did not already carry a duration.
105
+ if (closed.has(lane.id)) return
106
+ closed.add(lane.id)
107
+ write(dir, { kind: "lane", ...lane })
108
+ return
109
+ }
110
+ // The open event: the lane was called. Written even if the call never
111
+ // finishes, because "this lane is useless" and "this lane is slow"
112
+ // are different questions and only the first one is settled by a
113
+ // record with no duration.
114
+ if (opened.has(lane.id)) return
115
+ opened.add(lane.id)
116
+ write(dir, { kind: "lane", ...lane })
59
117
  }
60
118
  },
61
119
 
@@ -77,6 +135,46 @@ export const TelemetryPlugin: Plugin = async ({ worktree }) => {
77
135
  }
78
136
  }
79
137
 
138
+ /**
139
+ * The lane, model and duration of one assistant message, read defensively.
140
+ *
141
+ * The event shape is opencode's, not ours, and it has changed between
142
+ * releases. Every field is optional and a shape we do not recognise yields
143
+ * null rather than a wrong number.
144
+ *
145
+ * The message id is what makes the record countable: one message is one call.
146
+ */
147
+ function laneOf(event: unknown): {
148
+ id?: string
149
+ agent?: string
150
+ model?: string
151
+ provider?: string
152
+ ms?: number
153
+ } | null {
154
+ const e = event as { properties?: any } | undefined
155
+ const info = e?.properties?.info ?? e?.properties
156
+ if (!info || typeof info !== "object") return null
157
+ if (info.role && info.role !== "assistant") return null
158
+
159
+ const id = typeof info.id === "string" ? info.id : undefined
160
+ const agent = typeof info.agent === "string" ? info.agent : undefined
161
+ const modelId = info.model?.modelID ?? info.modelID
162
+ const providerId = info.model?.providerID ?? info.providerID
163
+ const time = info.time ?? {}
164
+ const created = typeof time.created === "number" ? time.created : undefined
165
+ const completed = typeof time.completed === "number" ? time.completed : undefined
166
+ const ms = created !== undefined && completed !== undefined ? completed - created : undefined
167
+
168
+ if (!id || !agent) return null
169
+ return {
170
+ id,
171
+ agent,
172
+ model: typeof modelId === "string" ? modelId : undefined,
173
+ provider: typeof providerId === "string" ? providerId : undefined,
174
+ ms: typeof ms === "number" && ms >= 0 ? ms : undefined,
175
+ }
176
+ }
177
+
80
178
  function extractRule(output: string): string {
81
179
  const m = output.match(/blocked:\s*([^\n]+)/)
82
180
  return m ? m[1].trim() : "unknown"
@@ -1,41 +0,0 @@
1
- {
2
- "$comment": "Contrarian example. Kept out of profiles/index.json on purpose: free, 1M context, and widely used, but its provider is permitted to train on your prompts and it has no `max` effort. `oc-harness list` shows it struck through with the reason. Install it with `oc-harness use muse-spark-1.3-contributor-free` and the warning is repeated on install.",
3
- "id": "muse-spark-1.3-contributor-free",
4
- "title": "Muse Spark 1.3 Contributor Free (OpenCode Zen)",
5
- "provider": "opencode",
6
- "model": "muse-spark-1.3-contributor-free",
7
- "family": "muse-free",
8
- "verified": "2026-10-07",
9
- "retention": "may-train",
10
- "notes": "Capability/privacy tradeoff, not a recommendation. Served over /v1/responses, a better cache dialect than chat/completions, and it supports temperature. But: 'Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models', and its top effort is xhigh rather than max. Do not make this the default for private code.",
11
- "limits": {
12
- "context": 1048576,
13
- "output": 131072
14
- },
15
- "supports": {
16
- "reasoning": true,
17
- "temperature": true,
18
- "toolCall": true,
19
- "structuredOutput": true,
20
- "attachment": true
21
- },
22
- "effort": {
23
- "levels": ["minimal", "low", "medium", "high", "xhigh"],
24
- "default": "xhigh",
25
- "reserveShare": 0.12
26
- },
27
- "roles": {
28
- "plan": "max",
29
- "build": "high",
30
- "reviewer": "xhigh",
31
- "locate": "low",
32
- "verify": "low"
33
- },
34
- "prune": {
35
- "enabled": true,
36
- "keepRecentToolCalls": 6,
37
- "keepRecentTurns": 3,
38
- "keepUserMessages": true
39
- },
40
- "fallback": ["x-preview-f-free", "longcat-2.5-preview-free"]
41
- }
@@ -1,41 +0,0 @@
1
- {
2
- "$comment": "Fallback tier 3. The smallest context in the queue at 200k, which is why prune.keepRecentToolCalls drops: the same settings tuned for a 1M model overflow this one.",
3
- "id": "deepseek-v4-flash-free",
4
- "title": "DeepSeek V4 Flash Free (OpenCode Zen)",
5
- "provider": "opencode",
6
- "model": "deepseek-v4-flash-free",
7
- "family": "deepseek-flash",
8
- "verified": "2026-10-07",
9
- "retention": "zero-retention",
10
- "notes": "Text only, no attachment, 200k context. Included to show the compiler scaling pruning to the model: reserveShare and retention fall together, so a 1M-tuned profile cannot silently overflow a 200k model.",
11
- "limits": {
12
- "context": 200000,
13
- "output": 128000
14
- },
15
- "supports": {
16
- "reasoning": true,
17
- "temperature": true,
18
- "toolCall": true,
19
- "structuredOutput": true,
20
- "attachment": false
21
- },
22
- "effort": {
23
- "levels": ["low", "high", "max"],
24
- "default": "max",
25
- "reserveShare": 0.12
26
- },
27
- "roles": {
28
- "plan": "max",
29
- "build": "high",
30
- "reviewer": "max",
31
- "locate": "low",
32
- "verify": "low"
33
- },
34
- "prune": {
35
- "enabled": true,
36
- "keepRecentToolCalls": 4,
37
- "keepRecentTurns": 2,
38
- "keepUserMessages": true
39
- },
40
- "fallback": []
41
- }
@@ -1,64 +0,0 @@
1
- {
2
- "$comment": "Split profile. The heavy reasoning roles run on Muse Spark 1.3 because it is the strongest free model on Zen by a wide margin (Terminal-Bench 2.1 88.8% vs Nemotron 3 Ultra's 56.4%, DeepSWE v1.1 75.4%). The bulk-output roles stay on Space Bunny because its 524,288-token output cap is 4x Muse Spark's 131,072, and scaffolding or migrating many files is where that ceiling is actually felt. `retention` below describes the BASE model only; the config header prints the effective retention, which is may-train because three roles run on Muse Spark.",
3
- "id": "muse-split-free",
4
- "title": "Muse Spark 1.3 (heavy) + Space Bunny Free (bulk output)",
5
- "provider": "opencode",
6
- "model": "space-bunny-free",
7
- "family": null,
8
- "verified": "2026-10-07",
9
- "retention": "zero-retention",
10
- "notes": "Opt-in split, not a default. Muse Spark is a Meta contributor-tier free endpoint: its provider may train on your prompts and completions. Three of the five roles are therefore may-train even though the base model is zero-retention. Install it only for public or throwaway repositories. Effort requests below are resolved against each role's own model, so plan and reviewer ask for `max` and land on `xhigh`, which is the top Muse Spark offers.",
11
- "limits": {
12
- "context": 1048576,
13
- "input": 524288,
14
- "output": 524288
15
- },
16
- "supports": {
17
- "reasoning": true,
18
- "temperature": false,
19
- "toolCall": true,
20
- "structuredOutput": false,
21
- "attachment": true
22
- },
23
- "effort": {
24
- "levels": ["low", "medium", "high", "xhigh", "max"],
25
- "default": "max",
26
- "reserveShare": 0.12
27
- },
28
- "roles": {
29
- "plan": "max",
30
- "build": "high",
31
- "reviewer": "max",
32
- "locate": "low",
33
- "verify": "low"
34
- },
35
- "roleModels": {
36
- "plan": {
37
- "model": "muse-spark-1.3-contributor-free",
38
- "levels": ["minimal", "low", "medium", "high", "xhigh"]
39
- },
40
- "build": {
41
- "model": "muse-spark-1.3-contributor-free",
42
- "levels": ["minimal", "low", "medium", "high", "xhigh"]
43
- },
44
- "reviewer": {
45
- "model": "muse-spark-1.3-contributor-free",
46
- "levels": ["minimal", "low", "medium", "high", "xhigh"]
47
- },
48
- "locate": {
49
- "model": "space-bunny-free",
50
- "levels": ["low", "medium", "high", "xhigh", "max"]
51
- },
52
- "verify": {
53
- "model": "space-bunny-free",
54
- "levels": ["low", "medium", "high", "xhigh", "max"]
55
- }
56
- },
57
- "prune": {
58
- "enabled": true,
59
- "keepRecentToolCalls": 6,
60
- "keepRecentTurns": 3,
61
- "keepUserMessages": true
62
- },
63
- "fallback": ["x-preview-f-free", "longcat-2.5-preview-free", "deepseek-v4-flash-free"]
64
- }
@@ -1,42 +0,0 @@
1
- {
2
- "$comment": "Validated against models.dev on 2026-10-07. Regenerate with `oc-harness use --refresh`.",
3
- "id": "space-bunny-free",
4
- "title": "Space Bunny Free (OpenCode Zen)",
5
- "provider": "opencode",
6
- "model": "space-bunny-free",
7
- "family": null,
8
- "verified": "2026-10-07",
9
- "retention": "zero-retention",
10
- "notes": "Stealth preview reasoning model. Served over /v1/chat/completions (openai-compatible), which is the weaker cache dialect compared with models on /v1/responses. At the time of writing it is the only free model on Zen whose top reasoning effort is `max` and whose provider is zero-retention. Free for a limited time; expect the slot to rotate.",
11
- "limits": {
12
- "context": 1048576,
13
- "input": 524288,
14
- "output": 524288
15
- },
16
- "supports": {
17
- "reasoning": true,
18
- "temperature": false,
19
- "toolCall": true,
20
- "structuredOutput": false,
21
- "attachment": true
22
- },
23
- "effort": {
24
- "levels": ["low", "medium", "high", "xhigh", "max"],
25
- "default": "max",
26
- "reserveShare": 0.12
27
- },
28
- "roles": {
29
- "plan": "max",
30
- "build": "max",
31
- "reviewer": "max",
32
- "locate": "low",
33
- "verify": "low"
34
- },
35
- "prune": {
36
- "enabled": true,
37
- "keepRecentToolCalls": 6,
38
- "keepRecentTurns": 3,
39
- "keepUserMessages": true
40
- },
41
- "fallback": ["x-preview-f-free", "longcat-2.5-preview-free", "deepseek-v4-flash-free"]
42
- }
@@ -1,41 +0,0 @@
1
- {
2
- "$comment": "Fallback tier 1 for space-bunny-free. Zero-retention, 1M context, and a `max` effort, but only three levels: there is no medium or xhigh, so roles asking for those resolve down to high.",
3
- "id": "x-preview-f-free",
4
- "title": "x-preview-f Free (OpenCode Zen)",
5
- "provider": "opencode",
6
- "model": "x-preview-f-free",
7
- "family": null,
8
- "verified": "2026-10-07",
9
- "retention": "zero-retention",
10
- "notes": "Only three effort levels (low, high, max) and a 131k completion ceiling, so long single answers are not its strength. Kept as first fallback: same 1M context class and the same zero-retention terms as the default.",
11
- "limits": {
12
- "context": 1000000,
13
- "output": 131072
14
- },
15
- "supports": {
16
- "reasoning": true,
17
- "temperature": true,
18
- "toolCall": true,
19
- "structuredOutput": true,
20
- "attachment": true
21
- },
22
- "effort": {
23
- "levels": ["low", "high", "max"],
24
- "default": "max",
25
- "reserveShare": 0.12
26
- },
27
- "roles": {
28
- "plan": "max",
29
- "build": "high",
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": ["longcat-2.5-preview-free", "deepseek-v4-flash-free"]
41
- }