opencode-harness-kit 0.5.4 → 0.5.5

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/src/router.ts CHANGED
@@ -24,7 +24,7 @@ import { join } from "node:path"
24
24
  import { safeStringify } from "./safe.js"
25
25
  import { projectRoot } from "./project.js"
26
26
  import { utilityModel } from "./config.js"
27
- import { readHealth, writeHealth, unhealthyModels } from "./health.js"
27
+ import { readHealth, writeHealth, unhealthyModels, unhealthyRoutes } from "./health.js"
28
28
  import {
29
29
  loadProfile,
30
30
  pruneThreshold,
@@ -43,15 +43,169 @@ const SPENT = [
43
43
  /\b429\b/,
44
44
  /quota (?:exceeded|exhausted)/i,
45
45
  /overloaded_error/i,
46
- /model .*not found/i,
46
+ // A withdrawn model, named in the error. The words have to sit next to
47
+ // each other - "model minimax-m3.1-flash-free not found", "model_not_found",
48
+ // "ModelNotFound" - because the text this runs against is the provider's
49
+ // own error and that can be a whole response body. The old pattern ran
50
+ // over the entire event, where a body that mentioned a model in one place
51
+ // and "not found" in another read as a withdrawn model: a live route
52
+ // written off by a coincidence.
53
+ /\bmodel[\W_]*not[\W_]*found\b/i,
54
+ /\bmodel\b[\w.:/-]*(?:\s+[\w.:/-]+){0,2}\s+not found\b/i,
47
55
  /no available provider/i,
48
56
  ]
49
57
 
50
- function classify(text: string): string | null {
58
+ /**
59
+ * The text of a provider error, and nothing else.
60
+ *
61
+ * The old code classified the whole serialised event. An event carries the
62
+ * message, its token counts and the tool output, so a successful assistant
63
+ * message whose `tokens.input` was 429 matched /\b429\b/ and marked a live
64
+ * route as spent - and a fleet that loses a lane to a number in a token count
65
+ * fails over onto the wrong model for the rest of the day.
66
+ *
67
+ * Only the error's own fields are read, and the SDK keeps the human-readable
68
+ * text in `data.message`, with the status code and the raw body beside it.
69
+ *
70
+ * @param {any} err
71
+ * @returns {string}
72
+ */
73
+ function errorText(err: any): string {
74
+ if (err == null) return ""
75
+ if (typeof err === "string") return err
76
+ if (typeof err !== "object") return String(err)
77
+ const parts: string[] = []
78
+ try {
79
+ const push = (v: unknown) => {
80
+ if (typeof v === "string" && v) parts.push(v)
81
+ else if (typeof v === "number" && Number.isFinite(v)) parts.push(String(v))
82
+ else if (v && typeof v === "object") parts.push(safeStringify(v))
83
+ }
84
+ push(err.name)
85
+ push(err.message)
86
+ push(err.code)
87
+ const data = err.data
88
+ if (data && typeof data === "object") {
89
+ push(data.name)
90
+ push(data.message)
91
+ push(data.code)
92
+ push(data.statusCode)
93
+ push(data.responseBody)
94
+ }
95
+ } catch {
96
+ // A field that throws on read is not a reason to take the session down.
97
+ }
98
+ return parts.join("\n")
99
+ }
100
+
101
+ /**
102
+ * Whether a provider error means the route is spent.
103
+ *
104
+ * @param {any} err
105
+ * @returns {string | null} the pattern that matched
106
+ */
107
+ function classifyError(err: any): string | null {
108
+ const text = errorText(err)
109
+ if (!text) return null
51
110
  for (const p of SPENT) if (p.test(text)) return p.source
52
111
  return null
53
112
  }
54
113
 
114
+ /**
115
+ * The provider error an event carries.
116
+ *
117
+ * `message.updated` carries it on the message, and `session.error` carries
118
+ * it directly. Both are read: a request that failed and a session that
119
+ * failed are the same news about the same route.
120
+ *
121
+ * @param {any} e
122
+ * @returns {any}
123
+ */
124
+ function errorOf(e: any): any {
125
+ const props = e?.properties
126
+ if (!props || typeof props !== "object") return undefined
127
+ return props.info?.error ?? props.error
128
+ }
129
+
130
+ /**
131
+ * The key a route is filed under in the health file.
132
+ *
133
+ * The router files a failure under the route the EVENT names - the provider
134
+ * the SDK reported and the model it reported - and the config hook looks the
135
+ * same route up at startup to decide whether the session has to move. The
136
+ * two halves used different keys: the event's `provider/model` on one side,
137
+ * and a hardcoded `opencode/model` on the other. For a profile whose
138
+ * provider is not "opencode" the failure was filed under
139
+ * "openrouter/<model>" and looked up under "opencode/<model>", so the
140
+ * session-model failover never fired.
141
+ *
142
+ * The key is the QUALIFIED route, and both halves build it with this
143
+ * function. The file is not re-keyed to the bare model id: it already says
144
+ * WHICH provider died, `unhealthyModels()` reads the bare id out of it for
145
+ * the lane chains, and a file written by an earlier version of the kit
146
+ * would stop matching.
147
+ *
148
+ * @param {string | undefined} provider
149
+ * @param {string} model
150
+ * @returns {string} `provider/model`
151
+ */
152
+ function routeKey(provider: string | undefined, model: string): string {
153
+ const p = typeof provider === "string" && provider ? provider : "opencode"
154
+ return `${p}/${model}`
155
+ }
156
+
157
+ /**
158
+ * The route an event is about, in the shape the SDK actually sends.
159
+ *
160
+ * `message.updated` carries the message under `properties.info`, and the
161
+ * message carries the model as `modelID` - or as `model.modelID` on a user
162
+ * message. The flat `properties.modelID` the router used to read is not in
163
+ * the SDK at all: it was a shape the router invented and then tested
164
+ * against, so the key it built was always undefined, nothing was ever
165
+ * recorded, and the failover this file exists for never ran in production.
166
+ *
167
+ * The flat shape is still read, after the SDK's own: some harnesses report
168
+ * it, and a route that is understood is a route that fails over.
169
+ *
170
+ * @param {any} e
171
+ * @returns {string | undefined} the route key, `provider/model`
172
+ */
173
+ function routeOf(e: any): string | undefined {
174
+ const props = e?.properties
175
+ if (!props || typeof props !== "object") return undefined
176
+ const info = props.info ?? props
177
+ if (!info || typeof info !== "object") return undefined
178
+ const model = info.model
179
+ const id = model?.modelID ?? info.modelID ?? props.modelID ?? props.model?.modelID
180
+ if (typeof id !== "string" || !id) return undefined
181
+ const provider = model?.providerID ?? info.providerID ?? props.providerID
182
+ return routeKey(typeof provider === "string" ? provider : undefined, id)
183
+ }
184
+
185
+ /**
186
+ * The session an event belongs to.
187
+ *
188
+ * @param {any} e
189
+ * @returns {string | undefined}
190
+ */
191
+ function sessionIdOf(e: any): string | undefined {
192
+ const props = e?.properties
193
+ const id = props?.info?.sessionID ?? props?.sessionID
194
+ return typeof id === "string" && id ? id : undefined
195
+ }
196
+
197
+ /**
198
+ * The route a config is pointed at.
199
+ *
200
+ * @param {any} cfg
201
+ * @returns {string | undefined} the route key, `provider/model`
202
+ */
203
+ function routeOfConfig(cfg: any): string | undefined {
204
+ const model = cfg?.model ?? cfg?.harness?.sessionModel
205
+ if (typeof model !== "string" || !model) return undefined
206
+ return model.includes("/") ? model : `opencode/${model}`
207
+ }
208
+
55
209
  const WARN_TEXT =
56
210
  "[opencode-harness-kit] This session's model route is rate limited or withdrawn. " +
57
211
  "No plugin can swap a live session's model, so stop retrying and ask the human to start a " +
@@ -65,6 +219,15 @@ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
65
219
  const project = projectRoot(directory, worktree)
66
220
  let failedSessions = 0
67
221
  let warned = false
222
+ // The route this session is on, and the one it was configured with.
223
+ //
224
+ // `session.error` carries an error and no model at all, so the event that
225
+ // reports a failure cannot always say which route failed. Without a
226
+ // fallback the failure is dropped, and a dropped failure is a dead route
227
+ // that stays live until the fleet hits it again.
228
+ const sessionRoutes = new Map<string, string>()
229
+ let lastRoute: string | undefined
230
+ let configuredRoute: string | undefined
68
231
 
69
232
  return {
70
233
  // Runs once, before the session does any work.
@@ -72,6 +235,10 @@ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
72
235
  try {
73
236
  const cfg = config as Record<string, any>
74
237
  const health = readHealth(project)
238
+ // The routes that are spent RIGHT NOW. A record whose window has
239
+ // passed belongs to a provider quota that has come back, and
240
+ // failing over on it moves a live route off a model that works.
241
+ const routes = unhealthyRoutes(project)
75
242
  const unhealthy = unhealthyModels(project)
76
243
 
77
244
  // config.harness is stripped by schema validation, so the active
@@ -94,8 +261,15 @@ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
94
261
  // overwriting the lanes here would collapse ten jobs onto one
95
262
  // model and throw away the chain that was supposed to survive the
96
263
  // outage. Only the session model moves.
97
- const baseKey = `opencode/${profile.model}`
98
- if (health.unhealthy?.[baseKey]) {
264
+ //
265
+ // The key is the profile's OWN route, qualified with the profile's
266
+ // provider - the same key `routeKey` builds for the event that
267
+ // filed the failure. A hardcoded "opencode/" prefix was a
268
+ // different string from the one the failure was filed under for
269
+ // every profile whose provider is not "opencode", so the check
270
+ // could never fire for them.
271
+ const baseKey = routeKey(profile.provider, profile.model)
272
+ if (routes[baseKey]) {
99
273
  const queue = [id, ...(profile.fallback ?? [])]
100
274
  for (const candidate of queue) {
101
275
  let fallback
@@ -107,8 +281,8 @@ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
107
281
  // The health file is keyed by MODEL, and a fleet's profile id is
108
282
  // not its model - so the check has to be against the model's own
109
283
  // key. Checking the id was a check that could never fire.
110
- const key = `opencode/${fallback.model}`
111
- if (health.unhealthy?.[key]) continue
284
+ const key = routeKey(fallback.provider, fallback.model)
285
+ if (routes[key]) continue
112
286
  const fleet = isFleet(profile)
113
287
  applySessionModel(cfg, fallback, {
114
288
  keepLanes: fleet,
@@ -124,6 +298,11 @@ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
124
298
  }
125
299
  }
126
300
 
301
+ // The route this session runs on, for a failure that arrives without
302
+ // one. After the failover above, so it names the route the session
303
+ // actually got and not the one it asked for.
304
+ configuredRoute = routeOfConfig(cfg) ?? routeKey(profile.provider, profile.model)
305
+
127
306
  // 2. Every lane. A lane whose model is spent moves to the next rung of
128
307
  // its own chain, so one dead route costs one lane, not the fleet.
129
308
  const roles = resolveRoles(profile, { unhealthy })
@@ -133,7 +312,10 @@ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
133
312
  if (!agent) continue
134
313
  const resolved = resolveLaneModel(profile, lane, { unhealthy })
135
314
  if (resolved.model === profile.model && !profile.roleModels?.[lane]) continue
136
- const wantModel = `opencode/${resolved.model}`
315
+ // The lane's model is written with the PROFILE's provider, the
316
+ // same way the config compiler writes it. The hardcoded "opencode/"
317
+ // prefix put a lane on a route the provider does not serve.
318
+ const wantModel = routeKey(profile.provider, resolved.model)
137
319
  if (agent.model !== wantModel) {
138
320
  agent.model = wantModel
139
321
  // The variant has to be resolved against the model the lane ended
@@ -155,20 +337,38 @@ export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
155
337
  const type = e?.type
156
338
  if (!type) return
157
339
 
158
- // The event is opencode's object, and it can hold a reference back to
159
- // itself. A plain JSON.stringify on a cyclic object throws, and a hook
160
- // that throws takes the event down with it.
161
- const text = safeStringify(e)
162
- const reason = classify(text)
340
+ // Remember the route this session is on. `session.error` carries no
341
+ // model at all, so the failure has to be attributed to the route the
342
+ // session was last seen on, or to the one it was configured with.
343
+ const seen = routeOf(e)
344
+ if (seen) {
345
+ lastRoute = seen
346
+ const sessionID = sessionIdOf(e)
347
+ if (sessionID) sessionRoutes.set(sessionID, seen)
348
+ }
349
+
350
+ // Only the PROVIDER ERROR is classified. The event itself carries the
351
+ // message, its token counts and the tool output, and none of that says
352
+ // anything about the route.
353
+ const err = errorOf(e)
354
+ if (!err) return
355
+ const reason = classifyError(err)
163
356
  if (!reason) return
164
357
 
165
- // Only mark the model this session is actually using.
166
- const modelID = e.properties?.modelID ?? e.properties?.model ?? e.properties?.modelID
167
- const key = modelID ? `opencode/${modelID}` : undefined
358
+ // The route the failure belongs to: the event's own, else the route
359
+ // this session was last seen on, else the route it was configured
360
+ // with. The last route seen anywhere is the last resort, because in a
361
+ // process with more than one session it belongs to a different job.
362
+ const sessionID = sessionIdOf(e)
363
+ const key =
364
+ seen ??
365
+ (sessionID ? sessionRoutes.get(sessionID) : undefined) ??
366
+ configuredRoute ??
367
+ lastRoute
168
368
  if (!key) return
169
369
 
170
370
  const health = readHealth(project)
171
- if (health.unhealthy[key]) return // already recorded, do not spam
371
+ if (unhealthyRoutes(project)[key]) return // already recorded, do not spam
172
372
  health.unhealthy[key] = { at: new Date().toISOString(), reason }
173
373
  writeHealth(project, health)
174
374
  failedSessions++