@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.
- package/CHANGELOG.md +29 -0
- package/README.md +14 -0
- package/dist/agents.js +2 -0
- package/dist/commands.js +2 -0
- package/dist/index.js +2472 -2118
- package/dist/lacode.js +2 -0
- package/dist/pool.js +154 -23
- package/dist/tui.js +153 -25
- package/dist/tunnel.js +2 -0
- package/docs/CONFIGURATION.md +12 -0
- package/docs/USER-STORIES.md +56 -8
- package/package.json +1 -1
- package/scripts/pool-selection.mjs +109 -15
- package/scripts/refresh-pool.mjs +72 -9
- package/scripts/score-models.mjs +151 -47
package/scripts/score-models.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
63
|
-
|
|
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 || (
|
|
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
|
|
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" ||
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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:
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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}`)
|