@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.
- package/CHANGELOG.md +40 -1
- package/README.md +3 -6
- package/dist/agents.js +3 -41
- package/dist/commands.js +2 -5
- package/dist/index.js +445 -249
- package/docs/CONTEXT.md +17 -33
- package/docs/USER-STORIES.md +27 -2
- package/package.json +1 -1
- package/scripts/refresh-pool.mjs +74 -63
- package/scripts/score-models.mjs +111 -37
package/scripts/score-models.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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()} —
|
|
505
|
-
|
|
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,
|