@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.
@@ -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
- // * It never probes a paid model. Only cost 0/0 models from the provider's
30
- // catalog are candidates (non-chat/media/safety/meta-router models are skipped).
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 free models per provider (default: 12)
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
- --timeout MS per-request deadline in ms (default: 20000)
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
- Only cost 0/0 concrete text chat/completion models are candidates
82
- (non-chat/media generators, safety classifiers, and OpenRouter meta-routers are skipped).
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", "12")) || 12)
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", "20000")) || 20_000)
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?.url && model?.api?.id)
153
- .filter(({ model }) => (model.cost?.input ?? 1) === 0 && (model.cost?.output ?? 1) === 0)
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.filter((m) => m.context == null || m.context >= minContext),
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
- response.status === 429
369
- ? "rate-limited"
370
- : response.status === 401 || response.status === 403
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) return { ok: false, ms, reason: `${parsed.error.message ?? "error"} `.trim() }
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
- return { ok: false, ms, reason: `session create http ${createRes.status}: ${detail}` }
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
- return { ok: false, ms, reason: `http ${msgRes.status}: ${detail}` }
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
- return { ok: false, ms, reason: failureText(failure).slice(0, 240) }
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
- return { ok: false, ms, reason: failureText(error).slice(0, 240) }
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 (SESSION_PROVIDERS.has(provider)) return probeViaSession(candidate, provider)
465
- return probeDirect(candidate, provider)
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} free model(s)${entry.models.length > candidates.length ? ` of ${entry.models.length}` : ""}${excludedNote}`)
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
- const outcome = await probe(candidate, entry.provider)
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 free models right now`)
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
- // 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.
567
- // Printed, never written — pool.txt stays the user's decision.
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) {
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), then the best others (weight 2)`)
579
- for (const { row, weight } of suggested) console.log(`${pad(row.model, 60)}${weight}`)
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
- providers: results.map((e) => ({
591
- provider: e.provider,
592
- error: e.error,
593
- excluded: e.excluded ?? [],
594
- rows: e.rows.map((r) => ({
595
- model: r.model,
596
- context: r.context,
597
- output: r.output,
598
- ok: r.ok,
599
- ms: r.ms,
600
- reason: r.reason,
601
- score: r.score?.value ?? null,
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}`)