@lawaty/lacode 1.3.1 → 2.1.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,6 +22,8 @@
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
29
  // * It never probes a paid model. Only cost 0/0 models from the provider's
@@ -38,7 +41,8 @@ import { dirname, join } from "node:path"
38
41
 
39
42
  const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
40
43
  const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
41
- const DEFAULT_PROVIDERS = ["opencode", "opencode-go", "openrouter", "console-openrouter"]
44
+ // No default provider list: `opencode models --verbose` with no argument returns
45
+ // every provider opencode knows about, and that is what we scan by default.
42
46
  const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
43
47
 
44
48
  // ── scoring weights ─────────────────────────────────────────────────────────
@@ -46,6 +50,8 @@ const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
46
50
  // scored on a log curve so 256k vs 1M is a difference, not a cliff.
47
51
  const W = { speed: 0.35, context: 0.3, output: 0.15, reliability: 0.2 }
48
52
  const MAX_CONTEXT = 1_000_000
53
+ const MIN_CONTEXT = 200_000 // below this, a model is excluded instead of ranked
54
+ const MIN_SCORE = 60 // below this, a probed model is excluded from ranking
49
55
  const FAST_MS = 300 // at or below this, speed scores 1.0
50
56
  const SLOW_MS = 8_000 // at or above this, speed scores 0
51
57
  const PROMPT = "Reply with the single word: ok"
@@ -63,8 +69,10 @@ const value = (flag, fallback) => {
63
69
  if (has("--help") || has("-h")) {
64
70
  console.log(`Usage: npm run score -- [options]
65
71
 
66
- --providers a,b providers to scan (default: ${DEFAULT_PROVIDERS.join(",")})
72
+ --providers a,b restrict the scan to these providers (default: all configured)
67
73
  --max N probe at most N free models per provider (default: 12)
74
+ --min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
75
+ --min-score N do not rank models scoring below N (default: ${MIN_SCORE})
68
76
  --timeout MS per-request deadline in ms (default: 20000)
69
77
  --delay MS pause between requests in ms (default: 300)
70
78
  --no-write do not write ~/.config/lacode/model-scores.json
@@ -79,8 +87,11 @@ opencode/opencode-go models are probed through a temporary local opencode serve
79
87
  process.exit(0)
80
88
  }
81
89
 
82
- const providers = String(value("--providers", DEFAULT_PROVIDERS.join(","))).split(",").map((s) => s.trim()).filter(Boolean)
90
+ const providerArg = value("--providers", null)
91
+ const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim()).filter(Boolean) : null
83
92
  const maxPerProvider = Math.max(1, Number(value("--max", "12")) || 12)
93
+ const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
94
+ const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
84
95
  const timeoutMs = Math.max(1_000, Number(value("--timeout", "20000")) || 20_000)
85
96
  const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
86
97
  const writeResults = !has("--no-write")
@@ -136,18 +147,7 @@ function isChatModel(model) {
136
147
  return true
137
148
  }
138
149
 
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)
150
+ function buildProviderEntry(provider, rows) {
151
151
  const models = rows
152
152
  .filter(({ model }) => model?.api?.url && model?.api?.id)
153
153
  .filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
@@ -161,7 +161,57 @@ function freeModels(provider) {
161
161
  output: model.limit?.output ?? null,
162
162
  name: model.name ?? id,
163
163
  }))
164
- return { provider, error: null, models }
164
+ const excluded = models
165
+ .filter((m) => m.context != null && m.context < minContext)
166
+ .map((m) => ({
167
+ model: m.model,
168
+ context: m.context,
169
+ output: m.output,
170
+ reason: `context ${m.context} < minimum ${minContext}`,
171
+ }))
172
+ return {
173
+ provider,
174
+ error: null,
175
+ models: models.filter((m) => m.context == null || m.context >= minContext),
176
+ excluded,
177
+ }
178
+ }
179
+
180
+ function freeModels(provider) {
181
+ let text
182
+ try {
183
+ text = execFileSync("opencode", ["models", "--verbose", provider], {
184
+ encoding: "utf8",
185
+ maxBuffer: 64 * 1024 * 1024,
186
+ stdio: ["ignore", "pipe", "ignore"],
187
+ })
188
+ } catch (error) {
189
+ return { provider, error: `opencode models failed: ${String(error?.message ?? error)}`, models: [], excluded: [] }
190
+ }
191
+ return buildProviderEntry(provider, parseVerbose(text))
192
+ }
193
+
194
+ function freeModelsAll() {
195
+ let text
196
+ try {
197
+ text = execFileSync("opencode", ["models", "--verbose"], {
198
+ encoding: "utf8",
199
+ maxBuffer: 64 * 1024 * 1024,
200
+ stdio: ["ignore", "pipe", "ignore"],
201
+ })
202
+ } catch (error) {
203
+ return [{ provider: "all", error: `opencode models failed: ${String(error?.message ?? error)}`, models: [], excluded: [] }]
204
+ }
205
+ const groups = new Map()
206
+ for (const row of parseVerbose(text)) {
207
+ const provider = String(row.model?.providerID ?? row.id.split("/")[0] ?? "")
208
+ if (!provider) continue
209
+ if (!groups.has(provider)) groups.set(provider, [])
210
+ groups.get(provider).push(row)
211
+ }
212
+ return [...groups.entries()]
213
+ .sort(([a], [b]) => a.localeCompare(b))
214
+ .map(([provider, rows]) => buildProviderEntry(provider, rows))
165
215
  }
166
216
 
167
217
  // ── credentials ─────────────────────────────────────────────────────────────
@@ -436,19 +486,24 @@ const pad = (s, n) => String(s).padEnd(n)
436
486
  const padL = (s, n) => String(s).padStart(n)
437
487
 
438
488
  async function main() {
439
- const scanned = providers.map(freeModels)
489
+ const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
440
490
  const results = []
441
491
 
442
492
  for (const entry of scanned) {
443
493
  if (entry.error) {
444
494
  console.log(`\n${entry.provider}: ${entry.error}`)
445
- results.push({ provider: entry.provider, error: entry.error, rows: [] })
495
+ results.push({ provider: entry.provider, error: entry.error, rows: [], excluded: [] })
446
496
  continue
447
497
  }
448
498
  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}` : ""}`)
499
+ const excludedNote = entry.excluded.length ? `, ${entry.excluded.length} excluded (context < ${minContext})` : ""
500
+ console.log(`\n${entry.provider}: probing ${candidates.length} free model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}${excludedNote}`)
501
+ if (entry.excluded.length) {
502
+ console.log(` excluded — context below ${minContext}:`)
503
+ for (const x of entry.excluded) console.log(` ${pad(x.model.replace(`${entry.provider}/`, ""), 52)} ${padL(x.context, 9)}`)
504
+ }
450
505
  if (candidates.length === 0) {
451
- results.push({ provider: entry.provider, error: null, rows: [] })
506
+ results.push({ provider: entry.provider, error: null, rows: [], excluded: entry.excluded })
452
507
  continue
453
508
  }
454
509
  const rows = []
@@ -456,12 +511,21 @@ async function main() {
456
511
  const outcome = await probe(candidate, entry.provider)
457
512
  const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
458
513
  row.score = score(row)
514
+ if (row.score && row.score.value < minScore) {
515
+ entry.excluded.push({
516
+ model: row.model,
517
+ context: row.context,
518
+ output: row.output,
519
+ score: row.score.value,
520
+ reason: `score ${row.score.value} < minimum ${minScore}`,
521
+ })
522
+ }
459
523
  rows.push(row)
460
524
  const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
461
525
  console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
462
526
  await sleep(delayMs)
463
527
  }
464
- results.push({ provider: entry.provider, error: null, rows })
528
+ results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded })
465
529
  }
466
530
  stopSessionServer()
467
531
 
@@ -470,7 +534,7 @@ async function main() {
470
534
  if (entry.error || entry.rows.length === 0) continue
471
535
  console.log(`\n${entry.provider} — ranked`)
472
536
  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)
537
+ const ranked = [...entry.rows].filter((r) => r.score && r.score.value >= minScore).sort((a, b) => b.score.value - a.score.value)
474
538
  ranked.forEach((r, i) => {
475
539
  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
540
  })
@@ -481,8 +545,15 @@ async function main() {
481
545
  }
482
546
  }
483
547
 
548
+ // Models dropped for a too-small context or a too-low score are reported, never ranked.
549
+ const excluded = results.flatMap((e) => e.excluded ?? [])
550
+ if (excluded.length) {
551
+ console.log(`\nexcluded — below the context or score floor (not ranked)`)
552
+ for (const x of excluded) console.log(` ${pad(x.model, 58)}${padL(x.context ?? "?", 9)} ${x.reason}`)
553
+ }
554
+
484
555
  // Overall ranking across providers.
485
- const all = results.flatMap((e) => e.rows).filter((r) => r.score)
556
+ const all = results.flatMap((e) => e.rows).filter((r) => r.score && r.score.value >= minScore)
486
557
  all.sort((a, b) => b.score.value - a.score.value)
487
558
  if (all.length) {
488
559
  console.log(`\noverall — best free models right now`)
@@ -491,21 +562,21 @@ async function main() {
491
562
  })
492
563
  }
493
564
 
494
- // A SUGGESTED pool: the best model from each provider, weights 4/3/2/1 by rank.
565
+ // A SUGGESTED pool: every opencode-go model (weight 4), then the best
566
+ // (10 - n) models from the other providers (weight 2), by measured score.
495
567
  // 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) {
568
+ const poolSize = 10
569
+ const goRows = all.filter((r) => r.model.startsWith("opencode-go/"))
570
+ const otherRows = all.filter((r) => !r.model.startsWith("opencode-go/"))
571
+ const pickedOthers = otherRows.slice(0, Math.max(0, poolSize - goRows.length))
572
+ const suggested = [
573
+ ...goRows.map((r) => ({ row: r, weight: 4 })),
574
+ ...pickedOthers.map((r) => ({ row: r, weight: 2 })),
575
+ ]
576
+ if (suggested.length) {
503
577
  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
- })
578
+ console.log(`# scored ${new Date().toISOString()} — opencode-go first (weight 4), then the best others (weight 2)`)
579
+ for (const { row, weight } of suggested) console.log(`${pad(row.model, 60)}${weight}`)
509
580
  }
510
581
 
511
582
  if (writeResults) {
@@ -514,9 +585,12 @@ async function main() {
514
585
  const payload = {
515
586
  generatedAt: new Date().toISOString(),
516
587
  weights: W,
588
+ minContext,
589
+ minScore,
517
590
  providers: results.map((e) => ({
518
591
  provider: e.provider,
519
592
  error: e.error,
593
+ excluded: e.excluded ?? [],
520
594
  rows: e.rows.map((r) => ({
521
595
  model: r.model,
522
596
  context: r.context,