@lawaty/lacode 2.0.0-beta.0 → 2.2.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.
@@ -12,7 +12,8 @@
12
12
  // npm run score -- --no-write print only, touch nothing
13
13
  //
14
14
  // WHAT IT MEASURES
15
- // context limit.context from `opencode models --verbose`
15
+ // context limit.context from `opencode models --verbose`; a known limit
16
+ // below --min-context is excluded from ranking, not scored low
16
17
  // output limit.output (the token ceiling for one reply)
17
18
  // speed wall-clock ms for one tiny completion, one request per model
18
19
  // reliability did the model actually answer? A 429/401/404 is recorded as the
@@ -21,10 +22,14 @@
21
22
  // WHAT IT DOES NOT DO
22
23
  // * It never invents a latency or a score. A model it could not reach is shown
23
24
  // as unreachable with the reason and is NOT ranked.
25
+ // * It never ranks a model whose known context is below --min-context, or whose
26
+ // measured score is below --min-score: those are listed as excluded instead.
24
27
  // * It never writes pool.txt. It prints a SUGGESTED table for you to paste; the
25
28
  // pool stays yours to decide.
26
- // * It never probes a paid model. Only cost 0/0 models from the provider's
27
- // catalog are candidates (non-chat/media/safety/meta-router models are skipped).
29
+ // * opencode and opencode-go stay free-only. Every other provider is probed
30
+ // even when its catalogue lists a price, because provider credits cover it;
31
+ // --free-only restores the cost-0 rule everywhere. Non-chat/media/safety/
32
+ // meta-router models are always skipped.
28
33
  //
29
34
  // OUTPUT
30
35
  // a per-provider ranked table, an overall ranking, a suggested pool.txt block,
@@ -35,11 +40,14 @@ import { execFileSync, spawn } from "node:child_process"
35
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
36
41
  import { homedir } from "node:os"
37
42
  import { dirname, join } from "node:path"
43
+ import { MIN_CONTEXT, MIN_SCORE, retryAfterMs, selectPool } from "./pool-selection.mjs"
38
44
 
39
45
  const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
40
46
  const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
41
- const DEFAULT_PROVIDERS = ["opencode", "opencode-go", "openrouter", "console-openrouter"]
47
+ // No default provider list: `opencode models --verbose` with no argument returns
48
+ // every provider opencode knows about, and that is what we scan by default.
42
49
  const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
50
+ const FREE_ONLY_PROVIDERS = new Set(["opencode", "opencode-go"])
43
51
 
44
52
  // ── scoring weights ─────────────────────────────────────────────────────────
45
53
  // Mechanical work prizes speed and availability over raw capability. Context is
@@ -50,6 +58,9 @@ const FAST_MS = 300 // at or below this, speed scores 1.0
50
58
  const SLOW_MS = 8_000 // at or above this, speed scores 0
51
59
  const PROMPT = "Reply with the single word: ok"
52
60
  const MAX_TOKENS = 8
61
+ 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 ?? ""))
53
64
 
54
65
  // ── args ────────────────────────────────────────────────────────────────────
55
66
  const argv = process.argv.slice(2)
@@ -63,27 +74,37 @@ const value = (flag, fallback) => {
63
74
  if (has("--help") || has("-h")) {
64
75
  console.log(`Usage: npm run score -- [options]
65
76
 
66
- --providers a,b providers to scan (default: ${DEFAULT_PROVIDERS.join(",")})
67
- --max N probe at most N free models per provider (default: 12)
68
- --timeout MS per-request deadline in ms (default: 20000)
77
+ --providers a,b restrict the scan to these providers (default: all configured)
78
+ --max N probe at most N candidate models per provider (default: 50)
79
+ --min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
80
+ --min-score N do not rank models scoring below N (default: ${MIN_SCORE})
81
+ --free-only only probe catalogue cost 0/0 models on every provider
82
+ --timeout MS per-request deadline in ms (default: 45000)
69
83
  --delay MS pause between requests in ms (default: 300)
70
84
  --no-write do not write ~/.config/lacode/model-scores.json
71
85
  -h, --help this message
72
86
 
73
- Only cost 0/0 concrete text chat/completion models are candidates
74
- (non-chat/media generators, safety classifiers, and OpenRouter meta-routers are skipped).
87
+ opencode and opencode-go stay free-only; every other provider is probed even
88
+ when its catalogue lists a price, because provider credits cover it. Use
89
+ --free-only to apply the cost-0 rule everywhere. Non-chat/media generators,
90
+ safety classifiers, and OpenRouter meta-routers are always skipped.
75
91
  Credentials come from ~/.local/share/opencode/auth.json; a provider with no
76
92
  entry falls back to the "opencode" key (which is how the console-openrouter
77
93
  connection authenticates).
78
- opencode/opencode-go models are probed through a temporary local opencode serve --pure session, while the other providers are probed directly.`)
94
+ opencode/opencode-go models are probed through a temporary local opencode serve --pure session, while the other providers are probed directly.
95
+ Providers with no direct HTTP endpoint (or an unexpanded \${...} URL) are probed through the same local opencode session as opencode/opencode-go.`)
79
96
  process.exit(0)
80
97
  }
81
98
 
82
- const providers = String(value("--providers", DEFAULT_PROVIDERS.join(","))).split(",").map((s) => s.trim()).filter(Boolean)
83
- const maxPerProvider = Math.max(1, Number(value("--max", "12")) || 12)
84
- const timeoutMs = Math.max(1_000, Number(value("--timeout", "20000")) || 20_000)
99
+ const providerArg = value("--providers", null)
100
+ const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim()).filter(Boolean) : null
101
+ const maxPerProvider = Math.max(1, Number(value("--max", "50")) || 50)
102
+ const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
103
+ const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
104
+ const timeoutMs = Math.max(1_000, Number(value("--timeout", "45000")) || 45_000)
85
105
  const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
86
106
  const writeResults = !has("--no-write")
107
+ const freeOnlyFlag = has("--free-only")
87
108
 
88
109
  // ── discovery ───────────────────────────────────────────────────────────────
89
110
  /** Parse `opencode models --verbose <provider>`: alternating id line + JSON block. */
@@ -136,21 +157,11 @@ function isChatModel(model) {
136
157
  return true
137
158
  }
138
159
 
139
- function freeModels(provider) {
140
- let text
141
- try {
142
- text = execFileSync("opencode", ["models", "--verbose", provider], {
143
- encoding: "utf8",
144
- maxBuffer: 64 * 1024 * 1024,
145
- stdio: ["ignore", "pipe", "ignore"],
146
- })
147
- } catch (error) {
148
- return { provider, error: `opencode models failed: ${String(error?.message ?? error)}`, models: [] }
149
- }
150
- const rows = parseVerbose(text)
160
+ function buildProviderEntry(provider, rows) {
161
+ const freeOnly = freeOnlyFlag || FREE_ONLY_PROVIDERS.has(provider)
151
162
  const models = rows
152
- .filter(({ model }) => model?.api?.url && model?.api?.id)
153
- .filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
163
+ .filter(({ model }) => model?.api?.id)
164
+ .filter(({ model }) => !freeOnly || ((model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0))
154
165
  .filter(({ model }) => !model.status || model.status === "active")
155
166
  .filter(({ model }) => isChatModel(model))
156
167
  .map(({ id, model }) => ({
@@ -161,7 +172,57 @@ function freeModels(provider) {
161
172
  output: model.limit?.output ?? null,
162
173
  name: model.name ?? id,
163
174
  }))
164
- return { provider, error: null, models }
175
+ const excluded = models
176
+ .filter((m) => m.context != null && m.context < minContext)
177
+ .map((m) => ({
178
+ model: m.model,
179
+ context: m.context,
180
+ output: m.output,
181
+ reason: `context ${m.context} < minimum ${minContext}`,
182
+ }))
183
+ return {
184
+ provider,
185
+ error: null,
186
+ models: models.filter((m) => m.context == null || m.context >= minContext),
187
+ excluded,
188
+ }
189
+ }
190
+
191
+ function freeModels(provider) {
192
+ let text
193
+ try {
194
+ text = execFileSync("opencode", ["models", "--verbose", provider], {
195
+ encoding: "utf8",
196
+ maxBuffer: 64 * 1024 * 1024,
197
+ stdio: ["ignore", "pipe", "ignore"],
198
+ })
199
+ } catch (error) {
200
+ return { provider, error: `opencode models failed: ${String(error?.message ?? error)}`, models: [], excluded: [] }
201
+ }
202
+ return buildProviderEntry(provider, parseVerbose(text))
203
+ }
204
+
205
+ function freeModelsAll() {
206
+ let text
207
+ try {
208
+ text = execFileSync("opencode", ["models", "--verbose"], {
209
+ encoding: "utf8",
210
+ maxBuffer: 64 * 1024 * 1024,
211
+ stdio: ["ignore", "pipe", "ignore"],
212
+ })
213
+ } catch (error) {
214
+ return [{ provider: "all", error: `opencode models failed: ${String(error?.message ?? error)}`, models: [], excluded: [] }]
215
+ }
216
+ const groups = new Map()
217
+ for (const row of parseVerbose(text)) {
218
+ const provider = String(row.model?.providerID ?? row.id.split("/")[0] ?? "")
219
+ if (!provider) continue
220
+ if (!groups.has(provider)) groups.set(provider, [])
221
+ groups.get(provider).push(row)
222
+ }
223
+ return [...groups.entries()]
224
+ .sort(([a], [b]) => a.localeCompare(b))
225
+ .map(([provider, rows]) => buildProviderEntry(provider, rows))
165
226
  }
166
227
 
167
228
  // ── credentials ─────────────────────────────────────────────────────────────
@@ -322,10 +383,17 @@ async function probeDirect(candidate, provider) {
322
383
  : response.status === 404
323
384
  ? "not served"
324
385
  : `http ${response.status}`
325
- return { ok: false, ms, reason: `${kind}: ${detail}` }
386
+ 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 }
326
391
  }
327
392
  // A 200 can still carry an error object (OpenRouter does this).
328
- if (parsed?.error) return { ok: false, ms, reason: `${parsed.error.message ?? "error"} `.trim() }
393
+ if (parsed?.error) {
394
+ const reason = `${parsed.error.message ?? "error"} `.trim()
395
+ return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
396
+ }
329
397
  if (!Array.isArray(parsed?.choices) || parsed.choices.length === 0) {
330
398
  return { ok: false, ms, reason: "no choices in response" }
331
399
  }
@@ -359,7 +427,7 @@ async function probeViaSession(candidate, provider) {
359
427
  if (!createRes.ok) {
360
428
  const detail = createdJson?.error?.message ?? createdJson?.message ?? createText.slice(0, 160)
361
429
  const ms = Math.round(performance.now() - started)
362
- return { ok: false, ms, reason: `session create http ${createRes.status}: ${detail}` }
430
+ return { ok: false, ms, reason: `session create http ${createRes.status}: ${detail}`, retryAfter: retryAfterMs(detail) }
363
431
  }
364
432
  id = createdJson?.id ?? createdJson?.data?.id ?? null
365
433
  if (!id) {
@@ -385,11 +453,12 @@ async function probeViaSession(candidate, provider) {
385
453
  const ms = Math.round(performance.now() - (promptStarted ?? started))
386
454
  if (!msgRes.ok) {
387
455
  const detail = parsed?.error?.message ?? parsed?.message ?? msgText.slice(0, 160)
388
- return { ok: false, ms, reason: `http ${msgRes.status}: ${detail}` }
456
+ return { ok: false, ms, reason: `http ${msgRes.status}: ${detail}`, retryAfter: retryAfterMs(detail) }
389
457
  }
390
458
  const failure = parsed?.error ?? parsed?.info?.error
391
459
  if (failure) {
392
- return { ok: false, ms, reason: failureText(failure).slice(0, 240) }
460
+ const reason = failureText(failure).slice(0, 240)
461
+ return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
393
462
  }
394
463
  if (!parsed?.info) {
395
464
  return { ok: false, ms, reason: "no response" }
@@ -400,7 +469,8 @@ async function probeViaSession(candidate, provider) {
400
469
  if (error?.name === "TimeoutError") {
401
470
  return { ok: false, ms, reason: `timeout after ${timeoutMs}ms` }
402
471
  }
403
- return { ok: false, ms, reason: failureText(error).slice(0, 240) }
472
+ const reason = failureText(error).slice(0, 240)
473
+ return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
404
474
  } finally {
405
475
  if (id && server) {
406
476
  try {
@@ -410,9 +480,22 @@ async function probeViaSession(candidate, provider) {
410
480
  }
411
481
  }
412
482
 
483
+ function usesSession(provider, candidate) {
484
+ if (SESSION_PROVIDERS.has(provider)) return true
485
+ const url = String(candidate.url ?? "")
486
+ return url.length === 0 || url.includes("${")
487
+ }
488
+
413
489
  async function probe(candidate, provider) {
414
- if (SESSION_PROVIDERS.has(provider)) return probeViaSession(candidate, provider)
415
- return probeDirect(candidate, provider)
490
+ if (usesSession(provider, candidate)) return probeViaSession(candidate, provider)
491
+ const outcome = await probeDirect(candidate, provider)
492
+ if (outcome.ok) return outcome
493
+ // 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 ?? ""))) {
496
+ return probeViaSession(candidate, provider)
497
+ }
498
+ return outcome
416
499
  }
417
500
 
418
501
  // ── scoring ─────────────────────────────────────────────────────────────────
@@ -436,32 +519,51 @@ const pad = (s, n) => String(s).padEnd(n)
436
519
  const padL = (s, n) => String(s).padStart(n)
437
520
 
438
521
  async function main() {
439
- const scanned = providers.map(freeModels)
522
+ const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
440
523
  const results = []
441
524
 
442
525
  for (const entry of scanned) {
443
526
  if (entry.error) {
444
527
  console.log(`\n${entry.provider}: ${entry.error}`)
445
- results.push({ provider: entry.provider, error: entry.error, rows: [] })
528
+ results.push({ provider: entry.provider, error: entry.error, rows: [], excluded: [] })
446
529
  continue
447
530
  }
448
531
  const candidates = entry.models.slice(0, maxPerProvider)
449
- console.log(`\n${entry.provider}: probing ${candidates.length} free model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}`)
532
+ const excludedNote = entry.excluded.length ? `, ${entry.excluded.length} excluded (context < ${minContext})` : ""
533
+ console.log(`\n${entry.provider}: probing ${candidates.length} candidate model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}${excludedNote}`)
534
+ if (entry.excluded.length) {
535
+ console.log(` excluded — context below ${minContext}:`)
536
+ for (const x of entry.excluded) console.log(` ${pad(x.model.replace(`${entry.provider}/`, ""), 52)} ${padL(x.context, 9)}`)
537
+ }
450
538
  if (candidates.length === 0) {
451
- results.push({ provider: entry.provider, error: null, rows: [] })
539
+ results.push({ provider: entry.provider, error: null, rows: [], excluded: entry.excluded })
452
540
  continue
453
541
  }
454
542
  const rows = []
455
543
  for (const candidate of candidates) {
456
- const outcome = await probe(candidate, entry.provider)
544
+ let outcome = await probe(candidate, entry.provider)
545
+ for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok && isTransientProbe(outcome.reason); attempt++) {
546
+ console.log(` retrying ${candidate.model.replace(`${entry.provider}/`, "")} after ${String(outcome.reason).slice(0, 60)}`)
547
+ await sleep(delayMs)
548
+ outcome = await probe(candidate, entry.provider)
549
+ }
457
550
  const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
458
551
  row.score = score(row)
552
+ if (row.score && row.score.value < minScore) {
553
+ entry.excluded.push({
554
+ model: row.model,
555
+ context: row.context,
556
+ output: row.output,
557
+ score: row.score.value,
558
+ reason: `score ${row.score.value} < minimum ${minScore}`,
559
+ })
560
+ }
459
561
  rows.push(row)
460
562
  const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
461
563
  console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
462
564
  await sleep(delayMs)
463
565
  }
464
- results.push({ provider: entry.provider, error: null, rows })
566
+ results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded })
465
567
  }
466
568
  stopSessionServer()
467
569
 
@@ -470,7 +572,7 @@ async function main() {
470
572
  if (entry.error || entry.rows.length === 0) continue
471
573
  console.log(`\n${entry.provider} — ranked`)
472
574
  console.log(` ${pad("#", 3)}${pad("model", 52)}${padL("ctx", 9)}${padL("out", 9)}${padL("ms", 7)}${padL("score", 7)}`)
473
- const ranked = [...entry.rows].filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)
575
+ const ranked = [...entry.rows].filter((r) => r.score && r.score.value >= minScore).sort((a, b) => b.score.value - a.score.value)
474
576
  ranked.forEach((r, i) => {
475
577
  console.log(` ${pad(i + 1, 3)}${pad(r.model.replace(`${entry.provider}/`, ""), 52)}${padL(r.context ?? "?", 9)}${padL(r.output ?? "?", 9)}${padL(r.ms ?? "?", 7)}${padL(r.score.value, 7)}`)
476
578
  })
@@ -481,32 +583,52 @@ async function main() {
481
583
  }
482
584
  }
483
585
 
586
+ // Models dropped for a too-small context or a too-low score are reported, never ranked.
587
+ const excluded = results.flatMap((e) => e.excluded ?? [])
588
+ if (excluded.length) {
589
+ console.log(`\nexcluded — below the context or score floor (not ranked)`)
590
+ for (const x of excluded) console.log(` ${pad(x.model, 58)}${padL(x.context ?? "?", 9)} ${x.reason}`)
591
+ }
592
+
484
593
  // Overall ranking across providers.
485
- const all = results.flatMap((e) => e.rows).filter((r) => r.score)
594
+ const all = results.flatMap((e) => e.rows).filter((r) => r.score && r.score.value >= minScore)
486
595
  all.sort((a, b) => b.score.value - a.score.value)
487
596
  if (all.length) {
488
- console.log(`\noverall — best free models right now`)
597
+ console.log(`\noverall — best candidate models right now`)
489
598
  all.slice(0, 12).forEach((r, i) => {
490
599
  console.log(` ${pad(i + 1, 3)}${pad(r.model, 58)}${padL(`${r.ms}ms`, 8)}${padL(r.score.value, 7)}`)
491
600
  })
492
601
  }
493
602
 
494
- // A SUGGESTED pool: the best model from each provider, weights 4/3/2/1 by rank.
495
- // Printed, never written — pool.txt stays the user's decision.
496
- const bestPerProvider = []
497
- for (const entry of results) {
498
- const top = entry.rows.filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)[0]
499
- if (top) bestPerProvider.push(top)
500
- }
501
- bestPerProvider.sort((a, b) => b.score.value - a.score.value)
502
- if (bestPerProvider.length) {
603
+ // Previous scores let a transient outage keep the pool it had.
604
+ const previousScores = (() => {
605
+ try {
606
+ return JSON.parse(readFileSync(OUT, "utf8"))
607
+ } catch {
608
+ return null
609
+ }
610
+ })()
611
+ const { selection, warnings, retry } = selectPool({
612
+ providers: results.map((entry) => ({
613
+ provider: entry.provider,
614
+ error: entry.error,
615
+ rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
616
+ excluded: entry.excluded,
617
+ })),
618
+ previousProviders: previousScores?.providers,
619
+ minContext,
620
+ minScore,
621
+ })
622
+
623
+ if (selection.length) {
503
624
  console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
504
- console.log(`# scored ${new Date().toISOString()} — one best free model per provider`)
505
- bestPerProvider.forEach((r, i) => {
506
- const weight = Math.max(1, 4 - i)
507
- console.log(`${pad(r.model, 60)}${weight}`)
508
- })
625
+ 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)`)
626
+ for (const entry of selection) {
627
+ const bucket = entry.bucket ? ` b:${entry.bucket}` : ""
628
+ console.log(`${pad(entry.model, 60)}${entry.weight}${bucket}`)
629
+ }
509
630
  }
631
+ if (retry) console.log(`\nlimited: ${retry.models.length} model(s); retry after ${retry.after}`)
510
632
 
511
633
  if (writeResults) {
512
634
  try {
@@ -514,9 +636,15 @@ async function main() {
514
636
  const payload = {
515
637
  generatedAt: new Date().toISOString(),
516
638
  weights: W,
639
+ minContext,
640
+ minScore,
641
+ selection,
642
+ warnings,
643
+ retry,
517
644
  providers: results.map((e) => ({
518
645
  provider: e.provider,
519
646
  error: e.error,
647
+ excluded: e.excluded ?? [],
520
648
  rows: e.rows.map((r) => ({
521
649
  model: r.model,
522
650
  context: r.context,
@@ -525,6 +653,7 @@ async function main() {
525
653
  ms: r.ms,
526
654
  reason: r.reason,
527
655
  score: r.score?.value ?? null,
656
+ retryAfter: r.retryAfter ?? null,
528
657
  })),
529
658
  })),
530
659
  }