@lawaty/lacode 2.1.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 +42 -0
- package/README.md +14 -0
- package/dist/agents.js +2 -0
- package/dist/commands.js +2 -0
- package/dist/index.js +2515 -2110
- package/dist/lacode.js +2 -0
- package/dist/pool.js +194 -18
- package/dist/tui.js +153 -25
- package/dist/tunnel.js +2 -0
- package/docs/CONFIGURATION.md +15 -1
- package/docs/USER-STORIES.md +56 -8
- package/package.json +2 -1
- package/presets/README.md +6 -6
- package/scripts/pool-selection.mjs +281 -0
- package/scripts/refresh-pool.mjs +149 -69
- package/scripts/score-models.mjs +227 -68
package/scripts/score-models.mjs
CHANGED
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
// measured score is below --min-score: those are listed as excluded instead.
|
|
27
27
|
// * It never writes pool.txt. It prints a SUGGESTED table for you to paste; the
|
|
28
28
|
// pool stays yours to decide.
|
|
29
|
-
// *
|
|
30
|
-
//
|
|
29
|
+
// * opencode and opencode-go stay free-only. Every other provider is probed
|
|
30
|
+
// even when its catalogue lists a price, because provider credits cover it;
|
|
31
|
+
// --free-only restores the cost-0 rule everywhere. Non-chat/media/safety/
|
|
32
|
+
// meta-router models are always skipped.
|
|
31
33
|
//
|
|
32
34
|
// OUTPUT
|
|
33
35
|
// a per-provider ranked table, an overall ranking, a suggested pool.txt block,
|
|
@@ -38,24 +40,48 @@ import { execFileSync, spawn } from "node:child_process"
|
|
|
38
40
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
39
41
|
import { homedir } from "node:os"
|
|
40
42
|
import { dirname, join } from "node:path"
|
|
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"
|
|
41
53
|
|
|
42
54
|
const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
|
|
43
55
|
const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
|
|
44
56
|
// No default provider list: `opencode models --verbose` with no argument returns
|
|
45
57
|
// every provider opencode knows about, and that is what we scan by default.
|
|
46
58
|
const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
|
|
59
|
+
const FREE_ONLY_PROVIDERS = new Set(["opencode", "opencode-go"])
|
|
47
60
|
|
|
48
61
|
// ── scoring weights ─────────────────────────────────────────────────────────
|
|
49
62
|
// Mechanical work prizes speed and availability over raw capability. Context is
|
|
50
63
|
// scored on a log curve so 256k vs 1M is a difference, not a cliff.
|
|
51
64
|
const W = { speed: 0.35, context: 0.3, output: 0.15, reliability: 0.2 }
|
|
52
65
|
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
|
|
55
66
|
const FAST_MS = 300 // at or below this, speed scores 1.0
|
|
56
67
|
const SLOW_MS = 8_000 // at or above this, speed scores 0
|
|
57
68
|
const PROMPT = "Reply with the single word: ok"
|
|
58
69
|
const MAX_TOKENS = 8
|
|
70
|
+
const PROBE_ATTEMPTS = 2 // one retry for a timeout or a dropped connection
|
|
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
|
+
}
|
|
59
85
|
|
|
60
86
|
// ── args ────────────────────────────────────────────────────────────────────
|
|
61
87
|
const argv = process.argv.slice(2)
|
|
@@ -70,31 +96,41 @@ const value = (flag, fallback) => {
|
|
|
70
96
|
console.log(`Usage: npm run score -- [options]
|
|
71
97
|
|
|
72
98
|
--providers a,b restrict the scan to these providers (default: all configured)
|
|
73
|
-
--max N probe at most N
|
|
99
|
+
--max N probe at most N candidate models per provider (default: 50)
|
|
74
100
|
--min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
|
|
75
101
|
--min-score N do not rank models scoring below N (default: ${MIN_SCORE})
|
|
76
|
-
--
|
|
102
|
+
--free-only only probe catalogue cost 0/0 models on every provider
|
|
103
|
+
--timeout MS per-request deadline in ms (default: 45000)
|
|
77
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)
|
|
78
108
|
--no-write do not write ~/.config/lacode/model-scores.json
|
|
79
109
|
-h, --help this message
|
|
80
110
|
|
|
81
|
-
|
|
82
|
-
|
|
111
|
+
opencode and opencode-go stay free-only; every other provider is probed even
|
|
112
|
+
when its catalogue lists a price, because provider credits cover it. Use
|
|
113
|
+
--free-only to apply the cost-0 rule everywhere. Non-chat/media generators,
|
|
114
|
+
safety classifiers, and OpenRouter meta-routers are always skipped.
|
|
83
115
|
Credentials come from ~/.local/share/opencode/auth.json; a provider with no
|
|
84
116
|
entry falls back to the "opencode" key (which is how the console-openrouter
|
|
85
117
|
connection authenticates).
|
|
86
|
-
opencode/opencode-go models are probed through a temporary local opencode serve --pure session, while the other providers are probed directly
|
|
118
|
+
opencode/opencode-go models are probed through a temporary local opencode serve --pure session, while the other providers are probed directly.
|
|
119
|
+
Providers with no direct HTTP endpoint (or an unexpanded \${...} URL) are probed through the same local opencode session as opencode/opencode-go.`)
|
|
87
120
|
process.exit(0)
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
const providerArg = value("--providers", null)
|
|
91
124
|
const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim()).filter(Boolean) : null
|
|
92
|
-
const maxPerProvider = Math.max(1, Number(value("--max", "
|
|
125
|
+
const maxPerProvider = Math.max(1, Number(value("--max", "50")) || 50)
|
|
93
126
|
const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
|
|
94
127
|
const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
|
|
95
|
-
const timeoutMs = Math.max(1_000, Number(value("--timeout", "
|
|
128
|
+
const timeoutMs = Math.max(1_000, Number(value("--timeout", "45000")) || 45_000)
|
|
96
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
|
|
97
132
|
const writeResults = !has("--no-write")
|
|
133
|
+
const freeOnlyFlag = has("--free-only")
|
|
98
134
|
|
|
99
135
|
// ── discovery ───────────────────────────────────────────────────────────────
|
|
100
136
|
/** Parse `opencode models --verbose <provider>`: alternating id line + JSON block. */
|
|
@@ -148,9 +184,11 @@ function isChatModel(model) {
|
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
function buildProviderEntry(provider, rows) {
|
|
187
|
+
const freeOnly = freeOnlyFlag || FREE_ONLY_PROVIDERS.has(provider)
|
|
188
|
+
const isFree = (model) => (model?.cost?.input ?? 1) === 0 && (model?.cost?.output ?? 1) === 0
|
|
151
189
|
const models = rows
|
|
152
|
-
.filter(({ model }) => model?.api?.
|
|
153
|
-
.filter(({ model }) =>
|
|
190
|
+
.filter(({ model }) => model?.api?.id)
|
|
191
|
+
.filter(({ model }) => !freeOnly || isFree(model))
|
|
154
192
|
.filter(({ model }) => !model.status || model.status === "active")
|
|
155
193
|
.filter(({ model }) => isChatModel(model))
|
|
156
194
|
.map(({ id, model }) => ({
|
|
@@ -160,6 +198,10 @@ function buildProviderEntry(provider, rows) {
|
|
|
160
198
|
context: model.limit?.context ?? null,
|
|
161
199
|
output: model.limit?.output ?? null,
|
|
162
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),
|
|
163
205
|
}))
|
|
164
206
|
const excluded = models
|
|
165
207
|
.filter((m) => m.context != null && m.context < minContext)
|
|
@@ -172,7 +214,9 @@ function buildProviderEntry(provider, rows) {
|
|
|
172
214
|
return {
|
|
173
215
|
provider,
|
|
174
216
|
error: null,
|
|
175
|
-
models: models
|
|
217
|
+
models: models
|
|
218
|
+
.filter((m) => m.context == null || m.context >= minContext)
|
|
219
|
+
.sort((a, b) => Number(b.free) - Number(a.free)),
|
|
176
220
|
excluded,
|
|
177
221
|
}
|
|
178
222
|
}
|
|
@@ -364,26 +408,25 @@ async function probeDirect(candidate, provider) {
|
|
|
364
408
|
|
|
365
409
|
if (!response.ok) {
|
|
366
410
|
const detail = parsed?.error?.message ?? body.slice(0, 160)
|
|
367
|
-
const kind =
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
? "credential"
|
|
372
|
-
: response.status === 404
|
|
373
|
-
? "not served"
|
|
374
|
-
: `http ${response.status}`
|
|
375
|
-
return { ok: false, ms, reason: `${kind}: ${detail}` }
|
|
411
|
+
const kind = classifyFailure({ status: response.status, reason: detail })
|
|
412
|
+
const 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 }
|
|
376
415
|
}
|
|
377
416
|
// A 200 can still carry an error object (OpenRouter does this).
|
|
378
|
-
if (parsed?.error)
|
|
417
|
+
if (parsed?.error) {
|
|
418
|
+
const reason = `${parsed.error.message ?? "error"} `.trim()
|
|
419
|
+
const kind = classifyFailure({ status: response.status, reason })
|
|
420
|
+
return { ok: false, ms, reason, status: response.status, kind, retryAfter: retryAfterMs(reason) }
|
|
421
|
+
}
|
|
379
422
|
if (!Array.isArray(parsed?.choices) || parsed.choices.length === 0) {
|
|
380
|
-
return { ok: false, ms, reason: "no choices in response" }
|
|
423
|
+
return { ok: false, ms, reason: "no choices in response", status: response.status, kind: "unknown" }
|
|
381
424
|
}
|
|
382
|
-
return { ok: true, ms, reason: "answered" }
|
|
425
|
+
return { ok: true, ms, reason: "answered", status: response.status, kind: null }
|
|
383
426
|
} catch (error) {
|
|
384
427
|
const ms = Math.round(performance.now() - started)
|
|
385
428
|
const name = error?.name === "TimeoutError" ? "timeout" : String(error?.message ?? error)
|
|
386
|
-
return { ok: false, ms, reason: name }
|
|
429
|
+
return { ok: false, ms, reason: name, kind: classifyFailure({ reason: name }) }
|
|
387
430
|
}
|
|
388
431
|
}
|
|
389
432
|
|
|
@@ -409,12 +452,20 @@ async function probeViaSession(candidate, provider) {
|
|
|
409
452
|
if (!createRes.ok) {
|
|
410
453
|
const detail = createdJson?.error?.message ?? createdJson?.message ?? createText.slice(0, 160)
|
|
411
454
|
const ms = Math.round(performance.now() - started)
|
|
412
|
-
|
|
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
|
+
}
|
|
413
464
|
}
|
|
414
465
|
id = createdJson?.id ?? createdJson?.data?.id ?? null
|
|
415
466
|
if (!id) {
|
|
416
467
|
const ms = Math.round(performance.now() - started)
|
|
417
|
-
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" }
|
|
418
469
|
}
|
|
419
470
|
promptStarted = performance.now()
|
|
420
471
|
const msgRes = await fetch(`${server.url}/session/${id}/message?directory=${directory}`, {
|
|
@@ -435,22 +486,33 @@ async function probeViaSession(candidate, provider) {
|
|
|
435
486
|
const ms = Math.round(performance.now() - (promptStarted ?? started))
|
|
436
487
|
if (!msgRes.ok) {
|
|
437
488
|
const detail = parsed?.error?.message ?? parsed?.message ?? msgText.slice(0, 160)
|
|
438
|
-
|
|
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
|
+
}
|
|
439
498
|
}
|
|
440
499
|
const failure = parsed?.error ?? parsed?.info?.error
|
|
441
500
|
if (failure) {
|
|
442
|
-
|
|
501
|
+
const reason = failureText(failure).slice(0, 240)
|
|
502
|
+
const kind = classifyFailure({ status: msgRes.status, reason })
|
|
503
|
+
return { ok: false, ms, reason, status: msgRes.status, kind, retryAfter: retryAfterMs(reason) }
|
|
443
504
|
}
|
|
444
505
|
if (!parsed?.info) {
|
|
445
|
-
return { ok: false, ms, reason: "no response" }
|
|
506
|
+
return { ok: false, ms, reason: "no response", status: msgRes.status, kind: "unknown" }
|
|
446
507
|
}
|
|
447
|
-
return { ok: true, ms, reason: "answered" }
|
|
508
|
+
return { ok: true, ms, reason: "answered", status: msgRes.status, kind: null }
|
|
448
509
|
} catch (error) {
|
|
449
510
|
const ms = Math.round(performance.now() - (promptStarted ?? started))
|
|
450
511
|
if (error?.name === "TimeoutError") {
|
|
451
|
-
return { ok: false, ms, reason: `timeout after ${timeoutMs}ms
|
|
512
|
+
return { ok: false, ms, reason: `timeout after ${timeoutMs}ms`, kind: "timeout" }
|
|
452
513
|
}
|
|
453
|
-
|
|
514
|
+
const reason = failureText(error).slice(0, 240)
|
|
515
|
+
return { ok: false, ms, reason, kind: classifyFailure({ reason }), retryAfter: retryAfterMs(reason) }
|
|
454
516
|
} finally {
|
|
455
517
|
if (id && server) {
|
|
456
518
|
try {
|
|
@@ -460,9 +522,27 @@ async function probeViaSession(candidate, provider) {
|
|
|
460
522
|
}
|
|
461
523
|
}
|
|
462
524
|
|
|
525
|
+
function usesSession(provider, candidate) {
|
|
526
|
+
if (SESSION_PROVIDERS.has(provider)) return true
|
|
527
|
+
const url = String(candidate.url ?? "")
|
|
528
|
+
return url.length === 0 || url.includes("${")
|
|
529
|
+
}
|
|
530
|
+
|
|
463
531
|
async function probe(candidate, provider) {
|
|
464
|
-
if (
|
|
465
|
-
|
|
532
|
+
if (usesSession(provider, candidate)) return probeViaSession(candidate, provider)
|
|
533
|
+
const outcome = await probeDirect(candidate, provider)
|
|
534
|
+
if (outcome.ok) return outcome
|
|
535
|
+
// A provider whose direct URL is misconfigured can still be reachable
|
|
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
|
+
) {
|
|
543
|
+
return probeViaSession(candidate, provider)
|
|
544
|
+
}
|
|
545
|
+
return outcome
|
|
466
546
|
}
|
|
467
547
|
|
|
468
548
|
// ── scoring ─────────────────────────────────────────────────────────────────
|
|
@@ -487,9 +567,23 @@ const padL = (s, n) => String(s).padStart(n)
|
|
|
487
567
|
|
|
488
568
|
async function main() {
|
|
489
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
|
+
)
|
|
490
575
|
const results = []
|
|
576
|
+
const deadline = Date.now() + budgetMs
|
|
577
|
+
const skippedProviders = []
|
|
578
|
+
let budgetHit = false
|
|
491
579
|
|
|
492
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
|
+
}
|
|
493
587
|
if (entry.error) {
|
|
494
588
|
console.log(`\n${entry.provider}: ${entry.error}`)
|
|
495
589
|
results.push({ provider: entry.provider, error: entry.error, rows: [], excluded: [] })
|
|
@@ -497,7 +591,7 @@ async function main() {
|
|
|
497
591
|
}
|
|
498
592
|
const candidates = entry.models.slice(0, maxPerProvider)
|
|
499
593
|
const excludedNote = entry.excluded.length ? `, ${entry.excluded.length} excluded (context < ${minContext})` : ""
|
|
500
|
-
console.log(`\n${entry.provider}: probing ${candidates.length}
|
|
594
|
+
console.log(`\n${entry.provider}: probing ${candidates.length} candidate model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}${excludedNote}`)
|
|
501
595
|
if (entry.excluded.length) {
|
|
502
596
|
console.log(` excluded — context below ${minContext}:`)
|
|
503
597
|
for (const x of entry.excluded) console.log(` ${pad(x.model.replace(`${entry.provider}/`, ""), 52)} ${padL(x.context, 9)}`)
|
|
@@ -508,7 +602,18 @@ async function main() {
|
|
|
508
602
|
}
|
|
509
603
|
const rows = []
|
|
510
604
|
for (const candidate of candidates) {
|
|
511
|
-
|
|
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
|
+
}
|
|
611
|
+
let outcome = await probe(candidate, entry.provider)
|
|
612
|
+
for (let attempt = 1; attempt < PROBE_ATTEMPTS && !outcome.ok && retryableProbe(outcome); attempt++) {
|
|
613
|
+
console.log(` retrying ${candidate.model.replace(`${entry.provider}/`, "")} after ${String(outcome.reason).slice(0, 60)}`)
|
|
614
|
+
await sleep(delayMs)
|
|
615
|
+
outcome = await probe(candidate, entry.provider)
|
|
616
|
+
}
|
|
512
617
|
const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
|
|
513
618
|
row.score = score(row)
|
|
514
619
|
if (row.score && row.score.value < minScore) {
|
|
@@ -525,7 +630,12 @@ async function main() {
|
|
|
525
630
|
console.log(` ${pad(candidate.model.replace(`${entry.provider}/`, ""), 52)} ${padL(verdict, 16)}`)
|
|
526
631
|
await sleep(delayMs)
|
|
527
632
|
}
|
|
528
|
-
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
|
+
)
|
|
529
639
|
}
|
|
530
640
|
stopSessionServer()
|
|
531
641
|
|
|
@@ -556,51 +666,100 @@ async function main() {
|
|
|
556
666
|
const all = results.flatMap((e) => e.rows).filter((r) => r.score && r.score.value >= minScore)
|
|
557
667
|
all.sort((a, b) => b.score.value - a.score.value)
|
|
558
668
|
if (all.length) {
|
|
559
|
-
console.log(`\noverall — best
|
|
669
|
+
console.log(`\noverall — best candidate models right now`)
|
|
560
670
|
all.slice(0, 12).forEach((r, i) => {
|
|
561
671
|
console.log(` ${pad(i + 1, 3)}${pad(r.model, 58)}${padL(`${r.ms}ms`, 8)}${padL(r.score.value, 7)}`)
|
|
562
672
|
})
|
|
563
673
|
}
|
|
564
674
|
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
675
|
+
// Previous scores let a transient outage keep the pool it had.
|
|
676
|
+
const previousScores = (() => {
|
|
677
|
+
try {
|
|
678
|
+
return JSON.parse(readFileSync(OUT, "utf8"))
|
|
679
|
+
} catch {
|
|
680
|
+
return null
|
|
681
|
+
}
|
|
682
|
+
})()
|
|
683
|
+
const { selection, warnings, retry } = selectPool({
|
|
684
|
+
providers: results.map((entry) => ({
|
|
685
|
+
provider: entry.provider,
|
|
686
|
+
error: entry.error,
|
|
687
|
+
truncated: entry.truncated,
|
|
688
|
+
rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
|
|
689
|
+
excluded: entry.excluded,
|
|
690
|
+
})),
|
|
691
|
+
previousProviders: previousScores?.providers,
|
|
692
|
+
minContext,
|
|
693
|
+
minScore,
|
|
694
|
+
})
|
|
695
|
+
if (budgetHit) {
|
|
696
|
+
warnings.push(`scan budget of ${budgetMin}m reached; providers not fully scanned keep their previous scores`)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (selection.length) {
|
|
577
700
|
console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
|
|
578
|
-
console.log(`# scored ${new Date().toISOString()} — opencode-go first (weight 4),
|
|
579
|
-
for (const
|
|
701
|
+
console.log(`# scored ${new Date().toISOString()} — opencode-go first (weight 4), small-context providers (weight 2), large-context providers (weight 1), opencode last (max 1)`)
|
|
702
|
+
for (const entry of selection) {
|
|
703
|
+
const bucket = entry.bucket ? ` b:${entry.bucket}` : ""
|
|
704
|
+
console.log(`${pad(entry.model, 60)}${entry.weight}${bucket}`)
|
|
705
|
+
}
|
|
580
706
|
}
|
|
707
|
+
if (retry) console.log(`\nlimited: ${retry.models.length} model(s); retry after ${retry.after}`)
|
|
581
708
|
|
|
582
709
|
if (writeResults) {
|
|
583
710
|
try {
|
|
584
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
|
+
}
|
|
585
723
|
const payload = {
|
|
586
724
|
generatedAt: new Date().toISOString(),
|
|
587
725
|
weights: W,
|
|
588
726
|
minContext,
|
|
589
727
|
minScore,
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
728
|
+
selection,
|
|
729
|
+
warnings,
|
|
730
|
+
retry,
|
|
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
|
+
}),
|
|
602
760
|
})),
|
|
603
|
-
|
|
761
|
+
...carried,
|
|
762
|
+
],
|
|
604
763
|
}
|
|
605
764
|
writeFileSync(OUT, JSON.stringify(payload, null, 2) + "\n")
|
|
606
765
|
console.log(`\nwrote ${OUT}`)
|