@lawaty/lacode 2.2.0-beta.0 → 2.3.0-beta.1

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.
@@ -40,7 +40,16 @@ import { execFileSync, spawn } from "node:child_process"
40
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
41
41
  import { homedir } from "node:os"
42
42
  import { dirname, join } from "node:path"
43
- import { MIN_CONTEXT, MIN_SCORE, retryAfterMs, selectPool } from "./pool-selection.mjs"
43
+ import {
44
+ MIN_CONTEXT,
45
+ MIN_SCORE,
46
+ classifyFailure,
47
+ isTransientFailure,
48
+ isTransientKind,
49
+ providerRank,
50
+ retryAfterMs,
51
+ selectPool,
52
+ } from "./pool-selection.mjs"
44
53
 
45
54
  const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
46
55
  const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
@@ -59,8 +68,24 @@ const SLOW_MS = 8_000 // at or above this, speed scores 0
59
68
  const PROMPT = "Reply with the single word: ok"
60
69
  const MAX_TOKENS = 8
61
70
  const PROBE_ATTEMPTS = 2 // one retry for a timeout or a dropped connection
62
- const isTransientProbe = (reason) =>
63
- /timeout|timed out|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up|no answer/i.test(String(reason ?? ""))
71
+ // Three consecutive provider-level timeouts/network errors is enough evidence
72
+ // that the provider is degraded. Stop spending the scan budget on the rest of
73
+ // its catalogue; the previous rows are retained and a retry is scheduled.
74
+ const PROVIDER_BREAKER_AFTER = 3
75
+ /**
76
+ * Retry the same probe only when the next attempt has a chance. A known
77
+ * exhausted balance or a permanent 401/403/404 will not recover in 300ms; the
78
+ * selector schedules those with retryAfter instead. A bare gateway 402 is
79
+ * retried once, because that is often a transient edge, not a real balance.
80
+ */
81
+ const retryableProbe = (outcome) => {
82
+ if (!outcome || outcome.ok) return false
83
+ const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
84
+ if (kind === "credit") {
85
+ return !/insufficient credits|out of credits|payment required|billing|purchase/i.test(String(outcome.reason ?? ""))
86
+ }
87
+ return isTransientKind(kind)
88
+ }
64
89
 
65
90
  // ── args ────────────────────────────────────────────────────────────────────
66
91
  const argv = process.argv.slice(2)
@@ -81,6 +106,9 @@ const value = (flag, fallback) => {
81
106
  --free-only only probe catalogue cost 0/0 models on every provider
82
107
  --timeout MS per-request deadline in ms (default: 45000)
83
108
  --delay MS pause between requests in ms (default: 300)
109
+ --budget-min N stop starting new probes after N minutes and write what was
110
+ measured; providers not scanned keep their previous rows
111
+ (default: 30)
84
112
  --no-write do not write ~/.config/lacode/model-scores.json
85
113
  -h, --help this message
86
114
 
@@ -103,6 +131,8 @@ const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT)
103
131
  const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
104
132
  const timeoutMs = Math.max(1_000, Number(value("--timeout", "45000")) || 45_000)
105
133
  const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
134
+ const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
135
+ const budgetMs = budgetMin * 60_000
106
136
  const writeResults = !has("--no-write")
107
137
  const freeOnlyFlag = has("--free-only")
108
138
 
@@ -159,9 +189,10 @@ function isChatModel(model) {
159
189
 
160
190
  function buildProviderEntry(provider, rows) {
161
191
  const freeOnly = freeOnlyFlag || FREE_ONLY_PROVIDERS.has(provider)
192
+ const isFree = (model) => (model?.cost?.input ?? 1) === 0 && (model?.cost?.output ?? 1) === 0
162
193
  const models = rows
163
194
  .filter(({ model }) => model?.api?.id)
164
- .filter(({ model }) => !freeOnly || ((model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0))
195
+ .filter(({ model }) => !freeOnly || isFree(model))
165
196
  .filter(({ model }) => !model.status || model.status === "active")
166
197
  .filter(({ model }) => isChatModel(model))
167
198
  .map(({ id, model }) => ({
@@ -171,6 +202,10 @@ function buildProviderEntry(provider, rows) {
171
202
  context: model.limit?.context ?? null,
172
203
  output: model.limit?.output ?? null,
173
204
  name: model.name ?? id,
205
+ // Ordering only: a zero-cost model is probed before a paid one, so a scan
206
+ // budget is spent on the models the pool is most likely to keep. It never
207
+ // excludes a paid model.
208
+ free: isFree(model),
174
209
  }))
175
210
  const excluded = models
176
211
  .filter((m) => m.context != null && m.context < minContext)
@@ -183,7 +218,9 @@ function buildProviderEntry(provider, rows) {
183
218
  return {
184
219
  provider,
185
220
  error: null,
186
- models: models.filter((m) => m.context == null || m.context >= minContext),
221
+ models: models
222
+ .filter((m) => m.context == null || m.context >= minContext)
223
+ .sort((a, b) => Number(b.free) - Number(a.free)),
187
224
  excluded,
188
225
  }
189
226
  }
@@ -375,33 +412,25 @@ async function probeDirect(candidate, provider) {
375
412
 
376
413
  if (!response.ok) {
377
414
  const detail = parsed?.error?.message ?? body.slice(0, 160)
378
- const kind =
379
- response.status === 429
380
- ? "rate-limited"
381
- : response.status === 401 || response.status === 403
382
- ? "credential"
383
- : response.status === 404
384
- ? "not served"
385
- : `http ${response.status}`
415
+ const kind = classifyFailure({ status: response.status, reason: detail })
386
416
  const retryAfter =
387
- kind === "rate-limited" || /credit|402/i.test(detail)
388
- ? retryAfterMs(body, response.headers.get("retry-after"))
389
- : undefined
390
- return { ok: false, ms, reason: `${kind}: ${detail}`, retryAfter }
417
+ kind === "rate-limited" || kind === "credit" ? retryAfterMs(body, response.headers.get("retry-after")) : undefined
418
+ return { ok: false, ms, reason: `${kind}: ${detail}`, status: response.status, kind, retryAfter }
391
419
  }
392
420
  // A 200 can still carry an error object (OpenRouter does this).
393
421
  if (parsed?.error) {
394
422
  const reason = `${parsed.error.message ?? "error"} `.trim()
395
- return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
423
+ const kind = classifyFailure({ status: response.status, reason })
424
+ return { ok: false, ms, reason, status: response.status, kind, retryAfter: retryAfterMs(reason) }
396
425
  }
397
426
  if (!Array.isArray(parsed?.choices) || parsed.choices.length === 0) {
398
- return { ok: false, ms, reason: "no choices in response" }
427
+ return { ok: false, ms, reason: "no choices in response", status: response.status, kind: "unknown" }
399
428
  }
400
- return { ok: true, ms, reason: "answered" }
429
+ return { ok: true, ms, reason: "answered", status: response.status, kind: null }
401
430
  } catch (error) {
402
431
  const ms = Math.round(performance.now() - started)
403
432
  const name = error?.name === "TimeoutError" ? "timeout" : String(error?.message ?? error)
404
- return { ok: false, ms, reason: name }
433
+ return { ok: false, ms, reason: name, kind: classifyFailure({ reason: name }) }
405
434
  }
406
435
  }
407
436
 
@@ -427,12 +456,20 @@ async function probeViaSession(candidate, provider) {
427
456
  if (!createRes.ok) {
428
457
  const detail = createdJson?.error?.message ?? createdJson?.message ?? createText.slice(0, 160)
429
458
  const ms = Math.round(performance.now() - started)
430
- return { ok: false, ms, reason: `session create http ${createRes.status}: ${detail}`, retryAfter: retryAfterMs(detail) }
459
+ const kind = classifyFailure({ status: createRes.status, reason: detail })
460
+ return {
461
+ ok: false,
462
+ ms,
463
+ reason: `session create http ${createRes.status}: ${detail}`,
464
+ status: createRes.status,
465
+ kind,
466
+ retryAfter: retryAfterMs(detail),
467
+ }
431
468
  }
432
469
  id = createdJson?.id ?? createdJson?.data?.id ?? null
433
470
  if (!id) {
434
471
  const ms = Math.round(performance.now() - started)
435
- return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}` }
472
+ return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}`, status: createRes.status, kind: "unknown" }
436
473
  }
437
474
  promptStarted = performance.now()
438
475
  const msgRes = await fetch(`${server.url}/session/${id}/message?directory=${directory}`, {
@@ -453,24 +490,33 @@ async function probeViaSession(candidate, provider) {
453
490
  const ms = Math.round(performance.now() - (promptStarted ?? started))
454
491
  if (!msgRes.ok) {
455
492
  const detail = parsed?.error?.message ?? parsed?.message ?? msgText.slice(0, 160)
456
- return { ok: false, ms, reason: `http ${msgRes.status}: ${detail}`, retryAfter: retryAfterMs(detail) }
493
+ const kind = classifyFailure({ status: msgRes.status, reason: detail })
494
+ return {
495
+ ok: false,
496
+ ms,
497
+ reason: `http ${msgRes.status}: ${detail}`,
498
+ status: msgRes.status,
499
+ kind,
500
+ retryAfter: retryAfterMs(detail),
501
+ }
457
502
  }
458
503
  const failure = parsed?.error ?? parsed?.info?.error
459
504
  if (failure) {
460
505
  const reason = failureText(failure).slice(0, 240)
461
- return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
506
+ const kind = classifyFailure({ status: msgRes.status, reason })
507
+ return { ok: false, ms, reason, status: msgRes.status, kind, retryAfter: retryAfterMs(reason) }
462
508
  }
463
509
  if (!parsed?.info) {
464
- return { ok: false, ms, reason: "no response" }
510
+ return { ok: false, ms, reason: "no response", status: msgRes.status, kind: "unknown" }
465
511
  }
466
- return { ok: true, ms, reason: "answered" }
512
+ return { ok: true, ms, reason: "answered", status: msgRes.status, kind: null }
467
513
  } catch (error) {
468
514
  const ms = Math.round(performance.now() - (promptStarted ?? started))
469
515
  if (error?.name === "TimeoutError") {
470
- return { ok: false, ms, reason: `timeout after ${timeoutMs}ms` }
516
+ return { ok: false, ms, reason: `timeout after ${timeoutMs}ms`, kind: "timeout" }
471
517
  }
472
518
  const reason = failureText(error).slice(0, 240)
473
- return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
519
+ return { ok: false, ms, reason, kind: classifyFailure({ reason }), retryAfter: retryAfterMs(reason) }
474
520
  } finally {
475
521
  if (id && server) {
476
522
  try {
@@ -491,8 +537,13 @@ async function probe(candidate, provider) {
491
537
  const outcome = await probeDirect(candidate, provider)
492
538
  if (outcome.ok) return outcome
493
539
  // A provider whose direct URL is misconfigured can still be reachable
494
- // through opencode's own provider resolution.
495
- if (/credential|no credential|not served|invalid request path|could not route/i.test(String(outcome.reason ?? ""))) {
540
+ // through opencode's own provider resolution. A real 401/404 is worth the
541
+ // second path; a permanent agreement/credit refusal is not retried here.
542
+ if (
543
+ outcome.kind === "credential" ||
544
+ outcome.kind === "not-served" ||
545
+ /invalid request path|could not route/i.test(String(outcome.reason ?? ""))
546
+ ) {
496
547
  return probeViaSession(candidate, provider)
497
548
  }
498
549
  return outcome
@@ -520,9 +571,23 @@ const padL = (s, n) => String(s).padStart(n)
520
571
 
521
572
  async function main() {
522
573
  const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
574
+ // Core providers first, then the free-only middle tier, then the slow paid
575
+ // catalogues. Ordering only; every provider is still probed if time allows.
576
+ scanned.sort(
577
+ (a, b) => providerRank(a.provider) - providerRank(b.provider) || String(a.provider).localeCompare(String(b.provider)),
578
+ )
523
579
  const results = []
580
+ const deadline = Date.now() + budgetMs
581
+ const skippedProviders = []
582
+ let budgetHit = false
524
583
 
525
584
  for (const entry of scanned) {
585
+ if (Date.now() >= deadline) {
586
+ budgetHit = true
587
+ skippedProviders.push(entry.provider)
588
+ console.log(`\n${entry.provider}: skipped (scan budget of ${budgetMin}m reached)`)
589
+ continue
590
+ }
526
591
  if (entry.error) {
527
592
  console.log(`\n${entry.provider}: ${entry.error}`)
528
593
  results.push({ provider: entry.provider, error: entry.error, rows: [], excluded: [] })
@@ -540,13 +605,23 @@ async function main() {
540
605
  continue
541
606
  }
542
607
  const rows = []
608
+ let consecutiveTransient = 0
543
609
  for (const candidate of candidates) {
610
+ if (Date.now() >= deadline) {
611
+ budgetHit = true
612
+ entry.truncated = true
613
+ console.log(` scan budget reached; stopped ${entry.provider} after ${rows.length}/${candidates.length}`)
614
+ break
615
+ }
544
616
  let outcome = await probe(candidate, entry.provider)
545
- for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok && isTransientProbe(outcome.reason); attempt++) {
617
+ for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok && retryableProbe(outcome); attempt++) {
546
618
  console.log(` retrying ${candidate.model.replace(`${entry.provider}/`, "")} after ${String(outcome.reason).slice(0, 60)}`)
547
619
  await sleep(delayMs)
548
620
  outcome = await probe(candidate, entry.provider)
549
621
  }
622
+ const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
623
+ const providerFault = !outcome.ok && ["timeout", "network", "server"].includes(kind)
624
+ consecutiveTransient = providerFault ? consecutiveTransient + 1 : 0
550
625
  const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
551
626
  row.score = score(row)
552
627
  if (row.score && row.score.value < minScore) {
@@ -561,9 +636,22 @@ async function main() {
561
636
  rows.push(row)
562
637
  const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
563
638
  console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
639
+ if (consecutiveTransient >= PROVIDER_BREAKER_AFTER) {
640
+ entry.truncated = true
641
+ entry.degraded = true
642
+ console.log(
643
+ ` provider degraded: ${consecutiveTransient} consecutive ${kind} failures; stopped ${entry.provider} after ${rows.length}/${candidates.length}`,
644
+ )
645
+ break
646
+ }
564
647
  await sleep(delayMs)
565
648
  }
566
- results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded })
649
+ results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded, truncated: !!entry.truncated, degraded: !!entry.degraded })
650
+ }
651
+ if (budgetHit) {
652
+ console.log(
653
+ `\nscan budget of ${budgetMin}m reached; ${skippedProviders.length} provider(s) skipped and any truncated provider keeps its previous rows.`,
654
+ )
567
655
  }
568
656
  stopSessionServer()
569
657
 
@@ -612,6 +700,7 @@ async function main() {
612
700
  providers: results.map((entry) => ({
613
701
  provider: entry.provider,
614
702
  error: entry.error,
703
+ truncated: entry.truncated,
615
704
  rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
616
705
  excluded: entry.excluded,
617
706
  })),
@@ -619,6 +708,9 @@ async function main() {
619
708
  minContext,
620
709
  minScore,
621
710
  })
711
+ if (budgetHit) {
712
+ warnings.push(`scan budget of ${budgetMin}m reached; providers not fully scanned keep their previous scores`)
713
+ }
622
714
 
623
715
  if (selection.length) {
624
716
  console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
@@ -633,6 +725,17 @@ async function main() {
633
725
  if (writeResults) {
634
726
  try {
635
727
  mkdirSync(dirname(OUT), { recursive: true })
728
+ // A provider the budget skipped has no current rows. Carry its previous
729
+ // entry forward verbatim so the next run still sees it and selectPool can
730
+ // keep its rankable models.
731
+ const scannedNames = new Set(results.map((e) => e.provider))
732
+ const carried = (previousScores?.providers ?? []).filter((p) => !scannedNames.has(String(p?.provider ?? "")))
733
+ const previousByModel = new Map()
734
+ for (const provider of previousScores?.providers ?? []) {
735
+ for (const row of provider?.rows ?? []) {
736
+ if (row?.model) previousByModel.set(row.model, row)
737
+ }
738
+ }
636
739
  const payload = {
637
740
  generatedAt: new Date().toISOString(),
638
741
  weights: W,
@@ -641,21 +744,39 @@ async function main() {
641
744
  selection,
642
745
  warnings,
643
746
  retry,
644
- providers: results.map((e) => ({
645
- provider: e.provider,
646
- error: e.error,
647
- excluded: e.excluded ?? [],
648
- rows: e.rows.map((r) => ({
649
- model: r.model,
650
- context: r.context,
651
- output: r.output,
652
- ok: r.ok,
653
- ms: r.ms,
654
- reason: r.reason,
655
- score: r.score?.value ?? null,
656
- retryAfter: r.retryAfter ?? null,
747
+ providers: [
748
+ ...results.map((e) => ({
749
+ provider: e.provider,
750
+ error: e.error,
751
+ truncated: !!e.truncated,
752
+ degraded: !!e.degraded,
753
+ excluded: e.excluded ?? [],
754
+ rows: e.rows.flatMap((r) => {
755
+ const current = {
756
+ model: r.model,
757
+ context: r.context,
758
+ output: r.output,
759
+ ok: r.ok,
760
+ ms: r.ms,
761
+ reason: r.reason,
762
+ status: r.status ?? null,
763
+ kind: r.kind ?? null,
764
+ score: r.score?.value ?? null,
765
+ retryAfter: r.retryAfter ?? null,
766
+ }
767
+ // A transient failure must not erase the last measured ranking:
768
+ // the next run needs it to keep a previously-good model while a
769
+ // gateway is down. Keep both rows; the selector prefers the
770
+ // rankable one and still schedules the failed one for retry.
771
+ if (r.ok === false && isTransientFailure(r)) {
772
+ const prior = previousByModel.get(r.model)
773
+ if (prior?.ok === true && typeof prior.score === "number") return [current, prior]
774
+ }
775
+ return [current]
776
+ }),
657
777
  })),
658
- })),
778
+ ...carried,
779
+ ],
659
780
  }
660
781
  writeFileSync(OUT, JSON.stringify(payload, null, 2) + "\n")
661
782
  console.log(`\nwrote ${OUT}`)