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/CHANGELOG.md +165 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +452 -68
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +18 -7
- package/eval/measure-prune.mjs +13 -4
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +46 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +329 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/trust.js +182 -54
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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 =
|
|
111
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
const
|
|
162
|
-
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
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 (
|
|
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++
|