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

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,20 @@ 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
+ /**
72
+ * Retry the same probe only when the next attempt has a chance. A known
73
+ * exhausted balance or a permanent 401/403/404 will not recover in 300ms; the
74
+ * selector schedules those with retryAfter instead. A bare gateway 402 is
75
+ * retried once, because that is often a transient edge, not a real balance.
76
+ */
77
+ const retryableProbe = (outcome) => {
78
+ if (!outcome || outcome.ok) return false
79
+ const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
80
+ if (kind === "credit") {
81
+ return !/insufficient credits|out of credits|payment required|billing|purchase/i.test(String(outcome.reason ?? ""))
82
+ }
83
+ return isTransientKind(kind)
84
+ }
64
85
 
65
86
  // ── args ────────────────────────────────────────────────────────────────────
66
87
  const argv = process.argv.slice(2)
@@ -81,6 +102,9 @@ const value = (flag, fallback) => {
81
102
  --free-only only probe catalogue cost 0/0 models on every provider
82
103
  --timeout MS per-request deadline in ms (default: 45000)
83
104
  --delay MS pause between requests in ms (default: 300)
105
+ --budget-min N stop starting new probes after N minutes and write what was
106
+ measured; providers not scanned keep their previous rows
107
+ (default: 30)
84
108
  --no-write do not write ~/.config/lacode/model-scores.json
85
109
  -h, --help this message
86
110
 
@@ -103,6 +127,8 @@ const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT)
103
127
  const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
104
128
  const timeoutMs = Math.max(1_000, Number(value("--timeout", "45000")) || 45_000)
105
129
  const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
130
+ const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
131
+ const budgetMs = budgetMin * 60_000
106
132
  const writeResults = !has("--no-write")
107
133
  const freeOnlyFlag = has("--free-only")
108
134
 
@@ -159,9 +185,10 @@ function isChatModel(model) {
159
185
 
160
186
  function buildProviderEntry(provider, rows) {
161
187
  const freeOnly = freeOnlyFlag || FREE_ONLY_PROVIDERS.has(provider)
188
+ const isFree = (model) => (model?.cost?.input ?? 1) === 0 && (model?.cost?.output ?? 1) === 0
162
189
  const models = rows
163
190
  .filter(({ model }) => model?.api?.id)
164
- .filter(({ model }) => !freeOnly || ((model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0))
191
+ .filter(({ model }) => !freeOnly || isFree(model))
165
192
  .filter(({ model }) => !model.status || model.status === "active")
166
193
  .filter(({ model }) => isChatModel(model))
167
194
  .map(({ id, model }) => ({
@@ -171,6 +198,10 @@ function buildProviderEntry(provider, rows) {
171
198
  context: model.limit?.context ?? null,
172
199
  output: model.limit?.output ?? null,
173
200
  name: model.name ?? id,
201
+ // Ordering only: a zero-cost model is probed before a paid one, so a scan
202
+ // budget is spent on the models the pool is most likely to keep. It never
203
+ // excludes a paid model.
204
+ free: isFree(model),
174
205
  }))
175
206
  const excluded = models
176
207
  .filter((m) => m.context != null && m.context < minContext)
@@ -183,7 +214,9 @@ function buildProviderEntry(provider, rows) {
183
214
  return {
184
215
  provider,
185
216
  error: null,
186
- models: models.filter((m) => m.context == null || m.context >= minContext),
217
+ models: models
218
+ .filter((m) => m.context == null || m.context >= minContext)
219
+ .sort((a, b) => Number(b.free) - Number(a.free)),
187
220
  excluded,
188
221
  }
189
222
  }
@@ -375,33 +408,25 @@ async function probeDirect(candidate, provider) {
375
408
 
376
409
  if (!response.ok) {
377
410
  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}`
411
+ const kind = classifyFailure({ status: response.status, reason: detail })
386
412
  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 }
413
+ kind === "rate-limited" || kind === "credit" ? retryAfterMs(body, response.headers.get("retry-after")) : undefined
414
+ return { ok: false, ms, reason: `${kind}: ${detail}`, status: response.status, kind, retryAfter }
391
415
  }
392
416
  // A 200 can still carry an error object (OpenRouter does this).
393
417
  if (parsed?.error) {
394
418
  const reason = `${parsed.error.message ?? "error"} `.trim()
395
- return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
419
+ const kind = classifyFailure({ status: response.status, reason })
420
+ return { ok: false, ms, reason, status: response.status, kind, retryAfter: retryAfterMs(reason) }
396
421
  }
397
422
  if (!Array.isArray(parsed?.choices) || parsed.choices.length === 0) {
398
- return { ok: false, ms, reason: "no choices in response" }
423
+ return { ok: false, ms, reason: "no choices in response", status: response.status, kind: "unknown" }
399
424
  }
400
- return { ok: true, ms, reason: "answered" }
425
+ return { ok: true, ms, reason: "answered", status: response.status, kind: null }
401
426
  } catch (error) {
402
427
  const ms = Math.round(performance.now() - started)
403
428
  const name = error?.name === "TimeoutError" ? "timeout" : String(error?.message ?? error)
404
- return { ok: false, ms, reason: name }
429
+ return { ok: false, ms, reason: name, kind: classifyFailure({ reason: name }) }
405
430
  }
406
431
  }
407
432
 
@@ -427,12 +452,20 @@ async function probeViaSession(candidate, provider) {
427
452
  if (!createRes.ok) {
428
453
  const detail = createdJson?.error?.message ?? createdJson?.message ?? createText.slice(0, 160)
429
454
  const ms = Math.round(performance.now() - started)
430
- return { ok: false, ms, reason: `session create http ${createRes.status}: ${detail}`, retryAfter: retryAfterMs(detail) }
455
+ const kind = classifyFailure({ status: createRes.status, reason: detail })
456
+ return {
457
+ ok: false,
458
+ ms,
459
+ reason: `session create http ${createRes.status}: ${detail}`,
460
+ status: createRes.status,
461
+ kind,
462
+ retryAfter: retryAfterMs(detail),
463
+ }
431
464
  }
432
465
  id = createdJson?.id ?? createdJson?.data?.id ?? null
433
466
  if (!id) {
434
467
  const ms = Math.round(performance.now() - started)
435
- return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}` }
468
+ return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}`, status: createRes.status, kind: "unknown" }
436
469
  }
437
470
  promptStarted = performance.now()
438
471
  const msgRes = await fetch(`${server.url}/session/${id}/message?directory=${directory}`, {
@@ -453,24 +486,33 @@ async function probeViaSession(candidate, provider) {
453
486
  const ms = Math.round(performance.now() - (promptStarted ?? started))
454
487
  if (!msgRes.ok) {
455
488
  const detail = parsed?.error?.message ?? parsed?.message ?? msgText.slice(0, 160)
456
- return { ok: false, ms, reason: `http ${msgRes.status}: ${detail}`, retryAfter: retryAfterMs(detail) }
489
+ const kind = classifyFailure({ status: msgRes.status, reason: detail })
490
+ return {
491
+ ok: false,
492
+ ms,
493
+ reason: `http ${msgRes.status}: ${detail}`,
494
+ status: msgRes.status,
495
+ kind,
496
+ retryAfter: retryAfterMs(detail),
497
+ }
457
498
  }
458
499
  const failure = parsed?.error ?? parsed?.info?.error
459
500
  if (failure) {
460
501
  const reason = failureText(failure).slice(0, 240)
461
- return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
502
+ const kind = classifyFailure({ status: msgRes.status, reason })
503
+ return { ok: false, ms, reason, status: msgRes.status, kind, retryAfter: retryAfterMs(reason) }
462
504
  }
463
505
  if (!parsed?.info) {
464
- return { ok: false, ms, reason: "no response" }
506
+ return { ok: false, ms, reason: "no response", status: msgRes.status, kind: "unknown" }
465
507
  }
466
- return { ok: true, ms, reason: "answered" }
508
+ return { ok: true, ms, reason: "answered", status: msgRes.status, kind: null }
467
509
  } catch (error) {
468
510
  const ms = Math.round(performance.now() - (promptStarted ?? started))
469
511
  if (error?.name === "TimeoutError") {
470
- return { ok: false, ms, reason: `timeout after ${timeoutMs}ms` }
512
+ return { ok: false, ms, reason: `timeout after ${timeoutMs}ms`, kind: "timeout" }
471
513
  }
472
514
  const reason = failureText(error).slice(0, 240)
473
- return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
515
+ return { ok: false, ms, reason, kind: classifyFailure({ reason }), retryAfter: retryAfterMs(reason) }
474
516
  } finally {
475
517
  if (id && server) {
476
518
  try {
@@ -491,8 +533,13 @@ async function probe(candidate, provider) {
491
533
  const outcome = await probeDirect(candidate, provider)
492
534
  if (outcome.ok) return outcome
493
535
  // 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 ?? ""))) {
536
+ // through opencode's own provider resolution. A real 401/404 is worth the
537
+ // second path; a permanent agreement/credit refusal is not retried here.
538
+ if (
539
+ outcome.kind === "credential" ||
540
+ outcome.kind === "not-served" ||
541
+ /invalid request path|could not route/i.test(String(outcome.reason ?? ""))
542
+ ) {
496
543
  return probeViaSession(candidate, provider)
497
544
  }
498
545
  return outcome
@@ -520,9 +567,23 @@ const padL = (s, n) => String(s).padStart(n)
520
567
 
521
568
  async function main() {
522
569
  const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
570
+ // Core providers first, then the free-only middle tier, then the slow paid
571
+ // catalogues. Ordering only; every provider is still probed if time allows.
572
+ scanned.sort(
573
+ (a, b) => providerRank(a.provider) - providerRank(b.provider) || String(a.provider).localeCompare(String(b.provider)),
574
+ )
523
575
  const results = []
576
+ const deadline = Date.now() + budgetMs
577
+ const skippedProviders = []
578
+ let budgetHit = false
524
579
 
525
580
  for (const entry of scanned) {
581
+ if (Date.now() >= deadline) {
582
+ budgetHit = true
583
+ skippedProviders.push(entry.provider)
584
+ console.log(`\n${entry.provider}: skipped (scan budget of ${budgetMin}m reached)`)
585
+ continue
586
+ }
526
587
  if (entry.error) {
527
588
  console.log(`\n${entry.provider}: ${entry.error}`)
528
589
  results.push({ provider: entry.provider, error: entry.error, rows: [], excluded: [] })
@@ -541,8 +602,14 @@ async function main() {
541
602
  }
542
603
  const rows = []
543
604
  for (const candidate of candidates) {
605
+ if (Date.now() >= deadline) {
606
+ budgetHit = true
607
+ entry.truncated = true
608
+ console.log(` scan budget reached; stopped ${entry.provider} after ${rows.length}/${candidates.length}`)
609
+ break
610
+ }
544
611
  let outcome = await probe(candidate, entry.provider)
545
- for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok && isTransientProbe(outcome.reason); attempt++) {
612
+ for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok && retryableProbe(outcome); attempt++) {
546
613
  console.log(` retrying ${candidate.model.replace(`${entry.provider}/`, "")} after ${String(outcome.reason).slice(0, 60)}`)
547
614
  await sleep(delayMs)
548
615
  outcome = await probe(candidate, entry.provider)
@@ -563,7 +630,12 @@ async function main() {
563
630
  console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
564
631
  await sleep(delayMs)
565
632
  }
566
- results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded })
633
+ results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded, truncated: !!entry.truncated })
634
+ }
635
+ if (budgetHit) {
636
+ console.log(
637
+ `\nscan budget of ${budgetMin}m reached; ${skippedProviders.length} provider(s) skipped and any truncated provider keeps its previous rows.`,
638
+ )
567
639
  }
568
640
  stopSessionServer()
569
641
 
@@ -612,6 +684,7 @@ async function main() {
612
684
  providers: results.map((entry) => ({
613
685
  provider: entry.provider,
614
686
  error: entry.error,
687
+ truncated: entry.truncated,
615
688
  rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
616
689
  excluded: entry.excluded,
617
690
  })),
@@ -619,6 +692,9 @@ async function main() {
619
692
  minContext,
620
693
  minScore,
621
694
  })
695
+ if (budgetHit) {
696
+ warnings.push(`scan budget of ${budgetMin}m reached; providers not fully scanned keep their previous scores`)
697
+ }
622
698
 
623
699
  if (selection.length) {
624
700
  console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
@@ -633,6 +709,17 @@ async function main() {
633
709
  if (writeResults) {
634
710
  try {
635
711
  mkdirSync(dirname(OUT), { recursive: true })
712
+ // A provider the budget skipped has no current rows. Carry its previous
713
+ // entry forward verbatim so the next run still sees it and selectPool can
714
+ // keep its rankable models.
715
+ const scannedNames = new Set(results.map((e) => e.provider))
716
+ const carried = (previousScores?.providers ?? []).filter((p) => !scannedNames.has(String(p?.provider ?? "")))
717
+ const previousByModel = new Map()
718
+ for (const provider of previousScores?.providers ?? []) {
719
+ for (const row of provider?.rows ?? []) {
720
+ if (row?.model) previousByModel.set(row.model, row)
721
+ }
722
+ }
636
723
  const payload = {
637
724
  generatedAt: new Date().toISOString(),
638
725
  weights: W,
@@ -641,21 +728,38 @@ async function main() {
641
728
  selection,
642
729
  warnings,
643
730
  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,
731
+ providers: [
732
+ ...results.map((e) => ({
733
+ provider: e.provider,
734
+ error: e.error,
735
+ truncated: !!e.truncated,
736
+ excluded: e.excluded ?? [],
737
+ rows: e.rows.flatMap((r) => {
738
+ const current = {
739
+ model: r.model,
740
+ context: r.context,
741
+ output: r.output,
742
+ ok: r.ok,
743
+ ms: r.ms,
744
+ reason: r.reason,
745
+ status: r.status ?? null,
746
+ kind: r.kind ?? null,
747
+ score: r.score?.value ?? null,
748
+ retryAfter: r.retryAfter ?? null,
749
+ }
750
+ // A transient failure must not erase the last measured ranking:
751
+ // the next run needs it to keep a previously-good model while a
752
+ // gateway is down. Keep both rows; the selector prefers the
753
+ // rankable one and still schedules the failed one for retry.
754
+ if (r.ok === false && isTransientFailure(r)) {
755
+ const prior = previousByModel.get(r.model)
756
+ if (prior?.ok === true && typeof prior.score === "number") return [current, prior]
757
+ }
758
+ return [current]
759
+ }),
657
760
  })),
658
- })),
761
+ ...carried,
762
+ ],
659
763
  }
660
764
  writeFileSync(OUT, JSON.stringify(payload, null, 2) + "\n")
661
765
  console.log(`\nwrote ${OUT}`)