@lawaty/lacode 2.3.0-beta.4 → 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,19 @@ 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"])
60
+
61
+ /**
62
+ * Failure kinds that vacate a pool slot for good: the model is gone, the
63
+ * credential is wrong, the terms are unsigned, or the balance is empty. Every
64
+ * other failure mode keeps the slot — benched, not deleted.
65
+ */
66
+ export const HARD_DEAD_KINDS = new Set(["not-served", "credential", "agreement", "credit"])
51
67
 
52
68
  export const isTransientKind = (kind) => TRANSIENT_KINDS.has(String(kind ?? "").toLowerCase())
53
69
 
@@ -127,9 +143,152 @@ const rowsOf = (providers) =>
127
143
 
128
144
  const providerOf = (model) => String(model ?? "").split("/")[0]
129
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
+
180
+ /**
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.
206
+ */
207
+ export function parsePoolSlots(text) {
208
+ const out = []
209
+ let section = "primary"
210
+ for (const line of String(text ?? "").split(/\r?\n/)) {
211
+ if (FALLBACK_MARKER.test(line.trim())) {
212
+ section = "fallback"
213
+ continue
214
+ }
215
+ const code = line.split("#")[0].trim()
216
+ if (!code || !code.includes("/")) continue
217
+ const [model, weight] = code.split(/\s+/)
218
+ if (!model || !model.includes("/")) continue
219
+ const bucket = /b:([^\s]+)/.exec(code)?.[1]
220
+ const context = /c:(\d+)/.exec(code)?.[1]
221
+ const ceiling = /ceil:(\d+)/.exec(code)?.[1]
222
+ out.push({
223
+ model,
224
+ weight: Number(weight) > 0 ? Number(weight) : 1,
225
+ ...(ceiling ? { ceiling: Number(ceiling) } : {}),
226
+ ...(bucket ? { bucket } : {}),
227
+ context: context ? Number(context) : null,
228
+ ...(section === "fallback" ? { fallback: true } : {}),
229
+ })
230
+ }
231
+ return out
232
+ }
233
+
234
+ /**
235
+ * Pure structural verdict for a pool slot list (formatted lines or selection
236
+ * entries). The contract: an opencode-go head (every go slot first, weight 4),
237
+ * exactly one opencode tail (last, weight 2, b:opencode), POOL_SIZE slots.
238
+ * Returns { valid, problems[] } — valid covers head+tail; count is reported
239
+ * separately so a short-but-structured pool reads as short, not broken.
240
+ */
241
+ export function poolStructureValid(slots) {
242
+ const problems = []
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)
255
+ const go = lines.filter((entry) => providerOf(entry.model) === "opencode-go")
256
+ const tails = lines.filter((entry) => providerOf(entry.model) === "opencode")
257
+ if (go.length === 0) problems.push("missing opencode-go head")
258
+ for (let i = 0; i < go.length; i++) {
259
+ if (lines[i] !== go[i]) {
260
+ problems.push("opencode-go slots must all come first")
261
+ break
262
+ }
263
+ if (go[i].weight !== 4) {
264
+ problems.push("opencode-go head slots must have weight 4")
265
+ break
266
+ }
267
+ }
268
+ if (tails.length !== 1) problems.push(`expected exactly one opencode tail, found ${tails.length}`)
269
+ else {
270
+ const tail = tails[0]
271
+ if (lines[lines.length - 1] !== tail) problems.push("opencode tail slot must be last")
272
+ if (tail.weight !== 2 || tail.bucket !== "opencode") problems.push("opencode tail must be weight 2 with b:opencode")
273
+ }
274
+ if (lines.length !== POOL_SIZE) problems.push(`expected ${POOL_SIZE} slots, found ${lines.length}`)
275
+ const structural = problems.filter((p) => !p.startsWith(`expected ${POOL_SIZE}`))
276
+ return { valid: structural.length === 0, problems, count: lines.length }
277
+ }
278
+
279
+ /**
280
+ * Hard-dead check for retention: only these failure kinds vacate a slot.
281
+ */
282
+ export const isHardDeadRow = (row) => {
283
+ if (!row || row.ok !== false) return false
284
+ const kind = String(row.kind ?? classifyFailure({ status: row.status, reason: row.reason }) ?? "").toLowerCase()
285
+ return HARD_DEAD_KINDS.has(kind)
286
+ }
287
+
130
288
  export function selectPool({
131
289
  providers,
132
290
  previousProviders,
291
+ currentSlots = [],
133
292
  minContext = MIN_CONTEXT,
134
293
  minScore = MIN_SCORE,
135
294
  poolSize = POOL_SIZE,
@@ -141,7 +300,11 @@ export function selectPool({
141
300
  typeof row.score === "number" &&
142
301
  Number.isFinite(row.score) &&
143
302
  row.score >= minScore &&
144
- (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
145
308
  const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
146
309
  const nowMs = typeof now === "function" ? Number(now()) : Number(now)
147
310
  const normalize = (row) => {
@@ -154,6 +317,14 @@ export function selectPool({
154
317
  const rows = rowsOf(providers).map(normalize)
155
318
  const previousRows = rowsOf(previousProviders)
156
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
+ )
157
328
  const scannedProviders = new Set(
158
329
  (Array.isArray(providers) ? providers : []).map((entry) => String(entry?.provider ?? "")),
159
330
  )
@@ -237,14 +408,146 @@ export function selectPool({
237
408
 
238
409
  const pickedZen = zen.slice(0, zenReserve)
239
410
 
240
- const selection = [
241
- ...go.map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
242
- ...pickedSmall.map((row) => ({ model: row.model, weight: 2, context: row.context ?? null })),
243
- ...pickedLarge.map((row) => ({ model: row.model, weight: 1, context: row.context ?? null })),
244
- ...pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null })),
411
+ const warnings = []
412
+
413
+ // Pool-level retention: a current member stays unless this run proved it
414
+ // hard-dead. A healthy-but-unselected model (caps, score floors) is NOT
415
+ // retained — the caps decided that. A transiently failed, unscanned, or
416
+ // budget-cut model is retained and benched in place by the writer; only a
417
+ // catalogue removal on a cleanly scanned provider vacates the slot. Head
418
+ // and tail are never vacated by a failed or partial scan.
419
+ const rowByModel = new Map(rows.map((row) => [row.model, row]))
420
+ const cleanlyScanned = (model) => {
421
+ const provider = providerOf(model)
422
+ return scannedProviders.has(provider) && !truncatedProviders.has(provider) && !erroredProviders.has(provider)
423
+ }
424
+ const placedModels = new Set([...go, ...cappedSmall, ...cappedLarge, ...pickedZen].map((row) => row.model))
425
+ const shouldRetain = (model) => {
426
+ if (!model.includes("/") || placedModels.has(model)) return false
427
+ const row = rowByModel.get(model)
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
432
+ return !cleanlyScanned(model)
433
+ }
434
+ const retainedHead = []
435
+ const retainedMiddle = []
436
+ let retainedTail = null
437
+ for (const slot of Array.isArray(currentSlots) ? currentSlots : []) {
438
+ const model = String(slot?.model ?? "")
439
+ if (!shouldRetain(model)) continue
440
+ const weight = Number(slot?.weight)
441
+ if (providerOf(model) === "opencode-go") {
442
+ retainedHead.push({ model, weight: 4, context: slot?.context ?? null, retained: true })
443
+ } else if (providerOf(model) === "opencode") {
444
+ if (!retainedTail) retainedTail = { model, weight: 2, bucket: "opencode", context: slot?.context ?? null, retained: true }
445
+ } else {
446
+ retainedMiddle.push({
447
+ model,
448
+ weight: Number.isFinite(weight) && weight > 0 ? weight : 2,
449
+ ...(slot?.bucket ? { bucket: slot.bucket } : {}),
450
+ context: slot?.context ?? null,
451
+ retained: true,
452
+ })
453
+ }
454
+ }
455
+
456
+ const toSmall = (row) => ({ model: row.model, weight: 2, context: row.context ?? null })
457
+ const toLarge = (row) => ({ model: row.model, weight: 1, context: row.context ?? null })
458
+ const middle = [...pickedSmall.map(toSmall), ...pickedLarge.map(toLarge)]
459
+ for (const entry of retainedMiddle) {
460
+ if (!middle.some((existing) => existing.model === entry.model)) middle.push(entry)
461
+ }
462
+ if (retainedTail && !pickedZen.some((row) => providerOf(row.model) === "opencode")) {
463
+ middle.push({ ...retainedTail })
464
+ retainedTail = { ...retainedTail, placed: true }
465
+ }
466
+ const head = [
467
+ ...retainedHead,
468
+ ...go.filter((row) => !retainedHead.some((entry) => entry.model === row.model)).map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
245
469
  ]
470
+ const tail = pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null }))
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
+ })
500
+ // Fit the contract size without ever vacating head or tail: trim the middle
501
+ // from the back (lowest score first), skipping retained slots. If even that
502
+ // cannot fit, the head wins and a warning says so.
503
+ while (selection.length > poolSize) {
504
+ let index = -1
505
+ for (let i = selection.length - 1; i >= 0; i--) {
506
+ const entry = selection[i]
507
+ if (!entry.retained && providerOf(entry.model) !== "opencode-go" && providerOf(entry.model) !== "opencode") {
508
+ index = i
509
+ break
510
+ }
511
+ }
512
+ if (index === -1) {
513
+ warnings.push(`pool head exceeds the contract size ${poolSize}; keeping every head slot`)
514
+ break
515
+ }
516
+ selection.splice(index, 1)
517
+ }
518
+
519
+ const retainedCount = selection.filter((entry) => entry.retained).length
520
+ if (retainedCount > 0) {
521
+ warnings.push(
522
+ `retained ${retainedCount} current slot(s) benched in place: ${selection
523
+ .filter((entry) => entry.retained)
524
+ .map((entry) => entry.model)
525
+ .join(", ")}`,
526
+ )
527
+ }
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
+ }
246
550
 
247
- const warnings = []
248
551
  for (const entry of Array.isArray(providers) ? providers : []) {
249
552
  if (entry?.error) {
250
553
  warnings.push(`${entry.provider}: ${entry.error}`)
@@ -280,5 +583,5 @@ export function selectPool({
280
583
  }
281
584
  : undefined
282
585
 
283
- return { selection, warnings, retry }
586
+ return { selection, fallback: fallbackSelection, warnings, retry }
284
587
  }