@lawaty/lacode 2.3.0-beta.5 → 2.3.0-beta.6
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 +25 -0
- package/README.md +13 -5
- package/dist/agents.js +1 -0
- package/dist/commands.js +6 -3
- package/dist/index.js +329 -111
- package/dist/lacode.js +1 -0
- package/dist/pool.js +149 -67
- package/dist/tunnel.js +1 -0
- package/docs/CONFIGURATION.md +15 -4
- package/docs/USER-STORIES.md +1 -1
- package/package.json +1 -1
- package/presets/README.md +35 -16
- package/presets/free-tier.json +17 -9
- package/scripts/pool-selection.mjs +161 -19
- package/scripts/refresh-pool.mjs +62 -20
- package/scripts/score-models.mjs +58 -21
|
@@ -19,6 +19,13 @@ export const STATED_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000
|
|
|
19
19
|
// for a gateway hiccup or a short provider outage to clear, short enough that a
|
|
20
20
|
// recovered model is back well before the 6-hour timer.
|
|
21
21
|
export const SYNTHETIC_RETRY_MS = 45 * 60 * 1000
|
|
22
|
+
// A model that fails transiently scan after scan is not having a bad moment; it
|
|
23
|
+
// is a bad slot. Keep it (benched in place) for this many consecutive scans, then
|
|
24
|
+
// let it vacate the pool. A successful scan resets the streak, so a model that
|
|
25
|
+
// genuinely recovers returns on the next run. This is what stops a model that
|
|
26
|
+
// times out on every single request from being retained forever because the
|
|
27
|
+
// failure kind is nominally "transient".
|
|
28
|
+
export const MAX_TRANSIENT_STREAK = 3
|
|
22
29
|
|
|
23
30
|
// Scan order, not selection order: the pool's core providers are probed first so
|
|
24
31
|
// a wall-clock budget can never starve them behind a slow paid catalogue. This is
|
|
@@ -35,7 +42,7 @@ export const providerRank = (provider) => {
|
|
|
35
42
|
// classifier existed or by a provider that gives only prose.
|
|
36
43
|
const FAILURE_KINDS = [
|
|
37
44
|
["agreement", /model\s+agreement|terms\s+of\s+(?:service|use)|\btos\b/i],
|
|
38
|
-
["credit", /out\s+of\s+credits|insufficient\s+credits|credit\s+balance|requires\s+more\s+credits|payment\s+required|billing|purchase|\b402\b/i],
|
|
45
|
+
["credit", /out\s+of\s+credits|insufficient\s+credits|credit\s+balance|requires\s+more\s+credits|payment\s+required|billing|purchase|budget\s+exhausted|api\s+budget|quota\s+exhausted|\b402\b/i],
|
|
39
46
|
["rate-limited", /rate[\s_-]?limit|usage\s+limit|too\s+many\s+requests|overloaded|limits?[\s_-]?(?:reached|exceeded)|free[-\s]?tier|freelimiterror|quota|\b429\b/i],
|
|
40
47
|
["credential", /\b401\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]?api[\s_-]?key/i],
|
|
41
48
|
["not-served", /\b404\b|no\s+such\s+model|model\s+not\s+found|does\s+not\s+exist|is\s+not\s+available|not\s+served|not\s+found/i],
|
|
@@ -44,10 +51,12 @@ const FAILURE_KINDS = [
|
|
|
44
51
|
["server", /\b5\d\d\b|internal\s+server|bad\s+gateway|service\s+unavailable|gateway\s+timeout/i],
|
|
45
52
|
]
|
|
46
53
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
54
|
+
// An empty balance is not a bad moment: the provider has said it will not serve
|
|
55
|
+
// this account until someone tops it up (or the reset date arrives). Retaining
|
|
56
|
+
// the slot only benches a seat forever, so credit exhaustion vacates it like a
|
|
57
|
+
// missing model. A later scan that answers re-admits it automatically. Rate
|
|
58
|
+
// limits and outages are the kinds that stay retained.
|
|
59
|
+
const TRANSIENT_KINDS = new Set(["rate-limited", "timeout", "network", "server"])
|
|
51
60
|
|
|
52
61
|
/**
|
|
53
62
|
* Failure kinds that vacate a pool slot for good: the model is gone, the
|
|
@@ -134,24 +143,89 @@ const rowsOf = (providers) =>
|
|
|
134
143
|
|
|
135
144
|
const providerOf = (model) => String(model ?? "").split("/")[0]
|
|
136
145
|
|
|
146
|
+
// Which providers spend ONE shared quota window across their free models.
|
|
147
|
+
// `opencode` (Zen) is the known case: every free model draws from the same free
|
|
148
|
+
// bucket, which is why the primary selector tags it `b:opencode`. `opencode-go`
|
|
149
|
+
// is known to publish a per-model window. For any other provider the scorer
|
|
150
|
+
// cannot claim two models are independent, so fallback disjointness treats the
|
|
151
|
+
// provider as one shared window (the safe direction). The primary selection is
|
|
152
|
+
// unchanged unless a bucket is explicit or the provider is a known shared one.
|
|
153
|
+
export const SHARED_PROVIDER_BUCKETS = { opencode: "opencode" }
|
|
154
|
+
const PER_MODEL_PROVIDERS = new Set(["opencode-go"])
|
|
155
|
+
|
|
156
|
+
/** The quota group a candidate actually spends: declared, known-shared, or its own model. */
|
|
157
|
+
export function effectiveBucketFor(model, declared) {
|
|
158
|
+
if (declared) return declared
|
|
159
|
+
const provider = providerOf(model)
|
|
160
|
+
if (PER_MODEL_PROVIDERS.has(provider)) return model
|
|
161
|
+
return SHARED_PROVIDER_BUCKETS[provider] ?? provider
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The bucket that must be written into pool.txt for a slot, when it is a known shared one. */
|
|
165
|
+
export function bucketToWriteFor(model, declared) {
|
|
166
|
+
return declared ?? SHARED_PROVIDER_BUCKETS[providerOf(model)]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** How many disjoint models the secondary pool keeps. */
|
|
170
|
+
export const FALLBACK_POOL_SIZE = 5
|
|
171
|
+
// Fallback seats are real capacity, not a token gesture: give each enough weight
|
|
172
|
+
// to run work, and allow up to two models from one fallback provider so a
|
|
173
|
+
// provider with several distinct models is not reduced to a single w1 seat.
|
|
174
|
+
export const FALLBACK_WEIGHT = 2
|
|
175
|
+
const MAX_FALLBACK_PER_PROVIDER = 2
|
|
176
|
+
|
|
177
|
+
// The fallback-section marker inside pool.txt, shared with the runtime parser.
|
|
178
|
+
const FALLBACK_MARKER = /^(?:#\s*-*\s*fallback(?:\s+slots)?\s*-*|\[fallback\])$/i
|
|
179
|
+
|
|
137
180
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
181
|
+
* Read `# exclude-provider: name[,name]` directives from a pool.txt header.
|
|
182
|
+
*
|
|
183
|
+
* A listed provider is not probed at all and is removed from the primary pool,
|
|
184
|
+
* the fallback pool, and the retention source. The directive lives in the
|
|
185
|
+
* header, which refresh preserves, so a provider whose account is out of budget
|
|
186
|
+
* (or otherwise unwanted) stays out across regenerations without hand-editing
|
|
187
|
+
* a generated file. Removing the line is all it takes to bring it back.
|
|
188
|
+
*/
|
|
189
|
+
export function parsePoolDirectives(text) {
|
|
190
|
+
const excluded = new Set()
|
|
191
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
192
|
+
const match = /^#\s*exclude-provider\s*:\s*(.+)$/i.exec(line.trim())
|
|
193
|
+
if (!match) continue
|
|
194
|
+
for (const name of match[1].split(/[,\s]+/)) {
|
|
195
|
+
const provider = name.trim()
|
|
196
|
+
if (provider) excluded.add(provider)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return excluded
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parse pool.txt slot lines into [{ model, weight, bucket?, context?, ceiling?, fallback? }].
|
|
204
|
+
* Comments and blanks are ignored; weight defaults to 1. A line that is exactly
|
|
205
|
+
* `# --- fallback ---` (or `[fallback]`) starts the fallback section.
|
|
140
206
|
*/
|
|
141
207
|
export function parsePoolSlots(text) {
|
|
142
208
|
const out = []
|
|
209
|
+
let section = "primary"
|
|
143
210
|
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
211
|
+
if (FALLBACK_MARKER.test(line.trim())) {
|
|
212
|
+
section = "fallback"
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
144
215
|
const code = line.split("#")[0].trim()
|
|
145
216
|
if (!code || !code.includes("/")) continue
|
|
146
217
|
const [model, weight] = code.split(/\s+/)
|
|
147
218
|
if (!model || !model.includes("/")) continue
|
|
148
219
|
const bucket = /b:([^\s]+)/.exec(code)?.[1]
|
|
149
220
|
const context = /c:(\d+)/.exec(code)?.[1]
|
|
221
|
+
const ceiling = /ceil:(\d+)/.exec(code)?.[1]
|
|
150
222
|
out.push({
|
|
151
223
|
model,
|
|
152
224
|
weight: Number(weight) > 0 ? Number(weight) : 1,
|
|
225
|
+
...(ceiling ? { ceiling: Number(ceiling) } : {}),
|
|
153
226
|
...(bucket ? { bucket } : {}),
|
|
154
227
|
context: context ? Number(context) : null,
|
|
228
|
+
...(section === "fallback" ? { fallback: true } : {}),
|
|
155
229
|
})
|
|
156
230
|
}
|
|
157
231
|
return out
|
|
@@ -166,15 +240,18 @@ export function parsePoolSlots(text) {
|
|
|
166
240
|
*/
|
|
167
241
|
export function poolStructureValid(slots) {
|
|
168
242
|
const problems = []
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
243
|
+
const parsed =
|
|
244
|
+
typeof slots === "string"
|
|
245
|
+
? parsePoolSlots(slots)
|
|
246
|
+
: Array.isArray(slots)
|
|
247
|
+
? slots.flatMap((entry) => (typeof entry === "string" ? parsePoolSlots(entry) : [entry]))
|
|
248
|
+
: []
|
|
249
|
+
const lines = parsed.map((entry) => ({
|
|
250
|
+
model: entry?.model ?? "",
|
|
251
|
+
weight: Number(entry?.weight) || 0,
|
|
252
|
+
bucket: entry?.bucket,
|
|
253
|
+
fallback: entry?.fallback === true,
|
|
254
|
+
})).filter((entry) => entry.model.includes("/") && !entry.fallback)
|
|
178
255
|
const go = lines.filter((entry) => providerOf(entry.model) === "opencode-go")
|
|
179
256
|
const tails = lines.filter((entry) => providerOf(entry.model) === "opencode")
|
|
180
257
|
if (go.length === 0) problems.push("missing opencode-go head")
|
|
@@ -223,7 +300,11 @@ export function selectPool({
|
|
|
223
300
|
typeof row.score === "number" &&
|
|
224
301
|
Number.isFinite(row.score) &&
|
|
225
302
|
row.score >= minScore &&
|
|
226
|
-
(row.context == null || row.context >= minContext)
|
|
303
|
+
(row.context == null || row.context >= minContext) &&
|
|
304
|
+
// A stale success carried beside a current failure is rankable only while the
|
|
305
|
+
// failure streak is short. Once the model has failed MAX_TRANSIENT_STREAK
|
|
306
|
+
// scans in a row, the old score stops being evidence about the present.
|
|
307
|
+
Number(row?.failStreak ?? 0) < MAX_TRANSIENT_STREAK
|
|
227
308
|
const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
|
|
228
309
|
const nowMs = typeof now === "function" ? Number(now()) : Number(now)
|
|
229
310
|
const normalize = (row) => {
|
|
@@ -236,6 +317,14 @@ export function selectPool({
|
|
|
236
317
|
const rows = rowsOf(providers).map(normalize)
|
|
237
318
|
const previousRows = rowsOf(previousProviders)
|
|
238
319
|
const currentByModel = new Map(rows.map((row) => [row.model, row]))
|
|
320
|
+
// A ceiling is the slot's hard concurrency cap (max parallel sessions). It is
|
|
321
|
+
// carried from the current pool through the report so a refresh never silently
|
|
322
|
+
// drops it; only a ceiling that still fits the slot's weight is kept.
|
|
323
|
+
const ceilingByModel = new Map(
|
|
324
|
+
(Array.isArray(currentSlots) ? currentSlots : [])
|
|
325
|
+
.filter((slot) => slot && typeof slot.model === "string" && Number.isFinite(Number(slot.ceiling)))
|
|
326
|
+
.map((slot) => [slot.model, Number(slot.ceiling)]),
|
|
327
|
+
)
|
|
239
328
|
const scannedProviders = new Set(
|
|
240
329
|
(Array.isArray(providers) ? providers : []).map((entry) => String(entry?.provider ?? "")),
|
|
241
330
|
)
|
|
@@ -336,7 +425,10 @@ export function selectPool({
|
|
|
336
425
|
const shouldRetain = (model) => {
|
|
337
426
|
if (!model.includes("/") || placedModels.has(model)) return false
|
|
338
427
|
const row = rowByModel.get(model)
|
|
339
|
-
|
|
428
|
+
// Retain a failed member only while its consecutive-failure streak is short.
|
|
429
|
+
// Past the cap the slot has proved it cannot serve; leaving it in the pool
|
|
430
|
+
// just benches a seat every refresh and hands work to it between refreshes.
|
|
431
|
+
if (row) return row.ok === false && !isHardDeadRow(row) && Number(row.failStreak ?? 0) < MAX_TRANSIENT_STREAK
|
|
340
432
|
return !cleanlyScanned(model)
|
|
341
433
|
}
|
|
342
434
|
const retainedHead = []
|
|
@@ -377,6 +469,34 @@ export function selectPool({
|
|
|
377
469
|
]
|
|
378
470
|
const tail = pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null }))
|
|
379
471
|
let selection = [...head, ...middle, ...tail]
|
|
472
|
+
// The report carries each slot's ceiling as a fact: a configured ceiling from
|
|
473
|
+
// the current pool survives regeneration, and a slot with none stays weight-only.
|
|
474
|
+
selection = selection.map((entry) => {
|
|
475
|
+
const ceiling = ceilingByModel.get(entry.model)
|
|
476
|
+
return ceiling !== undefined && ceiling >= Number(entry.weight) ? { ...entry, ceiling } : entry
|
|
477
|
+
})
|
|
478
|
+
// Two provider ids can front the same upstream model (an `openrouter` and a
|
|
479
|
+
// `console-openrouter` alias, for example). They draw on one quota window, so
|
|
480
|
+
// giving both a slot burns capacity twice and both fall over together. When
|
|
481
|
+
// the provider names are aliases (one is a suffix of the other) and the model
|
|
482
|
+
// id past the provider prefix is identical, keep only the first, score-ranked
|
|
483
|
+
// occurrence. A plain `provider/model` id is never collapsed, so different
|
|
484
|
+
// providers serving the same model name keep their independent slots.
|
|
485
|
+
const upstreamOf = (model) => {
|
|
486
|
+
const slash = String(model).indexOf("/")
|
|
487
|
+
const upstream = slash < 0 ? "" : String(model).slice(slash + 1)
|
|
488
|
+
return upstream.includes("/") ? upstream : ""
|
|
489
|
+
}
|
|
490
|
+
const seenUpstream = new Map()
|
|
491
|
+
selection = selection.filter((entry) => {
|
|
492
|
+
const upstream = upstreamOf(entry.model)
|
|
493
|
+
if (!upstream) return true
|
|
494
|
+
const provider = providerOf(entry.model)
|
|
495
|
+
const prior = seenUpstream.get(upstream)
|
|
496
|
+
if (prior && (provider.endsWith(prior) || prior.endsWith(provider))) return false
|
|
497
|
+
seenUpstream.set(upstream, provider)
|
|
498
|
+
return true
|
|
499
|
+
})
|
|
380
500
|
// Fit the contract size without ever vacating head or tail: trim the middle
|
|
381
501
|
// from the back (lowest score first), skipping retained slots. If even that
|
|
382
502
|
// cannot fit, the head wins and a warning says so.
|
|
@@ -406,6 +526,28 @@ export function selectPool({
|
|
|
406
526
|
)
|
|
407
527
|
}
|
|
408
528
|
|
|
529
|
+
// A fallback pool for the case where every primary slot is cooling at once.
|
|
530
|
+
// Candidates are score-ranked and must spend a different quota window from
|
|
531
|
+
// every primary slot: a same-bucket model would be benched by the same limit,
|
|
532
|
+
// so it cannot serve as a fallback. A provider may contribute up to
|
|
533
|
+
// MAX_FALLBACK_PER_PROVIDER distinct models; a shared bucket is written only
|
|
534
|
+
// when it is a known fact (Zen).
|
|
535
|
+
const selectedModels = new Set(selection.map((entry) => entry.model))
|
|
536
|
+
const primaryBuckets = new Set(selection.map((entry) => effectiveBucketFor(entry.model, entry.bucket)))
|
|
537
|
+
const fallbackSelection = []
|
|
538
|
+
const fallbackCounts = new Map()
|
|
539
|
+
for (const row of merge(() => true)) {
|
|
540
|
+
if (fallbackSelection.length >= FALLBACK_POOL_SIZE) break
|
|
541
|
+
if (!rankable(row) || selectedModels.has(row.model)) continue
|
|
542
|
+
if (primaryBuckets.has(effectiveBucketFor(row.model, undefined))) continue
|
|
543
|
+
const provider = providerOf(row.model)
|
|
544
|
+
const count = fallbackCounts.get(provider) ?? 0
|
|
545
|
+
if (count >= MAX_FALLBACK_PER_PROVIDER) continue
|
|
546
|
+
fallbackCounts.set(provider, count + 1)
|
|
547
|
+
const bucket = bucketToWriteFor(row.model, undefined)
|
|
548
|
+
fallbackSelection.push({ model: row.model, weight: FALLBACK_WEIGHT, ...(bucket ? { bucket } : {}), context: row.context ?? null })
|
|
549
|
+
}
|
|
550
|
+
|
|
409
551
|
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
410
552
|
if (entry?.error) {
|
|
411
553
|
warnings.push(`${entry.provider}: ${entry.error}`)
|
|
@@ -441,5 +583,5 @@ export function selectPool({
|
|
|
441
583
|
}
|
|
442
584
|
: undefined
|
|
443
585
|
|
|
444
|
-
return { selection, warnings, retry }
|
|
586
|
+
return { selection, fallback: fallbackSelection, warnings, retry }
|
|
445
587
|
}
|
package/scripts/refresh-pool.mjs
CHANGED
|
@@ -60,6 +60,11 @@ const REFRESH_LOCK_STALE_MS = 60 * 60 * 1000
|
|
|
60
60
|
*/
|
|
61
61
|
function acquireRefreshLock() {
|
|
62
62
|
const take = () => {
|
|
63
|
+
// Ensure the parent exists (a fresh HOME, or the stable systemd copy before
|
|
64
|
+
// the plugin has ever started) but keep the lock directory's own creation
|
|
65
|
+
// non-recursive: mkdir with `recursive` succeeds on an existing directory,
|
|
66
|
+
// which would let two refreshes run at once.
|
|
67
|
+
mkdirSync(dirname(REFRESH_LOCK_DIR), { recursive: true })
|
|
63
68
|
mkdirSync(REFRESH_LOCK_DIR)
|
|
64
69
|
writeFileSync(join(REFRESH_LOCK_DIR, "pid"), String(process.pid))
|
|
65
70
|
return true
|
|
@@ -99,6 +104,9 @@ const isSlotLine = (line) => {
|
|
|
99
104
|
return stripped.length > 0 && stripped.includes("/")
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
/** The marker that starts the fallback section; shared with src/lib/pool.ts. */
|
|
108
|
+
export const FALLBACK_MARKER = "# --- fallback ---"
|
|
109
|
+
|
|
102
110
|
function splitHeaderAndSlots(text) {
|
|
103
111
|
const lines = String(text ?? "").split(/\r?\n/)
|
|
104
112
|
// Drop a single trailing empty element produced by a final newline so we can
|
|
@@ -120,11 +128,17 @@ function refreshComment(now, scoresGeneratedAt) {
|
|
|
120
128
|
|
|
121
129
|
function withRefreshComment(headerLines, comment) {
|
|
122
130
|
const kept = headerLines.filter((line) => !/^#\s*Refreshed\b/.test(String(line)))
|
|
131
|
+
// The header includes the blank separator before the slots, so trim trailing
|
|
132
|
+
// blanks or every run would prepend one more and the file would grow an
|
|
133
|
+
// unbounded run of empty lines.
|
|
134
|
+
while (kept.length > 0 && String(kept[kept.length - 1]).trim() === "") kept.pop()
|
|
123
135
|
return [...kept, comment]
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
function formatGeneratedLine(model, weight, bucket, context) {
|
|
138
|
+
function formatGeneratedLine(model, weight, bucket, context, ceiling) {
|
|
127
139
|
const parts = [String(model), String(weight)]
|
|
140
|
+
// A ceiling equal to the weight is the default (one pass), so it is not printed.
|
|
141
|
+
if (Number.isFinite(Number(ceiling)) && Number(ceiling) > Number(weight)) parts.push(`ceil:${Math.round(Number(ceiling))}`)
|
|
128
142
|
if (context != null && Number.isFinite(Number(context))) parts.push(`c:${Math.round(Number(context))}`)
|
|
129
143
|
if (bucket) parts.push(`b:${bucket}`)
|
|
130
144
|
return parts.join(" ")
|
|
@@ -141,29 +155,38 @@ export function buildPool(input) {
|
|
|
141
155
|
if (!scores || typeof scores !== "object" || !Array.isArray(scores.providers) || !Array.isArray(scores.selection)) {
|
|
142
156
|
throw new Error("score file is missing or invalid (expected { providers: [...], selection: [...] })")
|
|
143
157
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
158
|
+
const validSelection = (entry) =>
|
|
159
|
+
entry &&
|
|
160
|
+
typeof entry.model === "string" &&
|
|
161
|
+
entry.model.includes("/") &&
|
|
162
|
+
typeof entry.weight === "number" &&
|
|
163
|
+
Number.isFinite(entry.weight) &&
|
|
164
|
+
entry.weight > 0
|
|
165
|
+
const selection = scores.selection.filter(validSelection)
|
|
153
166
|
if (selection.length === 0) {
|
|
154
167
|
throw new Error("score file has no selected models; refusing to write pool.txt")
|
|
155
168
|
}
|
|
169
|
+
// A score file written before fallback/ceiling support has no such fields.
|
|
170
|
+
// Treat "absent" as "no opinion" and carry the configured facts forward, so a
|
|
171
|
+
// refresh cannot silently delete a hand-written fallback or ceiling.
|
|
172
|
+
const currentSlots = parsePoolSlots(input?.currentPoolText ?? "")
|
|
173
|
+
const currentByModel = new Map(currentSlots.map((slot) => [slot.model, slot]))
|
|
174
|
+
const fallbackProvided = Array.isArray(scores.fallback)
|
|
175
|
+
const fallback = (fallbackProvided ? scores.fallback : currentSlots.filter((slot) => slot.fallback)).filter(validSelection)
|
|
176
|
+
const ceilingOf = (entry) => entry.ceiling ?? currentByModel.get(entry.model)?.ceiling
|
|
156
177
|
const warnings = Array.isArray(scores.warnings) ? scores.warnings : []
|
|
157
178
|
const { header } = splitHeaderAndSlots(input?.currentPoolText ?? "")
|
|
158
|
-
const outSlots = selection.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context))
|
|
179
|
+
const outSlots = selection.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context, ceilingOf(entry)))
|
|
180
|
+
const outFallback = fallback.map((entry) => formatGeneratedLine(entry.model, entry.weight, entry.bucket, entry.context, ceilingOf(entry)))
|
|
159
181
|
const comment = refreshComment(input?.now ?? new Date(), scores.generatedAt)
|
|
160
182
|
const outHeader = withRefreshComment(header, comment)
|
|
161
|
-
const text = [...outHeader, "", ...outSlots, ""].join("\n")
|
|
183
|
+
const text = [...outHeader, "", ...outSlots, ...(outFallback.length > 0 ? ["", FALLBACK_MARKER, ...outFallback] : []), ""].join("\n")
|
|
162
184
|
return {
|
|
163
185
|
text,
|
|
164
186
|
slots: outSlots,
|
|
187
|
+
fallback: outFallback,
|
|
165
188
|
warnings,
|
|
166
|
-
chosen: { selected: selection.map((entry) => entry.model) },
|
|
189
|
+
chosen: { selected: selection.map((entry) => entry.model), fallback: fallback.map((entry) => entry.model) },
|
|
167
190
|
}
|
|
168
191
|
}
|
|
169
192
|
|
|
@@ -240,7 +263,7 @@ function runCanary({ currentPoolText, scorerArgs, max }) {
|
|
|
240
263
|
* Count the usable model slots in a pool.txt text (comments and blanks ignored).
|
|
241
264
|
*/
|
|
242
265
|
export function poolSlotCount(text) {
|
|
243
|
-
return
|
|
266
|
+
return parsePoolSlots(text).filter((slot) => !slot.fallback).length
|
|
244
267
|
}
|
|
245
268
|
|
|
246
269
|
/**
|
|
@@ -249,15 +272,21 @@ export function poolSlotCount(text) {
|
|
|
249
272
|
* the moment a probe watches it answer.
|
|
250
273
|
*/
|
|
251
274
|
export const SCAN_BENCH_REASON = "scan-kept"
|
|
252
|
-
|
|
275
|
+
// Bench a kept-but-failing member until the next scheduled refresh. The timer
|
|
276
|
+
// runs every six hours; a 45-minute bench left the slot routable for most of the
|
|
277
|
+
// cycle, so it kept failing spawns between refreshes. The cooldown probe still
|
|
278
|
+
// re-admits the model as soon as it watches it answer, so a real recovery is not
|
|
279
|
+
// made to wait for the timer.
|
|
280
|
+
export const SCAN_BENCH_WINDOW_MS = 6 * 60 * 60 * 1000
|
|
253
281
|
export const scanBenchDir = (home = HOME) => join(home, ".local", "share", "opencode", "agent-pool")
|
|
254
282
|
export const scanBenchFile = (model) => `limit.${String(model).replace(/[^a-zA-Z0-9._-]/g, "__")}.json`
|
|
255
283
|
|
|
256
284
|
/**
|
|
257
285
|
* Pure bench plan for the current pool members. A member whose current row
|
|
258
|
-
* failed transiently is benched until its stated reset (else
|
|
259
|
-
* member that answered has any stale bench cleared; an
|
|
260
|
-
* left alone for lack of evidence. Only pool members are
|
|
286
|
+
* failed transiently is benched until its stated reset (else until the next
|
|
287
|
+
* scheduled refresh); a member that answered has any stale bench cleared; an
|
|
288
|
+
* unscanned member is left alone for lack of evidence. Only pool members are
|
|
289
|
+
* ever touched.
|
|
261
290
|
*/
|
|
262
291
|
export function computeScanBench({ selectionModels, providers, now = Date.now() }) {
|
|
263
292
|
const currentByModel = new Map()
|
|
@@ -302,8 +331,21 @@ export function applyScanBench({ dir, bench, clear }) {
|
|
|
302
331
|
} catch {}
|
|
303
332
|
}
|
|
304
333
|
for (const model of clear ?? []) {
|
|
334
|
+
const path = join(dir, scanBenchFile(model))
|
|
305
335
|
try {
|
|
306
|
-
|
|
336
|
+
// Clear only the bench THIS scan wrote. A runtime strike
|
|
337
|
+
// (`rate-limited`, `no answer`, `provider error`, ...) is evidence from a
|
|
338
|
+
// real spawn and carries the escalation count; deleting it because a
|
|
339
|
+
// probe happened to answer would let a model that fails every real
|
|
340
|
+
// request be re-admitted every refresh. The runtime owns that record.
|
|
341
|
+
let reason
|
|
342
|
+
try {
|
|
343
|
+
reason = JSON.parse(readFileSync(path, "utf8"))?.reason
|
|
344
|
+
} catch {
|
|
345
|
+
continue // nothing on disk to clear
|
|
346
|
+
}
|
|
347
|
+
if (reason !== SCAN_BENCH_REASON) continue
|
|
348
|
+
rmSync(path, { force: true })
|
|
307
349
|
applied.cleared.push(model)
|
|
308
350
|
} catch {}
|
|
309
351
|
}
|
|
@@ -335,7 +377,7 @@ export function shouldKeepPool({ currentPoolText, newSlots }) {
|
|
|
335
377
|
reason: `new selection breaks the pool structure (${newCheck.problems.join("; ")}); keeping the previous pool`,
|
|
336
378
|
}
|
|
337
379
|
}
|
|
338
|
-
if (slots.filter(
|
|
380
|
+
if (parsePoolSlots(slots.join("\n")).filter((slot) => !slot.fallback).length >= threshold) return { keep: false, prev, threshold, reason: "" }
|
|
339
381
|
return {
|
|
340
382
|
keep: true,
|
|
341
383
|
prev,
|
package/scripts/score-models.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
classifyFailure,
|
|
47
47
|
isTransientFailure,
|
|
48
48
|
isTransientKind,
|
|
49
|
+
parsePoolDirectives,
|
|
49
50
|
parsePoolSlots,
|
|
50
51
|
providerRank,
|
|
51
52
|
retryAfterMs,
|
|
@@ -589,7 +590,20 @@ const pad = (s, n) => String(s).padEnd(n)
|
|
|
589
590
|
const padL = (s, n) => String(s).padStart(n)
|
|
590
591
|
|
|
591
592
|
async function main() {
|
|
593
|
+
const currentPoolText = (() => {
|
|
594
|
+
if (!currentPoolPath) return ""
|
|
595
|
+
try {
|
|
596
|
+
return readFileSync(currentPoolPath, "utf8")
|
|
597
|
+
} catch {
|
|
598
|
+
return ""
|
|
599
|
+
}
|
|
600
|
+
})()
|
|
601
|
+
const excludedProviders = parsePoolDirectives(currentPoolText)
|
|
592
602
|
let scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
|
|
603
|
+
if (excludedProviders.size > 0) {
|
|
604
|
+
scanned = scanned.filter((entry) => !excludedProviders.has(entry.provider))
|
|
605
|
+
if (scanned.length === 0) console.log(`every provider was excluded by pool.txt directives; nothing to probe`)
|
|
606
|
+
}
|
|
593
607
|
// Core providers first, then the free-only middle tier, then the slow paid
|
|
594
608
|
// catalogues — and explicitly deprioritized providers last. Ordering only;
|
|
595
609
|
// every provider is still probed if time allows.
|
|
@@ -599,14 +613,9 @@ async function main() {
|
|
|
599
613
|
providerRank(a.provider) - providerRank(b.provider) ||
|
|
600
614
|
String(a.provider).localeCompare(String(b.provider)),
|
|
601
615
|
)
|
|
602
|
-
const currentSlots = ((
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
return parsePoolSlots(readFileSync(currentPoolPath, "utf8"))
|
|
606
|
-
} catch {
|
|
607
|
-
return []
|
|
608
|
-
}
|
|
609
|
-
})()
|
|
616
|
+
const currentSlots = parsePoolSlots(currentPoolText).filter(
|
|
617
|
+
(slot) => !excludedProviders.has(String(slot.model).split("/")[0]),
|
|
618
|
+
)
|
|
610
619
|
if (probeModels && probeModels.size > 0) {
|
|
611
620
|
const seen = new Set()
|
|
612
621
|
scanned = scanned.flatMap((entry) => {
|
|
@@ -662,6 +671,12 @@ async function main() {
|
|
|
662
671
|
outcome = await probe(candidate, entry.provider)
|
|
663
672
|
}
|
|
664
673
|
const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
|
|
674
|
+
// Only cheap-failure kinds that also stall the scan count toward the
|
|
675
|
+
// breaker. A rate limit is deliberately NOT included: a 429 answers in
|
|
676
|
+
// milliseconds, so truncating on it saves little, and it would leave the
|
|
677
|
+
// provider's pool members unscanned -- which means no current failure row,
|
|
678
|
+
// so their consecutive-failure streak could never advance and they could
|
|
679
|
+
// never retire. Probe every model and record the evidence instead.
|
|
665
680
|
const providerFault = !outcome.ok && ["timeout", "network", "server"].includes(kind)
|
|
666
681
|
consecutiveTransient = providerFault ? consecutiveTransient + 1 : 0
|
|
667
682
|
const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
|
|
@@ -764,15 +779,19 @@ async function main() {
|
|
|
764
779
|
return null
|
|
765
780
|
}
|
|
766
781
|
})()
|
|
767
|
-
const { selection, warnings, retry } = selectPool({
|
|
768
|
-
providers: results
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
782
|
+
const { selection, fallback, warnings, retry } = selectPool({
|
|
783
|
+
providers: results
|
|
784
|
+
.filter((entry) => !excludedProviders.has(entry.provider))
|
|
785
|
+
.map((entry) => ({
|
|
786
|
+
provider: entry.provider,
|
|
787
|
+
error: entry.error,
|
|
788
|
+
truncated: entry.truncated,
|
|
789
|
+
rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
|
|
790
|
+
excluded: entry.excluded,
|
|
791
|
+
})),
|
|
792
|
+
previousProviders: (previousScores?.providers ?? []).filter(
|
|
793
|
+
(entry) => !excludedProviders.has(String(entry?.provider ?? "")),
|
|
794
|
+
),
|
|
776
795
|
currentSlots,
|
|
777
796
|
minContext,
|
|
778
797
|
minScore,
|
|
@@ -785,6 +804,14 @@ async function main() {
|
|
|
785
804
|
console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
|
|
786
805
|
console.log(`# scored ${new Date().toISOString()} — opencode-go first (weight 4), small-context providers (weight 2), large-context providers (weight 1), opencode last (max 1)`)
|
|
787
806
|
for (const entry of selection) {
|
|
807
|
+
const ceiling = Number.isFinite(entry.ceiling) && entry.ceiling > entry.weight ? ` ceil:${entry.ceiling}` : ""
|
|
808
|
+
const bucket = entry.bucket ? ` b:${entry.bucket}` : ""
|
|
809
|
+
console.log(`${pad(entry.model, 60)}${entry.weight}${ceiling}${bucket}`)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (fallback.length) {
|
|
813
|
+
console.log(`\nfallback section (used only when every primary slot is unavailable)`)
|
|
814
|
+
for (const entry of fallback) {
|
|
788
815
|
const bucket = entry.bucket ? ` b:${entry.bucket}` : ""
|
|
789
816
|
console.log(`${pad(entry.model, 60)}${entry.weight}${bucket}`)
|
|
790
817
|
}
|
|
@@ -798,7 +825,9 @@ async function main() {
|
|
|
798
825
|
// entry forward verbatim so the next run still sees it and selectPool can
|
|
799
826
|
// keep its rankable models.
|
|
800
827
|
const scannedNames = new Set(results.map((e) => e.provider))
|
|
801
|
-
const carried = (previousScores?.providers ?? []).filter(
|
|
828
|
+
const carried = (previousScores?.providers ?? []).filter(
|
|
829
|
+
(p) => !scannedNames.has(String(p?.provider ?? "")) && !excludedProviders.has(String(p?.provider ?? "")),
|
|
830
|
+
)
|
|
802
831
|
const previousByModel = new Map()
|
|
803
832
|
for (const provider of previousScores?.providers ?? []) {
|
|
804
833
|
for (const row of provider?.rows ?? []) {
|
|
@@ -811,6 +840,7 @@ async function main() {
|
|
|
811
840
|
minContext,
|
|
812
841
|
minScore,
|
|
813
842
|
selection,
|
|
843
|
+
fallback,
|
|
814
844
|
warnings,
|
|
815
845
|
retry,
|
|
816
846
|
providers: [
|
|
@@ -832,14 +862,21 @@ async function main() {
|
|
|
832
862
|
kind: r.kind ?? null,
|
|
833
863
|
score: r.score?.value ?? null,
|
|
834
864
|
retryAfter: r.retryAfter ?? null,
|
|
865
|
+
failStreak: 0,
|
|
835
866
|
}
|
|
836
867
|
// A transient failure must not erase the last measured ranking:
|
|
837
868
|
// the next run needs it to keep a previously-good model while a
|
|
838
|
-
// gateway is down.
|
|
839
|
-
//
|
|
869
|
+
// gateway is down. But a model that keeps failing scan after scan
|
|
870
|
+
// is not having a bad moment, and the selector must be able to see
|
|
871
|
+
// that: carry a consecutive-failure count forward on both rows so
|
|
872
|
+
// it can retire the slot after MAX_TRANSIENT_STREAK scans. A
|
|
873
|
+
// successful scan emits a fresh row with a zero streak.
|
|
840
874
|
if (r.ok === false && isTransientFailure(r)) {
|
|
841
875
|
const prior = previousByModel.get(r.model)
|
|
842
|
-
|
|
876
|
+
const failStreak = Number(prior?.failStreak ?? 0) + 1
|
|
877
|
+
const failed = { ...current, failStreak }
|
|
878
|
+
if (prior?.ok === true && typeof prior.score === "number") return [failed, { ...prior, failStreak }]
|
|
879
|
+
return [failed]
|
|
843
880
|
}
|
|
844
881
|
return [current]
|
|
845
882
|
}),
|