@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/dist/lacode.js
CHANGED
|
@@ -60,6 +60,7 @@ var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
|
60
60
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
61
61
|
var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
62
62
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
63
|
+
var TRANSIENT_UPSTREAM_COOLDOWN_MS = 60 * 1e3;
|
|
63
64
|
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
64
65
|
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
65
66
|
var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
package/dist/pool.js
CHANGED
|
@@ -39,14 +39,16 @@ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
|
|
|
39
39
|
var UNAVAILABLE_REASON = "endpoint-unavailable";
|
|
40
40
|
var PROVIDER_DEGRADED_REASON = "provider-degraded";
|
|
41
41
|
var PROVIDER_DEGRADED_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
42
|
+
var SCAN_KEPT_REASON = "scan-kept";
|
|
42
43
|
var PROVIDER_DEGRADED_WINDOW_MS = 10 * 60 * 1e3;
|
|
43
44
|
var PROVIDER_DEGRADED_MIN_MODELS = 2;
|
|
44
45
|
var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
|
|
45
46
|
var FLAT_OUTAGE_REASONS = /* @__PURE__ */ new Set([UNAVAILABLE_REASON, PROVIDER_DEGRADED_REASON]);
|
|
46
47
|
var MAX_WEIGHT = 100;
|
|
48
|
+
var MAX_CEILING = MAX_WEIGHT;
|
|
47
49
|
var MIN_SLOT_CONTEXT = 1e5;
|
|
48
50
|
var MIN_SLOT_TPM = 2e4;
|
|
49
|
-
var slotCeiling = (slot) => slot.weight;
|
|
51
|
+
var slotCeiling = (slot) => slot.ceiling ?? slot.weight;
|
|
50
52
|
var slotBucket = (slot) => slot.bucket ?? slot.model;
|
|
51
53
|
var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
|
|
52
54
|
var notifyDefault = (model) => {
|
|
@@ -66,7 +68,7 @@ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
|
66
68
|
var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
67
69
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
68
70
|
var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
69
|
-
var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
|
|
71
|
+
var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|budget|429/i.test(reason);
|
|
70
72
|
var MODEL_MISSING_REASON = "model-missing";
|
|
71
73
|
var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
|
|
72
74
|
var AUTH_FAILURE_REASON = "auth-failed";
|
|
@@ -75,6 +77,16 @@ var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\
|
|
|
75
77
|
var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
|
|
76
78
|
var AUTH_FAILURE_PATTERN = /\b401\b|\b403\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;
|
|
77
79
|
var isAuthFailureText = (text) => AUTH_FAILURE_PATTERN.test(text);
|
|
80
|
+
var ABORT_PATTERN = /MessageAbortedError|"name"\s*:\s*"[^"]*Abort/i;
|
|
81
|
+
var isAbortText = (text) => ABORT_PATTERN.test(text);
|
|
82
|
+
var INVALID_REQUEST_REASON = "invalid-request";
|
|
83
|
+
var INVALID_REQUEST_COOLDOWN_MS = MODEL_MISSING_COOLDOWN_MS;
|
|
84
|
+
var UNSUPPORTED_PARAMETER_PATTERN = /not\s+a\s+supported\s+value|unsupported\s+(?:value|parameter|field)|(?:invalid|unknown)\s+(?:value|parameter)\s+for|is\s+not\s+supported/i;
|
|
85
|
+
var isUnsupportedParameterText = (text) => UNSUPPORTED_PARAMETER_PATTERN.test(text);
|
|
86
|
+
var TRANSIENT_UPSTREAM_REASON = "upstream-transient";
|
|
87
|
+
var TRANSIENT_UPSTREAM_COOLDOWN_MS = 60 * 1e3;
|
|
88
|
+
var TRANSIENT_UPSTREAM_PATTERN = /reasoning_display/i;
|
|
89
|
+
var isTransientUpstreamText = (text) => TRANSIENT_UPSTREAM_PATTERN.test(text);
|
|
78
90
|
var BASES = ["explore", "implement", "context"];
|
|
79
91
|
var LEARNED_TPM_FILE = "learned-tpm.json";
|
|
80
92
|
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
@@ -101,64 +113,100 @@ var coerceNotify = (raw, at, model, problems) => {
|
|
|
101
113
|
problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
|
|
102
114
|
return notifyDefault(model);
|
|
103
115
|
};
|
|
116
|
+
var FALLBACK_MARKER = /^(?:#\s*-*\s*fallback(?:\s+slots)?\s*-*|\[fallback\])$/i;
|
|
117
|
+
var isFallbackMarker = (line) => FALLBACK_MARKER.test(String(line).trim());
|
|
118
|
+
function parseSlotEntry(entry, at, problems) {
|
|
119
|
+
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
|
|
120
|
+
if (!model.includes("/")) {
|
|
121
|
+
problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
|
|
122
|
+
return void 0;
|
|
123
|
+
}
|
|
124
|
+
const weight = readWeight(entry, at, model, problems);
|
|
125
|
+
const ceiling = readCeiling(entry, weight, at, model, problems);
|
|
126
|
+
const rawBucket = entry?.bucket;
|
|
127
|
+
const bucket = typeof rawBucket === "string" ? rawBucket.trim() : void 0;
|
|
128
|
+
if (rawBucket !== void 0 && !bucket) {
|
|
129
|
+
problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
|
|
130
|
+
}
|
|
131
|
+
const notify = coerceNotify(entry.notify, at, model, problems);
|
|
132
|
+
const rawContext = entry?.context;
|
|
133
|
+
let context;
|
|
134
|
+
if (rawContext !== void 0) {
|
|
135
|
+
const parsedContext = typeof rawContext === "number" ? rawContext : Number(rawContext);
|
|
136
|
+
if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
|
|
137
|
+
else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
|
|
138
|
+
}
|
|
139
|
+
if (context !== void 0 && context < MIN_SLOT_CONTEXT) {
|
|
140
|
+
problems.push(`${at} (${model}): context ${context} is below the ${MIN_SLOT_CONTEXT}-token minimum; dropping the slot`);
|
|
141
|
+
return void 0;
|
|
142
|
+
}
|
|
143
|
+
const rawTpm = entry?.tpm;
|
|
144
|
+
let tpm;
|
|
145
|
+
if (rawTpm !== void 0) {
|
|
146
|
+
const parsedTpm = typeof rawTpm === "number" ? rawTpm : Number(rawTpm);
|
|
147
|
+
if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
|
|
148
|
+
else problems.push(`${at} (${model}): tpm must be a positive number, got ${JSON.stringify(rawTpm)}; ignoring it`);
|
|
149
|
+
}
|
|
150
|
+
if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
|
|
151
|
+
problems.push(`${at} (${model}): tpm ${tpm} is below the ${MIN_SLOT_TPM}-token/minute minimum; dropping the slot`);
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
154
|
+
return { index: 0, model, weight, ...ceiling === void 0 ? {} : { ceiling }, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } };
|
|
155
|
+
}
|
|
104
156
|
function parseSlots(parsed, origin) {
|
|
105
157
|
const problems = [];
|
|
106
|
-
const
|
|
107
|
-
|
|
158
|
+
const primaryEntries = parsed?.slots;
|
|
159
|
+
const fallbackEntries = parsed?.fallbackSlots;
|
|
160
|
+
if ((!Array.isArray(primaryEntries) || primaryEntries.length === 0) && (!Array.isArray(fallbackEntries) || fallbackEntries.length === 0)) {
|
|
108
161
|
return { slots: FALLBACK_SLOTS, problems: [`${origin} has no non-empty "slots" array; using built-in defaults`] };
|
|
109
162
|
}
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
problems.push(`${at} (${model}): context ${context} is below the ${MIN_SLOT_CONTEXT}-token minimum; dropping the slot`);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
const rawTpm = entry?.tpm;
|
|
134
|
-
let tpm;
|
|
135
|
-
if (rawTpm !== void 0) {
|
|
136
|
-
const parsedTpm = typeof rawTpm === "number" ? rawTpm : Number(rawTpm);
|
|
137
|
-
if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
|
|
138
|
-
else problems.push(`${at} (${model}): tpm must be a positive number, got ${JSON.stringify(rawTpm)}; ignoring it`);
|
|
139
|
-
}
|
|
140
|
-
if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
|
|
141
|
-
problems.push(`${at} (${model}): tpm ${tpm} is below the ${MIN_SLOT_TPM}-token/minute minimum; dropping the slot`);
|
|
142
|
-
return;
|
|
163
|
+
const parseList = (list, fallback2) => {
|
|
164
|
+
if (!Array.isArray(list)) return [];
|
|
165
|
+
const out = [];
|
|
166
|
+
list.forEach((entry, i) => {
|
|
167
|
+
const slot = parseSlotEntry(entry, `${fallback2 ? "fallback slot" : "slot"} ${i + 1}`, problems);
|
|
168
|
+
if (slot) out.push({ ...slot, ...fallback2 ? { fallback: true } : {} });
|
|
169
|
+
});
|
|
170
|
+
return out;
|
|
171
|
+
};
|
|
172
|
+
const primary = parseList(primaryEntries, false);
|
|
173
|
+
let fallback = parseList(fallbackEntries, true);
|
|
174
|
+
const primaryBuckets = new Set(primary.map((slot) => slotBucket(slot)));
|
|
175
|
+
const primaryModels = new Set(primary.map((slot) => slot.model));
|
|
176
|
+
fallback = fallback.filter((slot) => {
|
|
177
|
+
const shared = primaryModels.has(slot.model) || primaryBuckets.has(slotBucket(slot));
|
|
178
|
+
if (shared) {
|
|
179
|
+
problems.push(
|
|
180
|
+
`fallback slot (${slot.model}): shares a limit window with the primary pool (${slotBucket(slot)}); dropping it`
|
|
181
|
+
);
|
|
182
|
+
return false;
|
|
143
183
|
}
|
|
144
|
-
|
|
184
|
+
return true;
|
|
145
185
|
});
|
|
186
|
+
const slots = [...primary, ...fallback].map((slot, i) => ({ ...slot, index: i + 1 }));
|
|
146
187
|
if (slots.length > SLOTS_WARN_AT) {
|
|
147
188
|
problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
|
|
148
189
|
}
|
|
149
|
-
if (
|
|
150
|
-
problems.push(`${origin} yielded no usable slot; using built-in defaults`);
|
|
190
|
+
if (primary.length === 0) {
|
|
191
|
+
problems.push(`${origin} yielded no usable primary slot; using built-in defaults`);
|
|
151
192
|
return { slots: FALLBACK_SLOTS, problems };
|
|
152
193
|
}
|
|
153
194
|
return { slots, problems };
|
|
154
195
|
}
|
|
155
196
|
function parseTextSlots(text, origin) {
|
|
156
197
|
const entries = [];
|
|
198
|
+
const fallbackEntries = [];
|
|
199
|
+
let section = "primary";
|
|
157
200
|
for (const raw of text.split(/\r?\n/)) {
|
|
201
|
+
if (isFallbackMarker(raw)) {
|
|
202
|
+
section = "fallback";
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
158
205
|
const line = raw.split("#")[0].trim();
|
|
159
206
|
if (!line) continue;
|
|
160
207
|
const [model, ...rest] = line.split(/\s+/);
|
|
161
208
|
let weight;
|
|
209
|
+
let ceiling;
|
|
162
210
|
let bucket;
|
|
163
211
|
let notify;
|
|
164
212
|
let context;
|
|
@@ -168,6 +216,13 @@ function parseTextSlots(text, origin) {
|
|
|
168
216
|
if (notify === void 0) notify = token.slice("notify:".length);
|
|
169
217
|
continue;
|
|
170
218
|
}
|
|
219
|
+
if (token.startsWith("ceil:")) {
|
|
220
|
+
if (ceiling === void 0) {
|
|
221
|
+
const parsedCeiling = Number(token.slice("ceil:".length));
|
|
222
|
+
if (Number.isFinite(parsedCeiling) && parsedCeiling > 0) ceiling = parsedCeiling;
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
171
226
|
if (token.startsWith("c:")) {
|
|
172
227
|
if (context === void 0) {
|
|
173
228
|
const parsedContext = Number(token.slice(2));
|
|
@@ -188,9 +243,11 @@ function parseTextSlots(text, origin) {
|
|
|
188
243
|
weight = token;
|
|
189
244
|
}
|
|
190
245
|
}
|
|
191
|
-
|
|
246
|
+
const entry = { model, ...weight === void 0 ? {} : { weight }, ...ceiling === void 0 ? {} : { ceiling }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } };
|
|
247
|
+
if (section === "fallback") fallbackEntries.push(entry);
|
|
248
|
+
else entries.push(entry);
|
|
192
249
|
}
|
|
193
|
-
return parseSlots({ slots: entries }, origin);
|
|
250
|
+
return parseSlots({ slots: entries, ...fallbackEntries.length > 0 ? { fallbackSlots: fallbackEntries } : {} }, origin);
|
|
194
251
|
}
|
|
195
252
|
function parseSlotText(text, origin) {
|
|
196
253
|
if (text.trimStart().startsWith("{")) {
|
|
@@ -295,6 +352,20 @@ function readWeight(entry, at, model, problems) {
|
|
|
295
352
|
}
|
|
296
353
|
return weight;
|
|
297
354
|
}
|
|
355
|
+
function readCeiling(entry, weight, at, model, problems) {
|
|
356
|
+
const raw = entry?.ceiling;
|
|
357
|
+
if (raw === void 0) return void 0;
|
|
358
|
+
const ceiling = typeof raw === "number" ? raw : Number(raw);
|
|
359
|
+
if (!Number.isFinite(ceiling) || ceiling <= 0 || ceiling > MAX_CEILING) {
|
|
360
|
+
problems.push(`${at} (${model}): ceiling must be a number in (0, ${MAX_CEILING}], got ${JSON.stringify(raw)}; using the weight`);
|
|
361
|
+
return void 0;
|
|
362
|
+
}
|
|
363
|
+
if (ceiling < weight) {
|
|
364
|
+
problems.push(`${at} (${model}): ceiling ${ceiling} is below weight ${weight}; using the weight as the ceiling`);
|
|
365
|
+
return void 0;
|
|
366
|
+
}
|
|
367
|
+
return Math.floor(ceiling);
|
|
368
|
+
}
|
|
298
369
|
var UNAVAILABLE_PATTERN = /endpoint\s+is\s+unavailable/i;
|
|
299
370
|
var LIMIT_PATTERNS = [
|
|
300
371
|
/rate[\s_-]?limit/i,
|
|
@@ -302,6 +373,7 @@ var LIMIT_PATTERNS = [
|
|
|
302
373
|
/too many requests/i,
|
|
303
374
|
/\b429\b/,
|
|
304
375
|
/quota/i,
|
|
376
|
+
/budget\s+exhausted|api\s+budget/i,
|
|
305
377
|
/freelimiterror/i,
|
|
306
378
|
/overloaded/i,
|
|
307
379
|
UNAVAILABLE_PATTERN
|
|
@@ -345,7 +417,7 @@ function parseRateLimitHeaders(headers) {
|
|
|
345
417
|
}
|
|
346
418
|
return void 0;
|
|
347
419
|
}
|
|
348
|
-
var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
|
|
420
|
+
var LIMIT_REASON_PATTERN = /rate[\s_-]?limit|usage limit|too many requests|quota|budget\s+exhausted|freelimiterror|overloaded|limits?[\s_-]?(?:reached|exceeded)/i;
|
|
349
421
|
var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
|
|
350
422
|
var limitReason = (text) => {
|
|
351
423
|
if (!isLimitText(text)) return void 0;
|
|
@@ -435,7 +507,7 @@ function ensureSlots(s, onWarn) {
|
|
|
435
507
|
for (const problem of problems) onWarn(problem);
|
|
436
508
|
return slots;
|
|
437
509
|
}
|
|
438
|
-
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}:${slot.tpm ?? ""}`).join("|")}`;
|
|
510
|
+
var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.ceiling ?? ""}:${slot.bucket ?? ""}:${slotNotify(slot) ? "1" : "0"}:${slot.tpm ?? ""}:${slot.fallback ? "f" : "p"}`).join("|")}`;
|
|
439
511
|
function reloadSlots(s, onWarn) {
|
|
440
512
|
const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
|
|
441
513
|
const effective = applyLearnedTpm(s, slots, onWarn);
|
|
@@ -695,7 +767,7 @@ function readLimits(s) {
|
|
|
695
767
|
try {
|
|
696
768
|
const strike = JSON.parse(readFileSync2(path, "utf8"));
|
|
697
769
|
if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
|
|
698
|
-
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
|
|
770
|
+
const countsAsStrike = strike.reason !== UNAVAILABLE_REASON && strike.reason !== SCAN_KEPT_REASON;
|
|
699
771
|
if (strike.until > now) {
|
|
700
772
|
cooling.set(strike.model, strike);
|
|
701
773
|
if (countsAsStrike) memory.set(strike.model, strike);
|
|
@@ -849,52 +921,54 @@ function snapshot(s) {
|
|
|
849
921
|
}
|
|
850
922
|
var loadOf = (counts, slot) => counts.get(slot) ?? 0;
|
|
851
923
|
var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
|
|
924
|
+
var fillPassIndex = (slot, counts) => Math.floor(loadOf(counts, slot.index) / Math.max(1, slot.weight));
|
|
925
|
+
var pickByPass = (group, counts, prefer) => {
|
|
926
|
+
const open = group.filter((slot) => underCeiling(slot, counts));
|
|
927
|
+
if (open.length === 0) return void 0;
|
|
928
|
+
const lowest = Math.min(...open.map((slot) => fillPassIndex(slot, counts)));
|
|
929
|
+
const atPass = open.filter((slot) => fillPassIndex(slot, counts) === lowest);
|
|
930
|
+
return prefer ? atPass.reduce(prefer, atPass[0]) : atPass[0];
|
|
931
|
+
};
|
|
852
932
|
function pickSlot(s, snap, neededContext) {
|
|
853
933
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
854
|
-
const
|
|
934
|
+
const primary = slots.filter((slot) => !slot.fallback);
|
|
935
|
+
const fallback = slots.filter((slot) => slot.fallback);
|
|
936
|
+
const usablePrimary = primary.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
937
|
+
const usable = usablePrimary.length > 0 ? usablePrimary : fallback.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
855
938
|
if (usable.length === 0) return void 0;
|
|
856
939
|
const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
|
|
940
|
+
const leastLoaded = (best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best;
|
|
857
941
|
if (neededContext === void 0) {
|
|
858
|
-
|
|
859
|
-
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
860
|
-
}
|
|
861
|
-
return usable.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, usable[0]);
|
|
942
|
+
return pickByPass(usable, snap.counts) ?? usable.reduce(leastLoaded, usable[0]);
|
|
862
943
|
}
|
|
863
944
|
const adequate = usable.filter((slot) => effective(slot) >= neededContext);
|
|
864
945
|
const adequateKnown = adequate.filter((slot) => Number.isFinite(effective(slot)));
|
|
865
946
|
const candidates = adequate.length > 0 ? adequate : usable;
|
|
947
|
+
const smallest = (best, slot) => Number.isFinite(effective(slot)) && effective(slot) < effective(best) ? slot : best;
|
|
866
948
|
if (adequateKnown.length === 0) {
|
|
867
949
|
const known = usable.filter((slot) => Number.isFinite(effective(slot)));
|
|
868
950
|
if (known.length > 0) {
|
|
869
951
|
const largest = Math.max(...known.map((slot) => effective(slot)));
|
|
870
952
|
const largestSlots = known.filter((slot) => effective(slot) === largest);
|
|
871
|
-
|
|
872
|
-
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
873
|
-
}
|
|
874
|
-
return largestSlots.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, largestSlots[0]);
|
|
953
|
+
return pickByPass(largestSlots, snap.counts) ?? largestSlots.reduce(leastLoaded, largestSlots[0]);
|
|
875
954
|
}
|
|
876
|
-
|
|
877
|
-
if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
|
|
878
|
-
}
|
|
879
|
-
return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
|
|
880
|
-
}
|
|
881
|
-
const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
|
|
882
|
-
if (underWeight.length > 0) {
|
|
883
|
-
const known = underWeight.filter((slot) => Number.isFinite(effective(slot)));
|
|
884
|
-
if (known.length === 0) return underWeight[0];
|
|
885
|
-
return known.reduce((best, slot) => effective(slot) < effective(best) ? slot : best, known[0]);
|
|
955
|
+
return pickByPass(candidates, snap.counts) ?? candidates.reduce(leastLoaded, candidates[0]);
|
|
886
956
|
}
|
|
887
|
-
return candidates
|
|
957
|
+
return pickByPass(candidates, snap.counts, smallest) ?? candidates.reduce(leastLoaded, candidates[0]);
|
|
888
958
|
}
|
|
889
959
|
function poolPass(s, snap, neededContext) {
|
|
890
960
|
const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
|
|
891
|
-
const
|
|
961
|
+
const primary = slots.filter((slot) => !slot.fallback);
|
|
962
|
+
const fallback = slots.filter((slot) => slot.fallback);
|
|
963
|
+
const usablePrimary = primary.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
964
|
+
const usable = usablePrimary.length > 0 ? usablePrimary : fallback.filter((slot) => isAvailable(s, slot.model, snap.cooling));
|
|
892
965
|
if (usable.length === 0) return "none";
|
|
893
966
|
const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
|
|
894
967
|
const candidates = neededContext === void 0 ? usable : usable.filter((slot) => effective(slot) >= neededContext);
|
|
895
968
|
const pool = candidates.length > 0 ? candidates : usable;
|
|
896
|
-
|
|
897
|
-
return "overflow";
|
|
969
|
+
const open = pool.filter((slot) => underCeiling(slot, snap.counts));
|
|
970
|
+
if (open.length === 0) return "overflow";
|
|
971
|
+
return Math.min(...open.map((slot) => fillPassIndex(slot, snap.counts))) + 1;
|
|
898
972
|
}
|
|
899
973
|
function agentFor(base, slot) {
|
|
900
974
|
return `${base}-${slot}`;
|
|
@@ -1083,7 +1157,10 @@ export {
|
|
|
1083
1157
|
FALLBACK_SLOTS,
|
|
1084
1158
|
HANG_COOLDOWN_MS,
|
|
1085
1159
|
HEARTBEAT_PUBLISH_MS,
|
|
1160
|
+
INVALID_REQUEST_COOLDOWN_MS,
|
|
1161
|
+
INVALID_REQUEST_REASON,
|
|
1086
1162
|
LEARNED_TPM_FILE,
|
|
1163
|
+
MAX_CEILING,
|
|
1087
1164
|
MAX_CLAIM_AGE_MS,
|
|
1088
1165
|
MAX_WEIGHT,
|
|
1089
1166
|
MIN_SLOT_CONTEXT,
|
|
@@ -1098,11 +1175,14 @@ export {
|
|
|
1098
1175
|
PROVIDER_LIST_TTL_MS,
|
|
1099
1176
|
QUOTA_COOLDOWN_BASE_MS,
|
|
1100
1177
|
QUOTA_COOLDOWN_MAX_MS,
|
|
1178
|
+
SCAN_KEPT_REASON,
|
|
1101
1179
|
SLOTS_WARN_AT,
|
|
1102
1180
|
STATED_COOLDOWN_MAX_MS,
|
|
1103
1181
|
STATE_SWEEP_INTERVAL_MS,
|
|
1104
1182
|
STUCK_IDLE_MS,
|
|
1105
1183
|
STUCK_MIN_AGE_MS,
|
|
1184
|
+
TRANSIENT_UPSTREAM_COOLDOWN_MS,
|
|
1185
|
+
TRANSIENT_UPSTREAM_REASON,
|
|
1106
1186
|
UNAVAILABLE_COOLDOWN_MS,
|
|
1107
1187
|
UNAVAILABLE_REASON,
|
|
1108
1188
|
UNBOUND_CLAIM_MS,
|
|
@@ -1125,16 +1205,20 @@ export {
|
|
|
1125
1205
|
findStuck,
|
|
1126
1206
|
idleFor,
|
|
1127
1207
|
init,
|
|
1208
|
+
isAbortText,
|
|
1128
1209
|
isAuthFailureText,
|
|
1129
1210
|
isAvailable,
|
|
1130
1211
|
isContextOverflowText,
|
|
1212
|
+
isFallbackMarker,
|
|
1131
1213
|
isLimitText,
|
|
1132
1214
|
isModelMissingText,
|
|
1133
1215
|
isProbe,
|
|
1134
1216
|
isQuotaReason,
|
|
1135
1217
|
isRequestTooLargeText,
|
|
1136
1218
|
isTransientGatewayText,
|
|
1219
|
+
isTransientUpstreamText,
|
|
1137
1220
|
isUnavailableText,
|
|
1221
|
+
isUnsupportedParameterText,
|
|
1138
1222
|
limitReason,
|
|
1139
1223
|
manualVariant,
|
|
1140
1224
|
markProbe,
|
package/dist/tunnel.js
CHANGED
|
@@ -53,6 +53,7 @@ var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
|
|
|
53
53
|
var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
|
|
54
54
|
var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
|
|
55
55
|
var STRIKE_DECAY_MS = 30 * 60 * 1e3;
|
|
56
|
+
var TRANSIENT_UPSTREAM_COOLDOWN_MS = 60 * 1e3;
|
|
56
57
|
var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
|
|
57
58
|
var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
|
|
58
59
|
var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -23,10 +23,21 @@ allowlist anywhere in the package, and no cost check of any kind: only the
|
|
|
23
23
|
structure is validated. The bundled preset is built from free models because
|
|
24
24
|
that is a sensible default, not because the pool requires it.
|
|
25
25
|
|
|
26
|
-
A slot's `weight` (default 1) is
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
weight.
|
|
26
|
+
A slot's `weight` (default 1) is its per-pass concurrency budget. Its hard cap is
|
|
27
|
+
its `ceiling` (max parallel sessions), which defaults to the weight and can be
|
|
28
|
+
raised with a `ceiling` field (flat form `ceil:<n>`; it must be at least the
|
|
29
|
+
weight). The pool fills slots in declaration order; when every usable slot has
|
|
30
|
+
reached its weight, later passes deepen a slot toward its ceiling. There is no
|
|
31
|
+
slot cap, and a model may appear on more than one line — each line is its own
|
|
32
|
+
slot with its own weight and ceiling. A `tier` key is the deprecated spelling of
|
|
33
|
+
`weight`.
|
|
34
|
+
|
|
35
|
+
A slot table may also declare a **fallback pool**: a `fallbackSlots` array in
|
|
36
|
+
JSON, or a line that is exactly `# --- fallback ---` (or `[fallback]`) followed
|
|
37
|
+
by more slots in `pool.txt`. Fallback slots are used only when no primary slot is
|
|
38
|
+
usable — they are not overflow capacity. A fallback slot that shares a quota
|
|
39
|
+
bucket (or model) with any primary slot is dropped with a warning, because the
|
|
40
|
+
same limit would bench both.
|
|
30
41
|
|
|
31
42
|
A slot may also declare a shared quota bucket with `b:<name>` on the line (or a
|
|
32
43
|
`bucket` field in JSON). A limit on any member benches the whole bucket, because
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -15,7 +15,7 @@ line. Criteria that cannot be observed without a running server are not in this
|
|
|
15
15
|
document; everything here is testable offline with a stub client and a
|
|
16
16
|
`mkdtempSync` state directory.
|
|
17
17
|
|
|
18
|
-
**Terminology.** A *slot* is one entry in `presets/free-tier.json`, carrying a model and
|
|
18
|
+
**Terminology.** A *slot* is one entry in `presets/free-tier.json`, carrying a model, a `weight` and an optional `ceiling`. A slot's `weight` is its per-pass budget: the number of concurrent claims one pass may add before the next slot is preferred. Its `ceiling` (max parallel sessions) is the hard cap and defaults to the weight. A *pass* is one declaration-order sweep of the slot list; when every usable slot has reached its weight, later passes fill toward each slot's ceiling. There is no slot cap, and a model may appear on more than one line — each line is its own slot with its own weight and ceiling. Preference is by declaration order, never by weight. A *base* is a pooled agent type (`explore`), and its slot holders are `<base>-<slot>`, hidden in `opencode.jsonc`. A *claim* is a running spawn recorded in `claims.<pid>.json`. A *context map* is the five-file `.opencode/context/` codemap described in Epic G.
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -202,6 +202,22 @@ A refresh once replaced a healthy ten-slot pool with a two-slot scan because not
|
|
|
202
202
|
- [ ] Given a thin or missing pool, When a fuller scan completes, Then it is always written — the guard only ever blocks shrinking, never recovery.
|
|
203
203
|
- [ ] Given the status endpoint is unreachable, When the refresh runs, Then the scan proceeds as before, because an unknown signal must never block a refresh and the write guard still protects the pool.
|
|
204
204
|
|
|
205
|
+
### US-41: The pool keeps its structure through any outage and shows its state
|
|
206
|
+
**As a** user relying on the pool, **I want** members to be benched in place instead of deleted, recovery to be cheap, and the slot states visible, **so that** no outage can shrink the pool, no recovery takes longer than minutes, and I can see what each slot is doing.
|
|
207
|
+
|
|
208
|
+
Selection retains current members unless the run proved them hard-dead (gone, credential, agreement, credit); a healthy-but-capped model is still decided by the caps. The refresh writes a flat `scan-kept` cooling record for every kept-but-transiently-failed member — the runtime already honours it and re-admits the model on the first answering probe — and clears stale benches for members that answered. The status page is a hint only: sick providers probe last, nothing is ever skipped or benched because of it. Degraded retries run a canary over the current members first and scan fully only when one stops answering. `pool_status` shows each cooling slot's absolute expiry, the structural verdict, the last refresh age, and the status hint.
|
|
209
|
+
|
|
210
|
+
**Story ID(s) for tests:** US-41
|
|
211
|
+
**Acceptance criteria:**
|
|
212
|
+
- [ ] Given a current member whose probe failed transiently, When selection runs, Then the member is retained in place (marked retained) with its pool weight, and head/tail positions are never vacated.
|
|
213
|
+
- [ ] Given a current member that is healthy but capped out, When selection runs, Then it is not retained — the caps decide.
|
|
214
|
+
- [ ] Given a model absent from a cleanly scanned catalogue, When selection runs, Then it is dropped as hard-dead, not retained.
|
|
215
|
+
- [ ] Given a retained member, When the refresh finishes, Then a flat `scan-kept` cooling record benches it without entering the strike memory, and a member that answered has any stale bench cleared.
|
|
216
|
+
- [ ] Given a scan-kept bench, When any probe watches the model answer, Then it is re-admitted without waiting out the window.
|
|
217
|
+
- [ ] Given a degraded page, When the refresh runs, Then sick providers only probe last; nothing is skipped or benched because of the page.
|
|
218
|
+
- [ ] Given a degraded retry, When it runs, Then it probes only the current members first and scans fully only when one stops answering.
|
|
219
|
+
- [ ] Given cooling slots, When `pool_status` renders, Then each shows its absolute expiry, plus the structural verdict, last refresh age, and status hint.
|
|
220
|
+
|
|
205
221
|
---
|
|
206
222
|
|
|
207
223
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -623,6 +639,7 @@ table was filled in.
|
|
|
623
639
|
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
624
640
|
| US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
|
|
625
641
|
| US-40 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/refresh-pool-test.mjs`, `tests/provider-status-test.mjs` |
|
|
642
|
+
| US-41 | B | `tests/stories/l-pool-refresh.test.mjs`, `tests/pool-selection-test.mjs`, `tests/refresh-pool-test.mjs`, `tests/pool-shared-test.mjs`, `tests/provider-status-test.mjs` |
|
|
626
643
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
627
644
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
628
645
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -656,6 +673,6 @@ The root-level suites under `tests/` are unit-style; the `tests/stories/` suites
|
|
|
656
673
|
the behavioural layer, written one epic per file with every test name carrying
|
|
657
674
|
its story id. Run everything with `npm test`.
|
|
658
675
|
|
|
659
|
-
|
|
676
|
+
41/41 stories covered by 380 test case(s); thinnest story has 1.
|
|
660
677
|
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
661
678
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
package/presets/README.md
CHANGED
|
@@ -32,30 +32,42 @@ change here takes effect without a restart. Plugin CODE still needs one.
|
|
|
32
32
|
|
|
33
33
|
## The flat pool file (recommended)
|
|
34
34
|
|
|
35
|
-
`~/.config/lacode/pool.txt` is one model per line — edit a model id
|
|
36
|
-
in place, no JSON and no objects:
|
|
35
|
+
`~/.config/lacode/pool.txt` is one model per line — edit a model id, a weight or
|
|
36
|
+
a ceiling in place, no JSON and no objects:
|
|
37
37
|
|
|
38
38
|
```text
|
|
39
|
-
# provider/model-id weight c:<context>
|
|
40
|
-
opencode/big-pickle 3 c:200000
|
|
39
|
+
# provider/model-id weight ceil:<max-parallel> c:<context>
|
|
40
|
+
opencode/big-pickle 3 ceil:6 c:200000
|
|
41
41
|
opencode-go/beam-free 2 c:200000
|
|
42
42
|
opencode/deepseek-v4.1-flash-free 1 c:200000
|
|
43
43
|
opencode/glm-5.3-flash-free 1 c:200000
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
`#` starts a comment (whole-line or trailing), blank lines are ignored, and the
|
|
47
|
-
weight is optional (default 1). `
|
|
47
|
+
weight is optional (default 1). `ceil:<max-parallel>` is optional and is the
|
|
48
|
+
slot's hard concurrency cap (max parallel sessions); it must be at least the
|
|
49
|
+
weight, and absent means the ceiling is the weight. `c:<context>` is the model's context window in tokens; the router uses it to prefer the smallest model that fits the task, and it is optional for hand-written lines. The rules are exactly the same as the JSON form
|
|
48
50
|
below: any number of slots, a model may repeat on another line, split on the
|
|
49
51
|
first slash only. The loader accepts either shape in the same file, so a leading
|
|
50
52
|
`{` means JSON and anything else means the flat form.
|
|
51
53
|
|
|
54
|
+
A line that is exactly `# --- fallback ---` (or `[fallback]`) starts the
|
|
55
|
+
**fallback section**. Those slots are used only when no primary slot is usable;
|
|
56
|
+
they are not extra overflow capacity. A fallback slot that shares a quota bucket
|
|
57
|
+
(or model) with any primary slot is dropped with a warning, because a limit that
|
|
58
|
+
benches the primary would bench it too. The JSON form is `"fallbackSlots": [...]`
|
|
59
|
+
beside `"slots"`.
|
|
60
|
+
|
|
52
61
|
## Schema
|
|
53
62
|
|
|
54
63
|
```jsonc
|
|
55
64
|
{
|
|
56
65
|
"slots": [
|
|
57
|
-
{ "model": "provider/model-id", "weight": 3 },
|
|
58
|
-
{ "model": "provider/another-id" } // weight defaults to 1
|
|
66
|
+
{ "model": "provider/model-id", "weight": 3, "ceiling": 6 },
|
|
67
|
+
{ "model": "provider/another-id" } // weight defaults to 1, ceiling to the weight
|
|
68
|
+
],
|
|
69
|
+
"fallbackSlots": [
|
|
70
|
+
{ "model": "other-provider/reserve-id", "weight": 1 }
|
|
59
71
|
]
|
|
60
72
|
}
|
|
61
73
|
```
|
|
@@ -63,7 +75,9 @@ first slash only. The loader accepts either shape in the same file, so a leading
|
|
|
63
75
|
| Field | Required | Meaning |
|
|
64
76
|
|-------|----------|---------|
|
|
65
77
|
| `model` | yes | `provider/model-id`, split on the **first** slash only |
|
|
66
|
-
| `weight` | no |
|
|
78
|
+
| `weight` | no | per-pass concurrency budget; absent = 1 |
|
|
79
|
+
| `ceiling` | no | hard concurrency cap (max parallel sessions); must be >= `weight`. Absent = the weight |
|
|
80
|
+
| `fallbackSlots` | no | slots used only when no primary slot is usable; each must be disjoint from the primary's buckets |
|
|
67
81
|
|
|
68
82
|
`$comment` is a JSON-legal array of strings and is ignored by the loader; the
|
|
69
83
|
bundled preset uses it to explain itself in place.
|
|
@@ -92,18 +106,23 @@ because the pool requires it.
|
|
|
92
106
|
|
|
93
107
|
## Weight, precisely
|
|
94
108
|
|
|
95
|
-
A weight is a slot's
|
|
96
|
-
may
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
A weight is a slot's per-pass budget: the number of concurrent claims one pass
|
|
110
|
+
may add to it before the next slot is preferred. Its hard cap is the `ceiling`
|
|
111
|
+
(max parallel sessions), which defaults to the weight and can be raised per slot
|
|
112
|
+
with a `ceiling` field (flat form `ceil:<n>`). The pool's capacity is the **sum
|
|
113
|
+
of the ceilings**, so a weight-3 slot with ceiling 6 may hold 6 concurrent
|
|
114
|
+
sessions, and listing a model on two lines gives it two independent budgets.
|
|
99
115
|
|
|
100
|
-
Routing
|
|
116
|
+
Routing repeats the pass:
|
|
101
117
|
|
|
102
118
|
1. fill each usable slot up to its weight, in declaration order
|
|
119
|
+
2. once every usable slot has reached its weight, the next pass deepens each
|
|
120
|
+
usable slot toward its ceiling
|
|
103
121
|
|
|
104
|
-
A slot is skipped when it is cooling or
|
|
105
|
-
at its
|
|
106
|
-
usable slot; the pool never refuses work.
|
|
122
|
+
A slot is skipped when it is cooling, unavailable, or at its ceiling. When every
|
|
123
|
+
usable slot is at its ceiling, the pool is at capacity and overflow goes to the
|
|
124
|
+
least loaded usable slot; the pool never refuses work. With no ceiling set the
|
|
125
|
+
slot stops at its weight, so the historical one-pass behaviour is unchanged.
|
|
107
126
|
|
|
108
127
|
Live claims decide the next slot: a released claim immediately reopens its
|
|
109
128
|
slot, and there is no stored position.
|
package/presets/free-tier.json
CHANGED
|
@@ -14,18 +14,26 @@
|
|
|
14
14
|
"ANY NUMBER OF SLOTS: one hidden variant per base per slot is declared from the resolved",
|
|
15
15
|
"table, and the watcher declares a variant when a reload grows the table. There is no slot cap.",
|
|
16
16
|
"",
|
|
17
|
-
"WEIGHT IS THE
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"Routing
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
17
|
+
"WEIGHT IS THE PER-PASS BUDGET: a slot may be given up to its weight concurrent claims",
|
|
18
|
+
"in one pass before the next slot is preferred. Slots are filled in declaration order;",
|
|
19
|
+
"preference is order, never weight.",
|
|
20
|
+
"Routing repeats the pass: each pass fills every usable slot to its weight, then the",
|
|
21
|
+
"next pass deepens a slot toward its CEILING (its max parallel sessions), stopping",
|
|
22
|
+
"when every usable slot is at its ceiling. A slot is skipped when it is cooling or",
|
|
23
|
+
"unavailable. With no ceiling configured the ceiling is the weight, so the pool does",
|
|
24
|
+
"one pass and its capacity is the sum of the weights; overflow then goes to the",
|
|
25
|
+
"least-loaded usable slot and the pool never refuses work.",
|
|
26
|
+
"An optional per-slot 'ceiling' (flat format: ceil:<max-parallel>) raises the hard cap",
|
|
27
|
+
"above the weight. It must be at least the weight.",
|
|
28
|
+
"An optional 'fallbackSlots' array (flat format: a line that is exactly",
|
|
29
|
+
"'# --- fallback ---', then the fallback lines below it) is used only when no primary",
|
|
30
|
+
"slot is usable. A fallback slot must not share a bucket or model with the primary,",
|
|
31
|
+
"or it is dropped: a limit that benches the primary would bench it too.",
|
|
24
32
|
"Live claims decide the next slot: a released claim immediately reopens its slot, and",
|
|
25
33
|
"there is no stored position.",
|
|
26
34
|
"",
|
|
27
|
-
"With this file: weights 3,2,1,1 give 7 active sessions in total. To give
|
|
28
|
-
"capacity, list it again on another line
|
|
35
|
+
"With this file: weights 3,2,1,1 (no ceilings) give 7 active sessions in total. To give",
|
|
36
|
+
"a model more capacity, raise its ceiling or list it again on another line.",
|
|
29
37
|
"Equal weights give a plain pass in list order.",
|
|
30
38
|
"",
|
|
31
39
|
"DECLARATION ORDER IS PREFERENCE. Slots are filled in declaration order, so the slot",
|