@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.
- package/CHANGELOG.md +39 -0
- package/README.md +13 -5
- package/dist/agents.js +1 -0
- package/dist/commands.js +6 -3
- package/dist/index.js +421 -161
- package/dist/lacode.js +1 -0
- package/dist/pool.js +152 -68
- package/dist/tunnel.js +1 -0
- package/docs/CONFIGURATION.md +15 -4
- package/docs/USER-STORIES.md +19 -2
- package/package.json +1 -1
- package/presets/README.md +35 -16
- package/presets/free-tier.json +17 -9
- package/scripts/pool-selection.mjs +316 -13
- package/scripts/refresh-pool.mjs +211 -94
- package/scripts/score-models.mjs +123 -17
package/scripts/score-models.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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(
|
|
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.
|
|
770
|
-
//
|
|
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
|
-
|
|
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
|
}),
|