@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.
@@ -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
- // A credit exhaustion is transient over hours, not permanent: the balance can be
48
- // topped up, and the retry schedule is what carries it. Agreement, credential
49
- // and not-served failures are permanent for this model until a human acts.
50
- const TRANSIENT_KINDS = new Set(["rate-limited", "credit", "timeout", "network", "server"])
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
- * Parse pool.txt slot lines into [{ model, weight, bucket?, context? }].
139
- * Comments and blanks are ignored; weight defaults to 1.
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 items = typeof slots === "string" ? String(slots).split(/\r?\n/) : Array.isArray(slots) ? slots : []
170
- const lines = items.map((entry) => {
171
- if (typeof entry === "string") {
172
- const parts = entry.split("#")[0].trim().split(/\s+/)
173
- const bucket = /b:([^\s]+)/.exec(entry)?.[1]
174
- return { model: parts[0] ?? "", weight: Number(parts[1]) || 0, bucket }
175
- }
176
- return { model: entry?.model ?? "", weight: Number(entry?.weight) || 0, bucket: entry?.bucket }
177
- }).filter((entry) => entry.model.includes("/"))
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
- if (row) return row.ok === false && !isHardDeadRow(row)
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
  }
@@ -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 selection = scores.selection.filter(
145
- (entry) =>
146
- entry &&
147
- typeof entry.model === "string" &&
148
- entry.model.includes("/") &&
149
- typeof entry.weight === "number" &&
150
- Number.isFinite(entry.weight) &&
151
- entry.weight > 0,
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 splitHeaderAndSlots(text).slots.filter(isSlotLine).length
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
- export const SCAN_BENCH_WINDOW_MS = 45 * 60 * 1000
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 45 minutes); a
259
- * member that answered has any stale bench cleared; an unscanned member is
260
- * left alone for lack of evidence. Only pool members are ever touched.
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
- rmSync(join(dir, scanBenchFile(model)), { force: true })
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(isSlotLine).length >= threshold) return { keep: false, prev, threshold, reason: "" }
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,
@@ -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
- if (!currentPoolPath) return []
604
- try {
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.map((entry) => ({
769
- provider: entry.provider,
770
- error: entry.error,
771
- truncated: entry.truncated,
772
- rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
773
- excluded: entry.excluded,
774
- })),
775
- previousProviders: previousScores?.providers,
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((p) => !scannedNames.has(String(p?.provider ?? "")))
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. Keep both rows; the selector prefers the
839
- // rankable one and still schedules the failed one for retry.
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
- if (prior?.ok === true && typeof prior.score === "number") return [current, prior]
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
  }),