@lawaty/lacode 1.0.3 → 1.2.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.
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ // LaCode free-model scorer — MEASURE, DO NOT FABRICATE.
3
+ //
4
+ // The pool is for mechanical work: reading files, grepping, mechanical edits,
5
+ // cartography. What matters for that is not "the smartest model" but a model that
6
+ // is (a) free, (b) awake, (c) fast, and (d) has enough context and output room.
7
+ // This script measures those four things and ranks the candidates.
8
+ //
9
+ // npm run score all configured free providers
10
+ // npm run score -- --providers opencode,openrouter
11
+ // npm run score -- --max 6 probe at most 6 models per provider
12
+ // npm run score -- --no-write print only, touch nothing
13
+ //
14
+ // WHAT IT MEASURES
15
+ // context limit.context from `opencode models --verbose`
16
+ // output limit.output (the token ceiling for one reply)
17
+ // speed wall-clock ms for one tiny completion, one request per model
18
+ // reliability did the model actually answer? A 429/401/404 is recorded as the
19
+ // named failure it is, never as a slow success.
20
+ //
21
+ // WHAT IT DOES NOT DO
22
+ // * It never invents a latency or a score. A model it could not reach is shown
23
+ // as unreachable with the reason and is NOT ranked.
24
+ // * It never writes pool.txt. It prints a SUGGESTED table for you to paste; the
25
+ // 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.
28
+ //
29
+ // OUTPUT
30
+ // a per-provider ranked table, an overall ranking, a suggested pool.txt block,
31
+ // and (unless --no-write) the raw rows in ~/.config/lacode/model-scores.json so
32
+ // a later run can compare.
33
+
34
+ import { execFileSync } from "node:child_process"
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
36
+ import { homedir } from "node:os"
37
+ import { dirname, join } from "node:path"
38
+
39
+ const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
40
+ const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
41
+ const DEFAULT_PROVIDERS = ["opencode", "opencode-go", "openrouter", "console-openrouter"]
42
+
43
+ // ── scoring weights ─────────────────────────────────────────────────────────
44
+ // Mechanical work prizes speed and availability over raw capability. Context is
45
+ // scored on a log curve so 256k vs 1M is a difference, not a cliff.
46
+ const W = { speed: 0.35, context: 0.3, output: 0.15, reliability: 0.2 }
47
+ const MAX_CONTEXT = 1_000_000
48
+ const FAST_MS = 300 // at or below this, speed scores 1.0
49
+ const SLOW_MS = 8_000 // at or above this, speed scores 0
50
+ const PROMPT = "Reply with the single word: ok"
51
+ const MAX_TOKENS = 8
52
+
53
+ // ── args ────────────────────────────────────────────────────────────────────
54
+ const argv = process.argv.slice(2)
55
+ const has = (flag) => argv.includes(flag)
56
+ const value = (flag, fallback) => {
57
+ const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`))
58
+ if (!hit) return fallback
59
+ return hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : argv[argv.indexOf(hit) + 1]
60
+ }
61
+
62
+ if (has("--help") || has("-h")) {
63
+ console.log(`Usage: npm run score -- [options]
64
+
65
+ --providers a,b providers to scan (default: ${DEFAULT_PROVIDERS.join(",")})
66
+ --max N probe at most N free models per provider (default: 12)
67
+ --timeout MS per-request deadline in ms (default: 20000)
68
+ --delay MS pause between requests in ms (default: 300)
69
+ --no-write do not write ~/.config/lacode/model-scores.json
70
+ -h, --help this message
71
+
72
+ Only cost 0/0 models are candidates. Credentials come from
73
+ ~/.local/share/opencode/auth.json; a provider with no entry falls back to the
74
+ "opencode" key (which is how the console-openrouter connection authenticates).`)
75
+ process.exit(0)
76
+ }
77
+
78
+ const providers = String(value("--providers", DEFAULT_PROVIDERS.join(","))).split(",").map((s) => s.trim()).filter(Boolean)
79
+ const maxPerProvider = Math.max(1, Number(value("--max", "12")) || 12)
80
+ const timeoutMs = Math.max(1_000, Number(value("--timeout", "20000")) || 20_000)
81
+ const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
82
+ const writeResults = !has("--no-write")
83
+
84
+ // ── discovery ───────────────────────────────────────────────────────────────
85
+ /** Parse `opencode models --verbose <provider>`: alternating id line + JSON block. */
86
+ function parseVerbose(text) {
87
+ const rows = []
88
+ let id = null
89
+ let buf = []
90
+ const flush = () => {
91
+ if (!id || buf.length === 0) return false
92
+ try {
93
+ const parsed = JSON.parse(buf.join("\n"))
94
+ rows.push({ id, model: parsed })
95
+ id = null
96
+ buf = []
97
+ return true
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+ for (const line of text.split(/\r?\n/)) {
103
+ const isId = /^\S/.test(line) && !/^[{}]/.test(line)
104
+ if (isId) {
105
+ if (id) flush()
106
+ if (line.startsWith("provider/") || line.includes("/")) {
107
+ id = line.trim()
108
+ buf = []
109
+ }
110
+ continue
111
+ }
112
+ if (id) {
113
+ buf.push(line)
114
+ const trimmed = line.trim()
115
+ if (trimmed === "}" || trimmed === "},") flush()
116
+ }
117
+ }
118
+ flush()
119
+ return rows
120
+ }
121
+
122
+ function freeModels(provider) {
123
+ let text
124
+ try {
125
+ text = execFileSync("opencode", ["models", "--verbose", provider], {
126
+ encoding: "utf8",
127
+ maxBuffer: 64 * 1024 * 1024,
128
+ stdio: ["ignore", "pipe", "ignore"],
129
+ })
130
+ } catch (error) {
131
+ return { provider, error: `opencode models failed: ${String(error?.message ?? error)}`, models: [] }
132
+ }
133
+ const rows = parseVerbose(text)
134
+ const models = rows
135
+ .filter(({ model }) => model?.api?.url && model?.api?.id)
136
+ .filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
137
+ .filter(({ model }) => !model.status || model.status === "active")
138
+ .map(({ id, model }) => ({
139
+ model: id,
140
+ url: model.api.url,
141
+ apiId: model.api.id,
142
+ context: model.limit?.context ?? null,
143
+ output: model.limit?.output ?? null,
144
+ name: model.name ?? id,
145
+ }))
146
+ return { provider, error: null, models }
147
+ }
148
+
149
+ // ── credentials ─────────────────────────────────────────────────────────────
150
+ function loadAuth() {
151
+ if (!existsSync(AUTH)) return {}
152
+ try {
153
+ return JSON.parse(readFileSync(AUTH, "utf8"))
154
+ } catch {
155
+ return {}
156
+ }
157
+ }
158
+ const auth = loadAuth()
159
+ const keyFor = (provider) => auth[provider]?.key ?? auth.opencode?.key ?? null
160
+ // The Zen gateways reject a request that does not name a session; the value does
161
+ // not need to be a real opencode session, it only has to be present.
162
+ const runSession = `score-${Date.now().toString(36)}`
163
+ const sessionHeader = (provider) =>
164
+ provider === "opencode" || provider === "opencode-go" ? { "x-opencode-session": runSession } : {}
165
+
166
+ // ── probing ─────────────────────────────────────────────────────────────────
167
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
168
+
169
+ /**
170
+ * One real request. Returns {ok, ms, reason}. A failure is named, never rounded
171
+ * into a success, and `ms` is only reported for a request that actually ran.
172
+ */
173
+ async function probe(candidate, provider) {
174
+ const key = keyFor(provider)
175
+ if (!key) return { ok: false, ms: null, reason: "no credential in auth.json" }
176
+
177
+ const url = `${String(candidate.url).replace(/\/+$/, "")}/chat/completions`
178
+ const started = performance.now()
179
+ try {
180
+ const response = await fetch(url, {
181
+ method: "POST",
182
+ headers: {
183
+ "Content-Type": "application/json",
184
+ Authorization: `Bearer ${key}`,
185
+ ...sessionHeader(provider),
186
+ ...(provider === "openrouter" ? { "HTTP-Referer": "https://opencode.ai", "X-Title": "LaCode score" } : {}),
187
+ },
188
+ body: JSON.stringify({
189
+ model: candidate.apiId,
190
+ messages: [{ role: "user", content: PROMPT }],
191
+ max_tokens: MAX_TOKENS,
192
+ temperature: 0,
193
+ stream: false,
194
+ }),
195
+ signal: AbortSignal.timeout(timeoutMs),
196
+ })
197
+ const ms = Math.round(performance.now() - started)
198
+ const body = await response.text()
199
+ let parsed = null
200
+ try {
201
+ parsed = JSON.parse(body)
202
+ } catch {}
203
+
204
+ if (!response.ok) {
205
+ const detail = parsed?.error?.message ?? body.slice(0, 160)
206
+ const kind =
207
+ response.status === 429
208
+ ? "rate-limited"
209
+ : response.status === 401 || response.status === 403
210
+ ? "credential"
211
+ : response.status === 404
212
+ ? "not served"
213
+ : `http ${response.status}`
214
+ return { ok: false, ms, reason: `${kind}: ${detail}` }
215
+ }
216
+ // A 200 can still carry an error object (OpenRouter does this).
217
+ if (parsed?.error) return { ok: false, ms, reason: `${parsed.error.message ?? "error"} `.trim() }
218
+ if (!Array.isArray(parsed?.choices) || parsed.choices.length === 0) {
219
+ return { ok: false, ms, reason: "no choices in response" }
220
+ }
221
+ return { ok: true, ms, reason: "answered" }
222
+ } catch (error) {
223
+ const ms = Math.round(performance.now() - started)
224
+ const name = error?.name === "TimeoutError" ? "timeout" : String(error?.message ?? error)
225
+ return { ok: false, ms, reason: name }
226
+ }
227
+ }
228
+
229
+ // ── scoring ─────────────────────────────────────────────────────────────────
230
+ const clamp01 = (n) => Math.max(0, Math.min(1, n))
231
+ const logScore = (n, max) => (n && n > 0 ? clamp01(Math.log2(1 + n) / Math.log2(1 + max)) : 0)
232
+ const speedScore = (ms) => (ms == null ? 0 : clamp01((SLOW_MS - ms) / (SLOW_MS - FAST_MS)))
233
+
234
+ function score(row) {
235
+ if (!row.ok) return null
236
+ const parts = {
237
+ speed: speedScore(row.ms),
238
+ context: logScore(row.context, MAX_CONTEXT),
239
+ output: logScore(row.output, 262_144),
240
+ reliability: 1,
241
+ }
242
+ const value = 100 * (Object.entries(W).reduce((sum, [k, w]) => sum + w * parts[k], 0) / Object.values(W).reduce((a, b) => a + b, 0))
243
+ return { value: Math.round(value * 10) / 10, parts }
244
+ }
245
+
246
+ const pad = (s, n) => String(s).padEnd(n)
247
+ const padL = (s, n) => String(s).padStart(n)
248
+
249
+ async function main() {
250
+ const scanned = providers.map(freeModels)
251
+ const results = []
252
+
253
+ for (const entry of scanned) {
254
+ if (entry.error) {
255
+ console.log(`\n${entry.provider}: ${entry.error}`)
256
+ results.push({ provider: entry.provider, error: entry.error, rows: [] })
257
+ continue
258
+ }
259
+ const candidates = entry.models.slice(0, maxPerProvider)
260
+ console.log(`\n${entry.provider}: probing ${candidates.length} free model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}`)
261
+ if (candidates.length === 0) {
262
+ results.push({ provider: entry.provider, error: null, rows: [] })
263
+ continue
264
+ }
265
+ const rows = []
266
+ for (const candidate of candidates) {
267
+ const outcome = await probe(candidate, entry.provider)
268
+ const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
269
+ row.score = score(row)
270
+ rows.push(row)
271
+ const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
272
+ console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
273
+ await sleep(delayMs)
274
+ }
275
+ results.push({ provider: entry.provider, error: null, rows })
276
+ }
277
+
278
+ // Per-provider ranked tables.
279
+ for (const entry of results) {
280
+ if (entry.error || entry.rows.length === 0) continue
281
+ console.log(`\n${entry.provider} — ranked`)
282
+ console.log(` ${pad("#", 3)}${pad("model", 52)}${padL("ctx", 9)}${padL("out", 9)}${padL("ms", 7)}${padL("score", 7)}`)
283
+ const ranked = [...entry.rows].filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)
284
+ ranked.forEach((r, i) => {
285
+ 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)}`)
286
+ })
287
+ const failed = entry.rows.filter((r) => !r.ok)
288
+ if (failed.length) {
289
+ console.log(` unreachable (not ranked):`)
290
+ for (const r of failed) console.log(` ${pad(r.model.replace(`${entry.provider}/`, ""), 52)} ${r.reason.slice(0, 90)}`)
291
+ }
292
+ }
293
+
294
+ // Overall ranking across providers.
295
+ const all = results.flatMap((e) => e.rows).filter((r) => r.score)
296
+ all.sort((a, b) => b.score.value - a.score.value)
297
+ if (all.length) {
298
+ console.log(`\noverall — best free models right now`)
299
+ all.slice(0, 12).forEach((r, i) => {
300
+ console.log(` ${pad(i + 1, 3)}${pad(r.model, 58)}${padL(`${r.ms}ms`, 8)}${padL(r.score.value, 7)}`)
301
+ })
302
+ }
303
+
304
+ // A SUGGESTED pool: the best model from each provider, weights 4/3/2/1 by rank.
305
+ // Printed, never written — pool.txt stays the user's decision.
306
+ const bestPerProvider = []
307
+ for (const entry of results) {
308
+ const top = entry.rows.filter((r) => r.score).sort((a, b) => b.score.value - a.score.value)[0]
309
+ if (top) bestPerProvider.push(top)
310
+ }
311
+ bestPerProvider.sort((a, b) => b.score.value - a.score.value)
312
+ if (bestPerProvider.length) {
313
+ console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
314
+ console.log(`# scored ${new Date().toISOString()} — one best free model per provider`)
315
+ bestPerProvider.forEach((r, i) => {
316
+ const weight = Math.max(1, 4 - i)
317
+ console.log(`${pad(r.model, 60)}${weight}`)
318
+ })
319
+ }
320
+
321
+ if (writeResults) {
322
+ try {
323
+ mkdirSync(dirname(OUT), { recursive: true })
324
+ const payload = {
325
+ generatedAt: new Date().toISOString(),
326
+ weights: W,
327
+ providers: results.map((e) => ({
328
+ provider: e.provider,
329
+ error: e.error,
330
+ rows: e.rows.map((r) => ({
331
+ model: r.model,
332
+ context: r.context,
333
+ output: r.output,
334
+ ok: r.ok,
335
+ ms: r.ms,
336
+ reason: r.reason,
337
+ score: r.score?.value ?? null,
338
+ })),
339
+ })),
340
+ }
341
+ writeFileSync(OUT, JSON.stringify(payload, null, 2) + "\n")
342
+ console.log(`\nwrote ${OUT}`)
343
+ } catch (error) {
344
+ console.error(`\ncould not write ${OUT}: ${String(error?.message ?? error)}`)
345
+ process.exitCode = 1
346
+ }
347
+ }
348
+ }
349
+
350
+ main().catch((error) => {
351
+ console.error(String(error?.stack ?? error))
352
+ process.exitCode = 1
353
+ })