@lawaty/lacode 2.2.0-beta.0 → 2.3.0-beta.1
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 +45 -0
- package/README.md +14 -0
- package/dist/agents.js +4 -0
- package/dist/commands.js +4 -0
- package/dist/index.js +2531 -2127
- package/dist/lacode.js +4 -0
- package/dist/pool.js +186 -27
- package/dist/tui.js +153 -25
- package/dist/tunnel.js +4 -0
- package/docs/CONFIGURATION.md +12 -0
- package/docs/USER-STORIES.md +59 -8
- package/package.json +1 -1
- package/scripts/pool-selection.mjs +112 -15
- package/scripts/refresh-pool.mjs +72 -9
- package/scripts/score-models.mjs +168 -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,24 @@ 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
|
+
// Three consecutive provider-level timeouts/network errors is enough evidence
|
|
72
|
+
// that the provider is degraded. Stop spending the scan budget on the rest of
|
|
73
|
+
// its catalogue; the previous rows are retained and a retry is scheduled.
|
|
74
|
+
const PROVIDER_BREAKER_AFTER = 3
|
|
75
|
+
/**
|
|
76
|
+
* Retry the same probe only when the next attempt has a chance. A known
|
|
77
|
+
* exhausted balance or a permanent 401/403/404 will not recover in 300ms; the
|
|
78
|
+
* selector schedules those with retryAfter instead. A bare gateway 402 is
|
|
79
|
+
* retried once, because that is often a transient edge, not a real balance.
|
|
80
|
+
*/
|
|
81
|
+
const retryableProbe = (outcome) => {
|
|
82
|
+
if (!outcome || outcome.ok) return false
|
|
83
|
+
const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
|
|
84
|
+
if (kind === "credit") {
|
|
85
|
+
return !/insufficient credits|out of credits|payment required|billing|purchase/i.test(String(outcome.reason ?? ""))
|
|
86
|
+
}
|
|
87
|
+
return isTransientKind(kind)
|
|
88
|
+
}
|
|
64
89
|
|
|
65
90
|
// ── args ────────────────────────────────────────────────────────────────────
|
|
66
91
|
const argv = process.argv.slice(2)
|
|
@@ -81,6 +106,9 @@ const value = (flag, fallback) => {
|
|
|
81
106
|
--free-only only probe catalogue cost 0/0 models on every provider
|
|
82
107
|
--timeout MS per-request deadline in ms (default: 45000)
|
|
83
108
|
--delay MS pause between requests in ms (default: 300)
|
|
109
|
+
--budget-min N stop starting new probes after N minutes and write what was
|
|
110
|
+
measured; providers not scanned keep their previous rows
|
|
111
|
+
(default: 30)
|
|
84
112
|
--no-write do not write ~/.config/lacode/model-scores.json
|
|
85
113
|
-h, --help this message
|
|
86
114
|
|
|
@@ -103,6 +131,8 @@ const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT)
|
|
|
103
131
|
const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
|
|
104
132
|
const timeoutMs = Math.max(1_000, Number(value("--timeout", "45000")) || 45_000)
|
|
105
133
|
const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
|
|
134
|
+
const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
|
|
135
|
+
const budgetMs = budgetMin * 60_000
|
|
106
136
|
const writeResults = !has("--no-write")
|
|
107
137
|
const freeOnlyFlag = has("--free-only")
|
|
108
138
|
|
|
@@ -159,9 +189,10 @@ function isChatModel(model) {
|
|
|
159
189
|
|
|
160
190
|
function buildProviderEntry(provider, rows) {
|
|
161
191
|
const freeOnly = freeOnlyFlag || FREE_ONLY_PROVIDERS.has(provider)
|
|
192
|
+
const isFree = (model) => (model?.cost?.input ?? 1) === 0 && (model?.cost?.output ?? 1) === 0
|
|
162
193
|
const models = rows
|
|
163
194
|
.filter(({ model }) => model?.api?.id)
|
|
164
|
-
.filter(({ model }) => !freeOnly || (
|
|
195
|
+
.filter(({ model }) => !freeOnly || isFree(model))
|
|
165
196
|
.filter(({ model }) => !model.status || model.status === "active")
|
|
166
197
|
.filter(({ model }) => isChatModel(model))
|
|
167
198
|
.map(({ id, model }) => ({
|
|
@@ -171,6 +202,10 @@ function buildProviderEntry(provider, rows) {
|
|
|
171
202
|
context: model.limit?.context ?? null,
|
|
172
203
|
output: model.limit?.output ?? null,
|
|
173
204
|
name: model.name ?? id,
|
|
205
|
+
// Ordering only: a zero-cost model is probed before a paid one, so a scan
|
|
206
|
+
// budget is spent on the models the pool is most likely to keep. It never
|
|
207
|
+
// excludes a paid model.
|
|
208
|
+
free: isFree(model),
|
|
174
209
|
}))
|
|
175
210
|
const excluded = models
|
|
176
211
|
.filter((m) => m.context != null && m.context < minContext)
|
|
@@ -183,7 +218,9 @@ function buildProviderEntry(provider, rows) {
|
|
|
183
218
|
return {
|
|
184
219
|
provider,
|
|
185
220
|
error: null,
|
|
186
|
-
models: models
|
|
221
|
+
models: models
|
|
222
|
+
.filter((m) => m.context == null || m.context >= minContext)
|
|
223
|
+
.sort((a, b) => Number(b.free) - Number(a.free)),
|
|
187
224
|
excluded,
|
|
188
225
|
}
|
|
189
226
|
}
|
|
@@ -375,33 +412,25 @@ async function probeDirect(candidate, provider) {
|
|
|
375
412
|
|
|
376
413
|
if (!response.ok) {
|
|
377
414
|
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}`
|
|
415
|
+
const kind = classifyFailure({ status: response.status, reason: detail })
|
|
386
416
|
const retryAfter =
|
|
387
|
-
kind === "rate-limited" ||
|
|
388
|
-
|
|
389
|
-
: undefined
|
|
390
|
-
return { ok: false, ms, reason: `${kind}: ${detail}`, retryAfter }
|
|
417
|
+
kind === "rate-limited" || kind === "credit" ? retryAfterMs(body, response.headers.get("retry-after")) : undefined
|
|
418
|
+
return { ok: false, ms, reason: `${kind}: ${detail}`, status: response.status, kind, retryAfter }
|
|
391
419
|
}
|
|
392
420
|
// A 200 can still carry an error object (OpenRouter does this).
|
|
393
421
|
if (parsed?.error) {
|
|
394
422
|
const reason = `${parsed.error.message ?? "error"} `.trim()
|
|
395
|
-
|
|
423
|
+
const kind = classifyFailure({ status: response.status, reason })
|
|
424
|
+
return { ok: false, ms, reason, status: response.status, kind, retryAfter: retryAfterMs(reason) }
|
|
396
425
|
}
|
|
397
426
|
if (!Array.isArray(parsed?.choices) || parsed.choices.length === 0) {
|
|
398
|
-
return { ok: false, ms, reason: "no choices in response" }
|
|
427
|
+
return { ok: false, ms, reason: "no choices in response", status: response.status, kind: "unknown" }
|
|
399
428
|
}
|
|
400
|
-
return { ok: true, ms, reason: "answered" }
|
|
429
|
+
return { ok: true, ms, reason: "answered", status: response.status, kind: null }
|
|
401
430
|
} catch (error) {
|
|
402
431
|
const ms = Math.round(performance.now() - started)
|
|
403
432
|
const name = error?.name === "TimeoutError" ? "timeout" : String(error?.message ?? error)
|
|
404
|
-
return { ok: false, ms, reason: name }
|
|
433
|
+
return { ok: false, ms, reason: name, kind: classifyFailure({ reason: name }) }
|
|
405
434
|
}
|
|
406
435
|
}
|
|
407
436
|
|
|
@@ -427,12 +456,20 @@ async function probeViaSession(candidate, provider) {
|
|
|
427
456
|
if (!createRes.ok) {
|
|
428
457
|
const detail = createdJson?.error?.message ?? createdJson?.message ?? createText.slice(0, 160)
|
|
429
458
|
const ms = Math.round(performance.now() - started)
|
|
430
|
-
|
|
459
|
+
const kind = classifyFailure({ status: createRes.status, reason: detail })
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
ms,
|
|
463
|
+
reason: `session create http ${createRes.status}: ${detail}`,
|
|
464
|
+
status: createRes.status,
|
|
465
|
+
kind,
|
|
466
|
+
retryAfter: retryAfterMs(detail),
|
|
467
|
+
}
|
|
431
468
|
}
|
|
432
469
|
id = createdJson?.id ?? createdJson?.data?.id ?? null
|
|
433
470
|
if (!id) {
|
|
434
471
|
const ms = Math.round(performance.now() - started)
|
|
435
|
-
return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}
|
|
472
|
+
return { ok: false, ms, reason: `no session id: ${createText.slice(0, 160)}`, status: createRes.status, kind: "unknown" }
|
|
436
473
|
}
|
|
437
474
|
promptStarted = performance.now()
|
|
438
475
|
const msgRes = await fetch(`${server.url}/session/${id}/message?directory=${directory}`, {
|
|
@@ -453,24 +490,33 @@ async function probeViaSession(candidate, provider) {
|
|
|
453
490
|
const ms = Math.round(performance.now() - (promptStarted ?? started))
|
|
454
491
|
if (!msgRes.ok) {
|
|
455
492
|
const detail = parsed?.error?.message ?? parsed?.message ?? msgText.slice(0, 160)
|
|
456
|
-
|
|
493
|
+
const kind = classifyFailure({ status: msgRes.status, reason: detail })
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
ms,
|
|
497
|
+
reason: `http ${msgRes.status}: ${detail}`,
|
|
498
|
+
status: msgRes.status,
|
|
499
|
+
kind,
|
|
500
|
+
retryAfter: retryAfterMs(detail),
|
|
501
|
+
}
|
|
457
502
|
}
|
|
458
503
|
const failure = parsed?.error ?? parsed?.info?.error
|
|
459
504
|
if (failure) {
|
|
460
505
|
const reason = failureText(failure).slice(0, 240)
|
|
461
|
-
|
|
506
|
+
const kind = classifyFailure({ status: msgRes.status, reason })
|
|
507
|
+
return { ok: false, ms, reason, status: msgRes.status, kind, retryAfter: retryAfterMs(reason) }
|
|
462
508
|
}
|
|
463
509
|
if (!parsed?.info) {
|
|
464
|
-
return { ok: false, ms, reason: "no response" }
|
|
510
|
+
return { ok: false, ms, reason: "no response", status: msgRes.status, kind: "unknown" }
|
|
465
511
|
}
|
|
466
|
-
return { ok: true, ms, reason: "answered" }
|
|
512
|
+
return { ok: true, ms, reason: "answered", status: msgRes.status, kind: null }
|
|
467
513
|
} catch (error) {
|
|
468
514
|
const ms = Math.round(performance.now() - (promptStarted ?? started))
|
|
469
515
|
if (error?.name === "TimeoutError") {
|
|
470
|
-
return { ok: false, ms, reason: `timeout after ${timeoutMs}ms
|
|
516
|
+
return { ok: false, ms, reason: `timeout after ${timeoutMs}ms`, kind: "timeout" }
|
|
471
517
|
}
|
|
472
518
|
const reason = failureText(error).slice(0, 240)
|
|
473
|
-
return { ok: false, ms, reason, retryAfter: retryAfterMs(reason) }
|
|
519
|
+
return { ok: false, ms, reason, kind: classifyFailure({ reason }), retryAfter: retryAfterMs(reason) }
|
|
474
520
|
} finally {
|
|
475
521
|
if (id && server) {
|
|
476
522
|
try {
|
|
@@ -491,8 +537,13 @@ async function probe(candidate, provider) {
|
|
|
491
537
|
const outcome = await probeDirect(candidate, provider)
|
|
492
538
|
if (outcome.ok) return outcome
|
|
493
539
|
// A provider whose direct URL is misconfigured can still be reachable
|
|
494
|
-
// through opencode's own provider resolution.
|
|
495
|
-
|
|
540
|
+
// through opencode's own provider resolution. A real 401/404 is worth the
|
|
541
|
+
// second path; a permanent agreement/credit refusal is not retried here.
|
|
542
|
+
if (
|
|
543
|
+
outcome.kind === "credential" ||
|
|
544
|
+
outcome.kind === "not-served" ||
|
|
545
|
+
/invalid request path|could not route/i.test(String(outcome.reason ?? ""))
|
|
546
|
+
) {
|
|
496
547
|
return probeViaSession(candidate, provider)
|
|
497
548
|
}
|
|
498
549
|
return outcome
|
|
@@ -520,9 +571,23 @@ const padL = (s, n) => String(s).padStart(n)
|
|
|
520
571
|
|
|
521
572
|
async function main() {
|
|
522
573
|
const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
|
|
574
|
+
// Core providers first, then the free-only middle tier, then the slow paid
|
|
575
|
+
// catalogues. Ordering only; every provider is still probed if time allows.
|
|
576
|
+
scanned.sort(
|
|
577
|
+
(a, b) => providerRank(a.provider) - providerRank(b.provider) || String(a.provider).localeCompare(String(b.provider)),
|
|
578
|
+
)
|
|
523
579
|
const results = []
|
|
580
|
+
const deadline = Date.now() + budgetMs
|
|
581
|
+
const skippedProviders = []
|
|
582
|
+
let budgetHit = false
|
|
524
583
|
|
|
525
584
|
for (const entry of scanned) {
|
|
585
|
+
if (Date.now() >= deadline) {
|
|
586
|
+
budgetHit = true
|
|
587
|
+
skippedProviders.push(entry.provider)
|
|
588
|
+
console.log(`\n${entry.provider}: skipped (scan budget of ${budgetMin}m reached)`)
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
526
591
|
if (entry.error) {
|
|
527
592
|
console.log(`\n${entry.provider}: ${entry.error}`)
|
|
528
593
|
results.push({ provider: entry.provider, error: entry.error, rows: [], excluded: [] })
|
|
@@ -540,13 +605,23 @@ async function main() {
|
|
|
540
605
|
continue
|
|
541
606
|
}
|
|
542
607
|
const rows = []
|
|
608
|
+
let consecutiveTransient = 0
|
|
543
609
|
for (const candidate of candidates) {
|
|
610
|
+
if (Date.now() >= deadline) {
|
|
611
|
+
budgetHit = true
|
|
612
|
+
entry.truncated = true
|
|
613
|
+
console.log(` scan budget reached; stopped ${entry.provider} after ${rows.length}/${candidates.length}`)
|
|
614
|
+
break
|
|
615
|
+
}
|
|
544
616
|
let outcome = await probe(candidate, entry.provider)
|
|
545
|
-
for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok &&
|
|
617
|
+
for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok && retryableProbe(outcome); attempt++) {
|
|
546
618
|
console.log(` retrying ${candidate.model.replace(`${entry.provider}/`, "")} after ${String(outcome.reason).slice(0, 60)}`)
|
|
547
619
|
await sleep(delayMs)
|
|
548
620
|
outcome = await probe(candidate, entry.provider)
|
|
549
621
|
}
|
|
622
|
+
const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
|
|
623
|
+
const providerFault = !outcome.ok && ["timeout", "network", "server"].includes(kind)
|
|
624
|
+
consecutiveTransient = providerFault ? consecutiveTransient + 1 : 0
|
|
550
625
|
const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
|
|
551
626
|
row.score = score(row)
|
|
552
627
|
if (row.score && row.score.value < minScore) {
|
|
@@ -561,9 +636,22 @@ async function main() {
|
|
|
561
636
|
rows.push(row)
|
|
562
637
|
const verdict = outcome.ok ? `${outcome.ms}ms` : outcome.reason.slice(0, 60)
|
|
563
638
|
console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
|
|
639
|
+
if (consecutiveTransient >= PROVIDER_BREAKER_AFTER) {
|
|
640
|
+
entry.truncated = true
|
|
641
|
+
entry.degraded = true
|
|
642
|
+
console.log(
|
|
643
|
+
` provider degraded: ${consecutiveTransient} consecutive ${kind} failures; stopped ${entry.provider} after ${rows.length}/${candidates.length}`,
|
|
644
|
+
)
|
|
645
|
+
break
|
|
646
|
+
}
|
|
564
647
|
await sleep(delayMs)
|
|
565
648
|
}
|
|
566
|
-
results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded })
|
|
649
|
+
results.push({ provider: entry.provider, error: null, rows, excluded: entry.excluded, truncated: !!entry.truncated, degraded: !!entry.degraded })
|
|
650
|
+
}
|
|
651
|
+
if (budgetHit) {
|
|
652
|
+
console.log(
|
|
653
|
+
`\nscan budget of ${budgetMin}m reached; ${skippedProviders.length} provider(s) skipped and any truncated provider keeps its previous rows.`,
|
|
654
|
+
)
|
|
567
655
|
}
|
|
568
656
|
stopSessionServer()
|
|
569
657
|
|
|
@@ -612,6 +700,7 @@ async function main() {
|
|
|
612
700
|
providers: results.map((entry) => ({
|
|
613
701
|
provider: entry.provider,
|
|
614
702
|
error: entry.error,
|
|
703
|
+
truncated: entry.truncated,
|
|
615
704
|
rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
|
|
616
705
|
excluded: entry.excluded,
|
|
617
706
|
})),
|
|
@@ -619,6 +708,9 @@ async function main() {
|
|
|
619
708
|
minContext,
|
|
620
709
|
minScore,
|
|
621
710
|
})
|
|
711
|
+
if (budgetHit) {
|
|
712
|
+
warnings.push(`scan budget of ${budgetMin}m reached; providers not fully scanned keep their previous scores`)
|
|
713
|
+
}
|
|
622
714
|
|
|
623
715
|
if (selection.length) {
|
|
624
716
|
console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
|
|
@@ -633,6 +725,17 @@ async function main() {
|
|
|
633
725
|
if (writeResults) {
|
|
634
726
|
try {
|
|
635
727
|
mkdirSync(dirname(OUT), { recursive: true })
|
|
728
|
+
// A provider the budget skipped has no current rows. Carry its previous
|
|
729
|
+
// entry forward verbatim so the next run still sees it and selectPool can
|
|
730
|
+
// keep its rankable models.
|
|
731
|
+
const scannedNames = new Set(results.map((e) => e.provider))
|
|
732
|
+
const carried = (previousScores?.providers ?? []).filter((p) => !scannedNames.has(String(p?.provider ?? "")))
|
|
733
|
+
const previousByModel = new Map()
|
|
734
|
+
for (const provider of previousScores?.providers ?? []) {
|
|
735
|
+
for (const row of provider?.rows ?? []) {
|
|
736
|
+
if (row?.model) previousByModel.set(row.model, row)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
636
739
|
const payload = {
|
|
637
740
|
generatedAt: new Date().toISOString(),
|
|
638
741
|
weights: W,
|
|
@@ -641,21 +744,39 @@ async function main() {
|
|
|
641
744
|
selection,
|
|
642
745
|
warnings,
|
|
643
746
|
retry,
|
|
644
|
-
providers:
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
747
|
+
providers: [
|
|
748
|
+
...results.map((e) => ({
|
|
749
|
+
provider: e.provider,
|
|
750
|
+
error: e.error,
|
|
751
|
+
truncated: !!e.truncated,
|
|
752
|
+
degraded: !!e.degraded,
|
|
753
|
+
excluded: e.excluded ?? [],
|
|
754
|
+
rows: e.rows.flatMap((r) => {
|
|
755
|
+
const current = {
|
|
756
|
+
model: r.model,
|
|
757
|
+
context: r.context,
|
|
758
|
+
output: r.output,
|
|
759
|
+
ok: r.ok,
|
|
760
|
+
ms: r.ms,
|
|
761
|
+
reason: r.reason,
|
|
762
|
+
status: r.status ?? null,
|
|
763
|
+
kind: r.kind ?? null,
|
|
764
|
+
score: r.score?.value ?? null,
|
|
765
|
+
retryAfter: r.retryAfter ?? null,
|
|
766
|
+
}
|
|
767
|
+
// A transient failure must not erase the last measured ranking:
|
|
768
|
+
// the next run needs it to keep a previously-good model while a
|
|
769
|
+
// gateway is down. Keep both rows; the selector prefers the
|
|
770
|
+
// rankable one and still schedules the failed one for retry.
|
|
771
|
+
if (r.ok === false && isTransientFailure(r)) {
|
|
772
|
+
const prior = previousByModel.get(r.model)
|
|
773
|
+
if (prior?.ok === true && typeof prior.score === "number") return [current, prior]
|
|
774
|
+
}
|
|
775
|
+
return [current]
|
|
776
|
+
}),
|
|
657
777
|
})),
|
|
658
|
-
|
|
778
|
+
...carried,
|
|
779
|
+
],
|
|
659
780
|
}
|
|
660
781
|
writeFileSync(OUT, JSON.stringify(payload, null, 2) + "\n")
|
|
661
782
|
console.log(`\nwrote ${OUT}`)
|