@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
|
@@ -19,6 +19,13 @@ export const STATED_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000
|
|
|
19
19
|
// for a gateway hiccup or a short provider outage to clear, short enough that a
|
|
20
20
|
// recovered model is back well before the 6-hour timer.
|
|
21
21
|
export const SYNTHETIC_RETRY_MS = 45 * 60 * 1000
|
|
22
|
+
// A model that fails transiently scan after scan is not having a bad moment; it
|
|
23
|
+
// is a bad slot. Keep it (benched in place) for this many consecutive scans, then
|
|
24
|
+
// let it vacate the pool. A successful scan resets the streak, so a model that
|
|
25
|
+
// genuinely recovers returns on the next run. This is what stops a model that
|
|
26
|
+
// times out on every single request from being retained forever because the
|
|
27
|
+
// failure kind is nominally "transient".
|
|
28
|
+
export const MAX_TRANSIENT_STREAK = 3
|
|
22
29
|
|
|
23
30
|
// Scan order, not selection order: the pool's core providers are probed first so
|
|
24
31
|
// a wall-clock budget can never starve them behind a slow paid catalogue. This is
|
|
@@ -35,7 +42,7 @@ export const providerRank = (provider) => {
|
|
|
35
42
|
// classifier existed or by a provider that gives only prose.
|
|
36
43
|
const FAILURE_KINDS = [
|
|
37
44
|
["agreement", /model\s+agreement|terms\s+of\s+(?:service|use)|\btos\b/i],
|
|
38
|
-
["credit", /out\s+of\s+credits|insufficient\s+credits|credit\s+balance|requires\s+more\s+credits|payment\s+required|billing|purchase|\b402\b/i],
|
|
45
|
+
["credit", /out\s+of\s+credits|insufficient\s+credits|credit\s+balance|requires\s+more\s+credits|payment\s+required|billing|purchase|budget\s+exhausted|api\s+budget|quota\s+exhausted|\b402\b/i],
|
|
39
46
|
["rate-limited", /rate[\s_-]?limit|usage\s+limit|too\s+many\s+requests|overloaded|limits?[\s_-]?(?:reached|exceeded)|free[-\s]?tier|freelimiterror|quota|\b429\b/i],
|
|
40
47
|
["credential", /\b401\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]?api[\s_-]?key/i],
|
|
41
48
|
["not-served", /\b404\b|no\s+such\s+model|model\s+not\s+found|does\s+not\s+exist|is\s+not\s+available|not\s+served|not\s+found/i],
|
|
@@ -44,10 +51,19 @@ const FAILURE_KINDS = [
|
|
|
44
51
|
["server", /\b5\d\d\b|internal\s+server|bad\s+gateway|service\s+unavailable|gateway\s+timeout/i],
|
|
45
52
|
]
|
|
46
53
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
54
|
+
// An empty balance is not a bad moment: the provider has said it will not serve
|
|
55
|
+
// this account until someone tops it up (or the reset date arrives). Retaining
|
|
56
|
+
// the slot only benches a seat forever, so credit exhaustion vacates it like a
|
|
57
|
+
// missing model. A later scan that answers re-admits it automatically. Rate
|
|
58
|
+
// limits and outages are the kinds that stay retained.
|
|
59
|
+
const TRANSIENT_KINDS = new Set(["rate-limited", "timeout", "network", "server"])
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Failure kinds that vacate a pool slot for good: the model is gone, the
|
|
63
|
+
* credential is wrong, the terms are unsigned, or the balance is empty. Every
|
|
64
|
+
* other failure mode keeps the slot — benched, not deleted.
|
|
65
|
+
*/
|
|
66
|
+
export const HARD_DEAD_KINDS = new Set(["not-served", "credential", "agreement", "credit"])
|
|
51
67
|
|
|
52
68
|
export const isTransientKind = (kind) => TRANSIENT_KINDS.has(String(kind ?? "").toLowerCase())
|
|
53
69
|
|
|
@@ -127,9 +143,152 @@ const rowsOf = (providers) =>
|
|
|
127
143
|
|
|
128
144
|
const providerOf = (model) => String(model ?? "").split("/")[0]
|
|
129
145
|
|
|
146
|
+
// Which providers spend ONE shared quota window across their free models.
|
|
147
|
+
// `opencode` (Zen) is the known case: every free model draws from the same free
|
|
148
|
+
// bucket, which is why the primary selector tags it `b:opencode`. `opencode-go`
|
|
149
|
+
// is known to publish a per-model window. For any other provider the scorer
|
|
150
|
+
// cannot claim two models are independent, so fallback disjointness treats the
|
|
151
|
+
// provider as one shared window (the safe direction). The primary selection is
|
|
152
|
+
// unchanged unless a bucket is explicit or the provider is a known shared one.
|
|
153
|
+
export const SHARED_PROVIDER_BUCKETS = { opencode: "opencode" }
|
|
154
|
+
const PER_MODEL_PROVIDERS = new Set(["opencode-go"])
|
|
155
|
+
|
|
156
|
+
/** The quota group a candidate actually spends: declared, known-shared, or its own model. */
|
|
157
|
+
export function effectiveBucketFor(model, declared) {
|
|
158
|
+
if (declared) return declared
|
|
159
|
+
const provider = providerOf(model)
|
|
160
|
+
if (PER_MODEL_PROVIDERS.has(provider)) return model
|
|
161
|
+
return SHARED_PROVIDER_BUCKETS[provider] ?? provider
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The bucket that must be written into pool.txt for a slot, when it is a known shared one. */
|
|
165
|
+
export function bucketToWriteFor(model, declared) {
|
|
166
|
+
return declared ?? SHARED_PROVIDER_BUCKETS[providerOf(model)]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** How many disjoint models the secondary pool keeps. */
|
|
170
|
+
export const FALLBACK_POOL_SIZE = 5
|
|
171
|
+
// Fallback seats are real capacity, not a token gesture: give each enough weight
|
|
172
|
+
// to run work, and allow up to two models from one fallback provider so a
|
|
173
|
+
// provider with several distinct models is not reduced to a single w1 seat.
|
|
174
|
+
export const FALLBACK_WEIGHT = 2
|
|
175
|
+
const MAX_FALLBACK_PER_PROVIDER = 2
|
|
176
|
+
|
|
177
|
+
// The fallback-section marker inside pool.txt, shared with the runtime parser.
|
|
178
|
+
const FALLBACK_MARKER = /^(?:#\s*-*\s*fallback(?:\s+slots)?\s*-*|\[fallback\])$/i
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read `# exclude-provider: name[,name]` directives from a pool.txt header.
|
|
182
|
+
*
|
|
183
|
+
* A listed provider is not probed at all and is removed from the primary pool,
|
|
184
|
+
* the fallback pool, and the retention source. The directive lives in the
|
|
185
|
+
* header, which refresh preserves, so a provider whose account is out of budget
|
|
186
|
+
* (or otherwise unwanted) stays out across regenerations without hand-editing
|
|
187
|
+
* a generated file. Removing the line is all it takes to bring it back.
|
|
188
|
+
*/
|
|
189
|
+
export function parsePoolDirectives(text) {
|
|
190
|
+
const excluded = new Set()
|
|
191
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
192
|
+
const match = /^#\s*exclude-provider\s*:\s*(.+)$/i.exec(line.trim())
|
|
193
|
+
if (!match) continue
|
|
194
|
+
for (const name of match[1].split(/[,\s]+/)) {
|
|
195
|
+
const provider = name.trim()
|
|
196
|
+
if (provider) excluded.add(provider)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return excluded
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parse pool.txt slot lines into [{ model, weight, bucket?, context?, ceiling?, fallback? }].
|
|
204
|
+
* Comments and blanks are ignored; weight defaults to 1. A line that is exactly
|
|
205
|
+
* `# --- fallback ---` (or `[fallback]`) starts the fallback section.
|
|
206
|
+
*/
|
|
207
|
+
export function parsePoolSlots(text) {
|
|
208
|
+
const out = []
|
|
209
|
+
let section = "primary"
|
|
210
|
+
for (const line of String(text ?? "").split(/\r?\n/)) {
|
|
211
|
+
if (FALLBACK_MARKER.test(line.trim())) {
|
|
212
|
+
section = "fallback"
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
const code = line.split("#")[0].trim()
|
|
216
|
+
if (!code || !code.includes("/")) continue
|
|
217
|
+
const [model, weight] = code.split(/\s+/)
|
|
218
|
+
if (!model || !model.includes("/")) continue
|
|
219
|
+
const bucket = /b:([^\s]+)/.exec(code)?.[1]
|
|
220
|
+
const context = /c:(\d+)/.exec(code)?.[1]
|
|
221
|
+
const ceiling = /ceil:(\d+)/.exec(code)?.[1]
|
|
222
|
+
out.push({
|
|
223
|
+
model,
|
|
224
|
+
weight: Number(weight) > 0 ? Number(weight) : 1,
|
|
225
|
+
...(ceiling ? { ceiling: Number(ceiling) } : {}),
|
|
226
|
+
...(bucket ? { bucket } : {}),
|
|
227
|
+
context: context ? Number(context) : null,
|
|
228
|
+
...(section === "fallback" ? { fallback: true } : {}),
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Pure structural verdict for a pool slot list (formatted lines or selection
|
|
236
|
+
* entries). The contract: an opencode-go head (every go slot first, weight 4),
|
|
237
|
+
* exactly one opencode tail (last, weight 2, b:opencode), POOL_SIZE slots.
|
|
238
|
+
* Returns { valid, problems[] } — valid covers head+tail; count is reported
|
|
239
|
+
* separately so a short-but-structured pool reads as short, not broken.
|
|
240
|
+
*/
|
|
241
|
+
export function poolStructureValid(slots) {
|
|
242
|
+
const problems = []
|
|
243
|
+
const parsed =
|
|
244
|
+
typeof slots === "string"
|
|
245
|
+
? parsePoolSlots(slots)
|
|
246
|
+
: Array.isArray(slots)
|
|
247
|
+
? slots.flatMap((entry) => (typeof entry === "string" ? parsePoolSlots(entry) : [entry]))
|
|
248
|
+
: []
|
|
249
|
+
const lines = parsed.map((entry) => ({
|
|
250
|
+
model: entry?.model ?? "",
|
|
251
|
+
weight: Number(entry?.weight) || 0,
|
|
252
|
+
bucket: entry?.bucket,
|
|
253
|
+
fallback: entry?.fallback === true,
|
|
254
|
+
})).filter((entry) => entry.model.includes("/") && !entry.fallback)
|
|
255
|
+
const go = lines.filter((entry) => providerOf(entry.model) === "opencode-go")
|
|
256
|
+
const tails = lines.filter((entry) => providerOf(entry.model) === "opencode")
|
|
257
|
+
if (go.length === 0) problems.push("missing opencode-go head")
|
|
258
|
+
for (let i = 0; i < go.length; i++) {
|
|
259
|
+
if (lines[i] !== go[i]) {
|
|
260
|
+
problems.push("opencode-go slots must all come first")
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
if (go[i].weight !== 4) {
|
|
264
|
+
problems.push("opencode-go head slots must have weight 4")
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (tails.length !== 1) problems.push(`expected exactly one opencode tail, found ${tails.length}`)
|
|
269
|
+
else {
|
|
270
|
+
const tail = tails[0]
|
|
271
|
+
if (lines[lines.length - 1] !== tail) problems.push("opencode tail slot must be last")
|
|
272
|
+
if (tail.weight !== 2 || tail.bucket !== "opencode") problems.push("opencode tail must be weight 2 with b:opencode")
|
|
273
|
+
}
|
|
274
|
+
if (lines.length !== POOL_SIZE) problems.push(`expected ${POOL_SIZE} slots, found ${lines.length}`)
|
|
275
|
+
const structural = problems.filter((p) => !p.startsWith(`expected ${POOL_SIZE}`))
|
|
276
|
+
return { valid: structural.length === 0, problems, count: lines.length }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Hard-dead check for retention: only these failure kinds vacate a slot.
|
|
281
|
+
*/
|
|
282
|
+
export const isHardDeadRow = (row) => {
|
|
283
|
+
if (!row || row.ok !== false) return false
|
|
284
|
+
const kind = String(row.kind ?? classifyFailure({ status: row.status, reason: row.reason }) ?? "").toLowerCase()
|
|
285
|
+
return HARD_DEAD_KINDS.has(kind)
|
|
286
|
+
}
|
|
287
|
+
|
|
130
288
|
export function selectPool({
|
|
131
289
|
providers,
|
|
132
290
|
previousProviders,
|
|
291
|
+
currentSlots = [],
|
|
133
292
|
minContext = MIN_CONTEXT,
|
|
134
293
|
minScore = MIN_SCORE,
|
|
135
294
|
poolSize = POOL_SIZE,
|
|
@@ -141,7 +300,11 @@ export function selectPool({
|
|
|
141
300
|
typeof row.score === "number" &&
|
|
142
301
|
Number.isFinite(row.score) &&
|
|
143
302
|
row.score >= minScore &&
|
|
144
|
-
(row.context == null || row.context >= minContext)
|
|
303
|
+
(row.context == null || row.context >= minContext) &&
|
|
304
|
+
// A stale success carried beside a current failure is rankable only while the
|
|
305
|
+
// failure streak is short. Once the model has failed MAX_TRANSIENT_STREAK
|
|
306
|
+
// scans in a row, the old score stops being evidence about the present.
|
|
307
|
+
Number(row?.failStreak ?? 0) < MAX_TRANSIENT_STREAK
|
|
145
308
|
const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
|
|
146
309
|
const nowMs = typeof now === "function" ? Number(now()) : Number(now)
|
|
147
310
|
const normalize = (row) => {
|
|
@@ -154,6 +317,14 @@ export function selectPool({
|
|
|
154
317
|
const rows = rowsOf(providers).map(normalize)
|
|
155
318
|
const previousRows = rowsOf(previousProviders)
|
|
156
319
|
const currentByModel = new Map(rows.map((row) => [row.model, row]))
|
|
320
|
+
// A ceiling is the slot's hard concurrency cap (max parallel sessions). It is
|
|
321
|
+
// carried from the current pool through the report so a refresh never silently
|
|
322
|
+
// drops it; only a ceiling that still fits the slot's weight is kept.
|
|
323
|
+
const ceilingByModel = new Map(
|
|
324
|
+
(Array.isArray(currentSlots) ? currentSlots : [])
|
|
325
|
+
.filter((slot) => slot && typeof slot.model === "string" && Number.isFinite(Number(slot.ceiling)))
|
|
326
|
+
.map((slot) => [slot.model, Number(slot.ceiling)]),
|
|
327
|
+
)
|
|
157
328
|
const scannedProviders = new Set(
|
|
158
329
|
(Array.isArray(providers) ? providers : []).map((entry) => String(entry?.provider ?? "")),
|
|
159
330
|
)
|
|
@@ -237,14 +408,146 @@ export function selectPool({
|
|
|
237
408
|
|
|
238
409
|
const pickedZen = zen.slice(0, zenReserve)
|
|
239
410
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
411
|
+
const warnings = []
|
|
412
|
+
|
|
413
|
+
// Pool-level retention: a current member stays unless this run proved it
|
|
414
|
+
// hard-dead. A healthy-but-unselected model (caps, score floors) is NOT
|
|
415
|
+
// retained — the caps decided that. A transiently failed, unscanned, or
|
|
416
|
+
// budget-cut model is retained and benched in place by the writer; only a
|
|
417
|
+
// catalogue removal on a cleanly scanned provider vacates the slot. Head
|
|
418
|
+
// and tail are never vacated by a failed or partial scan.
|
|
419
|
+
const rowByModel = new Map(rows.map((row) => [row.model, row]))
|
|
420
|
+
const cleanlyScanned = (model) => {
|
|
421
|
+
const provider = providerOf(model)
|
|
422
|
+
return scannedProviders.has(provider) && !truncatedProviders.has(provider) && !erroredProviders.has(provider)
|
|
423
|
+
}
|
|
424
|
+
const placedModels = new Set([...go, ...cappedSmall, ...cappedLarge, ...pickedZen].map((row) => row.model))
|
|
425
|
+
const shouldRetain = (model) => {
|
|
426
|
+
if (!model.includes("/") || placedModels.has(model)) return false
|
|
427
|
+
const row = rowByModel.get(model)
|
|
428
|
+
// Retain a failed member only while its consecutive-failure streak is short.
|
|
429
|
+
// Past the cap the slot has proved it cannot serve; leaving it in the pool
|
|
430
|
+
// just benches a seat every refresh and hands work to it between refreshes.
|
|
431
|
+
if (row) return row.ok === false && !isHardDeadRow(row) && Number(row.failStreak ?? 0) < MAX_TRANSIENT_STREAK
|
|
432
|
+
return !cleanlyScanned(model)
|
|
433
|
+
}
|
|
434
|
+
const retainedHead = []
|
|
435
|
+
const retainedMiddle = []
|
|
436
|
+
let retainedTail = null
|
|
437
|
+
for (const slot of Array.isArray(currentSlots) ? currentSlots : []) {
|
|
438
|
+
const model = String(slot?.model ?? "")
|
|
439
|
+
if (!shouldRetain(model)) continue
|
|
440
|
+
const weight = Number(slot?.weight)
|
|
441
|
+
if (providerOf(model) === "opencode-go") {
|
|
442
|
+
retainedHead.push({ model, weight: 4, context: slot?.context ?? null, retained: true })
|
|
443
|
+
} else if (providerOf(model) === "opencode") {
|
|
444
|
+
if (!retainedTail) retainedTail = { model, weight: 2, bucket: "opencode", context: slot?.context ?? null, retained: true }
|
|
445
|
+
} else {
|
|
446
|
+
retainedMiddle.push({
|
|
447
|
+
model,
|
|
448
|
+
weight: Number.isFinite(weight) && weight > 0 ? weight : 2,
|
|
449
|
+
...(slot?.bucket ? { bucket: slot.bucket } : {}),
|
|
450
|
+
context: slot?.context ?? null,
|
|
451
|
+
retained: true,
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const toSmall = (row) => ({ model: row.model, weight: 2, context: row.context ?? null })
|
|
457
|
+
const toLarge = (row) => ({ model: row.model, weight: 1, context: row.context ?? null })
|
|
458
|
+
const middle = [...pickedSmall.map(toSmall), ...pickedLarge.map(toLarge)]
|
|
459
|
+
for (const entry of retainedMiddle) {
|
|
460
|
+
if (!middle.some((existing) => existing.model === entry.model)) middle.push(entry)
|
|
461
|
+
}
|
|
462
|
+
if (retainedTail && !pickedZen.some((row) => providerOf(row.model) === "opencode")) {
|
|
463
|
+
middle.push({ ...retainedTail })
|
|
464
|
+
retainedTail = { ...retainedTail, placed: true }
|
|
465
|
+
}
|
|
466
|
+
const head = [
|
|
467
|
+
...retainedHead,
|
|
468
|
+
...go.filter((row) => !retainedHead.some((entry) => entry.model === row.model)).map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
|
|
245
469
|
]
|
|
470
|
+
const tail = pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null }))
|
|
471
|
+
let selection = [...head, ...middle, ...tail]
|
|
472
|
+
// The report carries each slot's ceiling as a fact: a configured ceiling from
|
|
473
|
+
// the current pool survives regeneration, and a slot with none stays weight-only.
|
|
474
|
+
selection = selection.map((entry) => {
|
|
475
|
+
const ceiling = ceilingByModel.get(entry.model)
|
|
476
|
+
return ceiling !== undefined && ceiling >= Number(entry.weight) ? { ...entry, ceiling } : entry
|
|
477
|
+
})
|
|
478
|
+
// Two provider ids can front the same upstream model (an `openrouter` and a
|
|
479
|
+
// `console-openrouter` alias, for example). They draw on one quota window, so
|
|
480
|
+
// giving both a slot burns capacity twice and both fall over together. When
|
|
481
|
+
// the provider names are aliases (one is a suffix of the other) and the model
|
|
482
|
+
// id past the provider prefix is identical, keep only the first, score-ranked
|
|
483
|
+
// occurrence. A plain `provider/model` id is never collapsed, so different
|
|
484
|
+
// providers serving the same model name keep their independent slots.
|
|
485
|
+
const upstreamOf = (model) => {
|
|
486
|
+
const slash = String(model).indexOf("/")
|
|
487
|
+
const upstream = slash < 0 ? "" : String(model).slice(slash + 1)
|
|
488
|
+
return upstream.includes("/") ? upstream : ""
|
|
489
|
+
}
|
|
490
|
+
const seenUpstream = new Map()
|
|
491
|
+
selection = selection.filter((entry) => {
|
|
492
|
+
const upstream = upstreamOf(entry.model)
|
|
493
|
+
if (!upstream) return true
|
|
494
|
+
const provider = providerOf(entry.model)
|
|
495
|
+
const prior = seenUpstream.get(upstream)
|
|
496
|
+
if (prior && (provider.endsWith(prior) || prior.endsWith(provider))) return false
|
|
497
|
+
seenUpstream.set(upstream, provider)
|
|
498
|
+
return true
|
|
499
|
+
})
|
|
500
|
+
// Fit the contract size without ever vacating head or tail: trim the middle
|
|
501
|
+
// from the back (lowest score first), skipping retained slots. If even that
|
|
502
|
+
// cannot fit, the head wins and a warning says so.
|
|
503
|
+
while (selection.length > poolSize) {
|
|
504
|
+
let index = -1
|
|
505
|
+
for (let i = selection.length - 1; i >= 0; i--) {
|
|
506
|
+
const entry = selection[i]
|
|
507
|
+
if (!entry.retained && providerOf(entry.model) !== "opencode-go" && providerOf(entry.model) !== "opencode") {
|
|
508
|
+
index = i
|
|
509
|
+
break
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (index === -1) {
|
|
513
|
+
warnings.push(`pool head exceeds the contract size ${poolSize}; keeping every head slot`)
|
|
514
|
+
break
|
|
515
|
+
}
|
|
516
|
+
selection.splice(index, 1)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const retainedCount = selection.filter((entry) => entry.retained).length
|
|
520
|
+
if (retainedCount > 0) {
|
|
521
|
+
warnings.push(
|
|
522
|
+
`retained ${retainedCount} current slot(s) benched in place: ${selection
|
|
523
|
+
.filter((entry) => entry.retained)
|
|
524
|
+
.map((entry) => entry.model)
|
|
525
|
+
.join(", ")}`,
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// A fallback pool for the case where every primary slot is cooling at once.
|
|
530
|
+
// Candidates are score-ranked and must spend a different quota window from
|
|
531
|
+
// every primary slot: a same-bucket model would be benched by the same limit,
|
|
532
|
+
// so it cannot serve as a fallback. A provider may contribute up to
|
|
533
|
+
// MAX_FALLBACK_PER_PROVIDER distinct models; a shared bucket is written only
|
|
534
|
+
// when it is a known fact (Zen).
|
|
535
|
+
const selectedModels = new Set(selection.map((entry) => entry.model))
|
|
536
|
+
const primaryBuckets = new Set(selection.map((entry) => effectiveBucketFor(entry.model, entry.bucket)))
|
|
537
|
+
const fallbackSelection = []
|
|
538
|
+
const fallbackCounts = new Map()
|
|
539
|
+
for (const row of merge(() => true)) {
|
|
540
|
+
if (fallbackSelection.length >= FALLBACK_POOL_SIZE) break
|
|
541
|
+
if (!rankable(row) || selectedModels.has(row.model)) continue
|
|
542
|
+
if (primaryBuckets.has(effectiveBucketFor(row.model, undefined))) continue
|
|
543
|
+
const provider = providerOf(row.model)
|
|
544
|
+
const count = fallbackCounts.get(provider) ?? 0
|
|
545
|
+
if (count >= MAX_FALLBACK_PER_PROVIDER) continue
|
|
546
|
+
fallbackCounts.set(provider, count + 1)
|
|
547
|
+
const bucket = bucketToWriteFor(row.model, undefined)
|
|
548
|
+
fallbackSelection.push({ model: row.model, weight: FALLBACK_WEIGHT, ...(bucket ? { bucket } : {}), context: row.context ?? null })
|
|
549
|
+
}
|
|
246
550
|
|
|
247
|
-
const warnings = []
|
|
248
551
|
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
249
552
|
if (entry?.error) {
|
|
250
553
|
warnings.push(`${entry.provider}: ${entry.error}`)
|
|
@@ -280,5 +583,5 @@ export function selectPool({
|
|
|
280
583
|
}
|
|
281
584
|
: undefined
|
|
282
585
|
|
|
283
|
-
return { selection, warnings, retry }
|
|
586
|
+
return { selection, fallback: fallbackSelection, warnings, retry }
|
|
284
587
|
}
|