@lawaty/lacode 2.3.0-beta.4 → 2.3.0-beta.6

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.
@@ -46,13 +46,15 @@ import {
46
46
  classifyFailure,
47
47
  isTransientFailure,
48
48
  isTransientKind,
49
+ parsePoolDirectives,
50
+ parsePoolSlots,
49
51
  providerRank,
50
52
  retryAfterMs,
51
53
  selectPool,
52
54
  } from "./pool-selection.mjs"
53
55
 
54
56
  const AUTH = join(homedir(), ".local", "share", "opencode", "auth.json")
55
- const OUT = join(homedir(), ".config", "lacode", "model-scores.json")
57
+ const DEFAULT_OUT = join(homedir(), ".config", "lacode", "model-scores.json")
56
58
  // No default provider list: `opencode models --verbose` with no argument returns
57
59
  // every provider opencode knows about, and that is what we scan by default.
58
60
  const SESSION_PROVIDERS = new Set(["opencode", "opencode-go"])
@@ -100,6 +102,13 @@ const value = (flag, fallback) => {
100
102
  console.log(`Usage: npm run score -- [options]
101
103
 
102
104
  --providers a,b restrict the scan to these providers (default: all configured)
105
+ --probe-models a,b probe exactly these provider/model ids (canary: skips the
106
+ catalogue scan for everything else; unscanned providers keep
107
+ their previous rows)
108
+ --deprioritize-providers a,b probe these providers last (a live status hint;
109
+ never excludes anything, ordering only)
110
+ --current-pool PATH feed pool.txt as the current pool so selection retains
111
+ members this run could not reach (default: no retention)
103
112
  --max N probe at most N candidate models per provider (default: 50)
104
113
  --min-context N exclude models whose context limit is below N (default: ${MIN_CONTEXT})
105
114
  --min-score N do not rank models scoring below N (default: ${MIN_SCORE})
@@ -126,6 +135,16 @@ Providers with no direct HTTP endpoint (or an unexpanded \${...} URL) are probed
126
135
 
127
136
  const providerArg = value("--providers", null)
128
137
  const providers = providerArg ? String(providerArg).split(",").map((s) => s.trim()).filter(Boolean) : null
138
+ const probeModelsArg = value("--probe-models", null)
139
+ const probeModels = probeModelsArg ? new Set(String(probeModelsArg).split(",").map((s) => s.trim()).filter(Boolean)) : null
140
+ const deprioritizeArg = value("--deprioritize-providers", null)
141
+ const deprioritizedProviders = new Set(
142
+ deprioritizeArg ? String(deprioritizeArg).split(",").map((s) => s.trim()).filter(Boolean) : [],
143
+ )
144
+ // probe --probe-models ids absent from every catalogue. Filled during the
145
+ // catalogue filter above; emitted as synthetic not-served rows after probing.
146
+ const missingProbeModels = []
147
+ const currentPoolPath = value("--current-pool", null)
129
148
  const maxPerProvider = Math.max(1, Number(value("--max", "50")) || 50)
130
149
  const minContext = Math.max(0, Number(value("--min-context", String(MIN_CONTEXT))) || MIN_CONTEXT)
131
150
  const minScore = Math.max(0, Number(value("--min-score", String(MIN_SCORE))) || MIN_SCORE)
@@ -133,6 +152,7 @@ const timeoutMs = Math.max(1_000, Number(value("--timeout", "12000")) || 12_000)
133
152
  const delayMs = Math.max(0, Number(value("--delay", "300")) || 300)
134
153
  const budgetMin = Math.max(1, Number(value("--budget-min", "30")) || 30)
135
154
  const budgetMs = budgetMin * 60_000
155
+ const OUT = value("--out", DEFAULT_OUT)
136
156
  const writeResults = !has("--no-write")
137
157
  const freeOnlyFlag = has("--free-only")
138
158
 
@@ -570,12 +590,43 @@ const pad = (s, n) => String(s).padEnd(n)
570
590
  const padL = (s, n) => String(s).padStart(n)
571
591
 
572
592
  async function main() {
573
- const scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
593
+ const currentPoolText = (() => {
594
+ if (!currentPoolPath) return ""
595
+ try {
596
+ return readFileSync(currentPoolPath, "utf8")
597
+ } catch {
598
+ return ""
599
+ }
600
+ })()
601
+ const excludedProviders = parsePoolDirectives(currentPoolText)
602
+ let scanned = providers ? providers.map((provider) => freeModels(provider)) : freeModelsAll()
603
+ if (excludedProviders.size > 0) {
604
+ scanned = scanned.filter((entry) => !excludedProviders.has(entry.provider))
605
+ if (scanned.length === 0) console.log(`every provider was excluded by pool.txt directives; nothing to probe`)
606
+ }
574
607
  // 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.
608
+ // catalogues — and explicitly deprioritized providers last. Ordering only;
609
+ // every provider is still probed if time allows.
576
610
  scanned.sort(
577
- (a, b) => providerRank(a.provider) - providerRank(b.provider) || String(a.provider).localeCompare(String(b.provider)),
611
+ (a, b) =>
612
+ Number(deprioritizedProviders.has(a.provider)) - Number(deprioritizedProviders.has(b.provider)) ||
613
+ providerRank(a.provider) - providerRank(b.provider) ||
614
+ String(a.provider).localeCompare(String(b.provider)),
578
615
  )
616
+ const currentSlots = parsePoolSlots(currentPoolText).filter(
617
+ (slot) => !excludedProviders.has(String(slot.model).split("/")[0]),
618
+ )
619
+ if (probeModels && probeModels.size > 0) {
620
+ const seen = new Set()
621
+ scanned = scanned.flatMap((entry) => {
622
+ if (entry.error) return []
623
+ const models = (entry.models ?? []).filter((candidate) => probeModels.has(candidate.model))
624
+ models.forEach((candidate) => seen.add(candidate.model))
625
+ if (models.length === 0) return []
626
+ return [{ ...entry, models }]
627
+ })
628
+ missingProbeModels.push(...[...probeModels].filter((model) => !seen.has(model)))
629
+ }
579
630
  const results = []
580
631
  const deadline = Date.now() + budgetMs
581
632
  const skippedProviders = []
@@ -620,6 +671,12 @@ async function main() {
620
671
  outcome = await probe(candidate, entry.provider)
621
672
  }
622
673
  const kind = outcome.kind ?? classifyFailure({ status: outcome.status, reason: outcome.reason })
674
+ // Only cheap-failure kinds that also stall the scan count toward the
675
+ // breaker. A rate limit is deliberately NOT included: a 429 answers in
676
+ // milliseconds, so truncating on it saves little, and it would leave the
677
+ // provider's pool members unscanned -- which means no current failure row,
678
+ // so their consecutive-failure streak could never advance and they could
679
+ // never retire. Probe every model and record the evidence instead.
623
680
  const providerFault = !outcome.ok && ["timeout", "network", "server"].includes(kind)
624
681
  consecutiveTransient = providerFault ? consecutiveTransient + 1 : 0
625
682
  const row = { provider: entry.provider, ...candidate, ...outcome, score: null }
@@ -655,6 +712,32 @@ async function main() {
655
712
  }
656
713
  stopSessionServer()
657
714
 
715
+ // A canary model absent from every catalogue is not "unscanned" — it is
716
+ // gone. A synthetic not-served row makes the selector drop it (hard-dead)
717
+ // instead of retaining it blindly.
718
+ for (const model of missingProbeModels) {
719
+ const slash = model.indexOf("/")
720
+ const provider = slash === -1 ? "unknown" : model.slice(0, slash)
721
+ const row = {
722
+ provider,
723
+ model,
724
+ url: "",
725
+ apiId: slash === -1 ? model : model.slice(slash + 1),
726
+ context: null,
727
+ output: null,
728
+ ok: false,
729
+ ms: null,
730
+ reason: "not in provider catalogue (model-missing)",
731
+ status: 404,
732
+ kind: "not-served",
733
+ score: null,
734
+ retryAfter: null,
735
+ }
736
+ const entry = results.find((candidate) => candidate.provider === provider)
737
+ if (entry) entry.rows.push(row)
738
+ else results.push({ provider, error: null, rows: [row], excluded: [], truncated: false, degraded: false })
739
+ }
740
+
658
741
  // Per-provider ranked tables.
659
742
  for (const entry of results) {
660
743
  if (entry.error || entry.rows.length === 0) continue
@@ -696,15 +779,20 @@ async function main() {
696
779
  return null
697
780
  }
698
781
  })()
699
- const { selection, warnings, retry } = selectPool({
700
- providers: results.map((entry) => ({
701
- provider: entry.provider,
702
- error: entry.error,
703
- truncated: entry.truncated,
704
- rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
705
- excluded: entry.excluded,
706
- })),
707
- previousProviders: previousScores?.providers,
782
+ const { selection, fallback, warnings, retry } = selectPool({
783
+ providers: results
784
+ .filter((entry) => !excludedProviders.has(entry.provider))
785
+ .map((entry) => ({
786
+ provider: entry.provider,
787
+ error: entry.error,
788
+ truncated: entry.truncated,
789
+ rows: entry.rows.map((row) => ({ ...row, score: row.score?.value ?? null })),
790
+ excluded: entry.excluded,
791
+ })),
792
+ previousProviders: (previousScores?.providers ?? []).filter(
793
+ (entry) => !excludedProviders.has(String(entry?.provider ?? "")),
794
+ ),
795
+ currentSlots,
708
796
  minContext,
709
797
  minScore,
710
798
  })
@@ -716,6 +804,14 @@ async function main() {
716
804
  console.log(`\nsuggested pool.txt (paste if it matches your judgement — nothing was changed)`)
717
805
  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)`)
718
806
  for (const entry of selection) {
807
+ const ceiling = Number.isFinite(entry.ceiling) && entry.ceiling > entry.weight ? ` ceil:${entry.ceiling}` : ""
808
+ const bucket = entry.bucket ? ` b:${entry.bucket}` : ""
809
+ console.log(`${pad(entry.model, 60)}${entry.weight}${ceiling}${bucket}`)
810
+ }
811
+ }
812
+ if (fallback.length) {
813
+ console.log(`\nfallback section (used only when every primary slot is unavailable)`)
814
+ for (const entry of fallback) {
719
815
  const bucket = entry.bucket ? ` b:${entry.bucket}` : ""
720
816
  console.log(`${pad(entry.model, 60)}${entry.weight}${bucket}`)
721
817
  }
@@ -729,7 +825,9 @@ async function main() {
729
825
  // entry forward verbatim so the next run still sees it and selectPool can
730
826
  // keep its rankable models.
731
827
  const scannedNames = new Set(results.map((e) => e.provider))
732
- const carried = (previousScores?.providers ?? []).filter((p) => !scannedNames.has(String(p?.provider ?? "")))
828
+ const carried = (previousScores?.providers ?? []).filter(
829
+ (p) => !scannedNames.has(String(p?.provider ?? "")) && !excludedProviders.has(String(p?.provider ?? "")),
830
+ )
733
831
  const previousByModel = new Map()
734
832
  for (const provider of previousScores?.providers ?? []) {
735
833
  for (const row of provider?.rows ?? []) {
@@ -742,6 +840,7 @@ async function main() {
742
840
  minContext,
743
841
  minScore,
744
842
  selection,
843
+ fallback,
745
844
  warnings,
746
845
  retry,
747
846
  providers: [
@@ -763,14 +862,21 @@ async function main() {
763
862
  kind: r.kind ?? null,
764
863
  score: r.score?.value ?? null,
765
864
  retryAfter: r.retryAfter ?? null,
865
+ failStreak: 0,
766
866
  }
767
867
  // A transient failure must not erase the last measured ranking:
768
868
  // 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.
869
+ // gateway is down. But a model that keeps failing scan after scan
870
+ // is not having a bad moment, and the selector must be able to see
871
+ // that: carry a consecutive-failure count forward on both rows so
872
+ // it can retire the slot after MAX_TRANSIENT_STREAK scans. A
873
+ // successful scan emits a fresh row with a zero streak.
771
874
  if (r.ok === false && isTransientFailure(r)) {
772
875
  const prior = previousByModel.get(r.model)
773
- if (prior?.ok === true && typeof prior.score === "number") return [current, prior]
876
+ const failStreak = Number(prior?.failStreak ?? 0) + 1
877
+ const failed = { ...current, failStreak }
878
+ if (prior?.ok === true && typeof prior.score === "number") return [failed, { ...prior, failStreak }]
879
+ return [failed]
774
880
  }
775
881
  return [current]
776
882
  }),