@lawaty/lacode 2.3.0-beta.5 → 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/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
@@ -45,9 +45,10 @@ var PROVIDER_DEGRADED_MIN_MODELS = 2;
45
45
  var TRANSIENT_DEGRADATION_REASONS = /^(?:no answer|no-answer|hang|timeout|timed out|endpoint-unavailable|provider-degraded|empty response|network|server)$/i;
46
46
  var FLAT_OUTAGE_REASONS = /* @__PURE__ */ new Set([UNAVAILABLE_REASON, PROVIDER_DEGRADED_REASON]);
47
47
  var MAX_WEIGHT = 100;
48
+ var MAX_CEILING = MAX_WEIGHT;
48
49
  var MIN_SLOT_CONTEXT = 1e5;
49
50
  var MIN_SLOT_TPM = 2e4;
50
- var slotCeiling = (slot) => slot.weight;
51
+ var slotCeiling = (slot) => slot.ceiling ?? slot.weight;
51
52
  var slotBucket = (slot) => slot.bucket ?? slot.model;
52
53
  var NOTIFY_DEFAULT_ON_PROVIDERS = /* @__PURE__ */ new Set(["opencode", "opencode-go"]);
53
54
  var notifyDefault = (model) => {
@@ -67,7 +68,7 @@ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
67
68
  var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
68
69
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
69
70
  var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
70
- 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);
71
72
  var MODEL_MISSING_REASON = "model-missing";
72
73
  var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
73
74
  var AUTH_FAILURE_REASON = "auth-failed";
@@ -76,6 +77,16 @@ var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\
76
77
  var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
77
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;
78
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);
79
90
  var BASES = ["explore", "implement", "context"];
80
91
  var LEARNED_TPM_FILE = "learned-tpm.json";
81
92
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
@@ -102,64 +113,100 @@ var coerceNotify = (raw, at, model, problems) => {
102
113
  problems.push(`${at} (${model}): notify must be a boolean or one of on/off, got ${JSON.stringify(raw)}; keeping the provider default`);
103
114
  return notifyDefault(model);
104
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
+ }
105
156
  function parseSlots(parsed, origin) {
106
157
  const problems = [];
107
- const entries = parsed?.slots;
108
- if (!Array.isArray(entries) || entries.length === 0) {
158
+ const primaryEntries = parsed?.slots;
159
+ const fallbackEntries = parsed?.fallbackSlots;
160
+ if ((!Array.isArray(primaryEntries) || primaryEntries.length === 0) && (!Array.isArray(fallbackEntries) || fallbackEntries.length === 0)) {
109
161
  return { slots: FALLBACK_SLOTS, problems: [`${origin} has no non-empty "slots" array; using built-in defaults`] };
110
162
  }
111
- const slots = [];
112
- entries.forEach((entry, i) => {
113
- const model = typeof entry?.model === "string" ? entry.model.trim() : "";
114
- const at = `slot ${i + 1}`;
115
- if (!model.includes("/")) return problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
116
- const weight = readWeight(entry, at, model, problems);
117
- const rawBucket = entry?.bucket;
118
- const bucket = typeof rawBucket === "string" ? rawBucket.trim() : void 0;
119
- if (rawBucket !== void 0 && !bucket) {
120
- problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
121
- }
122
- const notify = coerceNotify(entry.notify, at, model, problems);
123
- const rawContext = entry?.context;
124
- let context;
125
- if (rawContext !== void 0) {
126
- const parsedContext = typeof rawContext === "number" ? rawContext : Number(rawContext);
127
- if (Number.isFinite(parsedContext) && parsedContext > 0) context = parsedContext;
128
- else problems.push(`${at} (${model}): context must be a positive number, got ${JSON.stringify(rawContext)}; ignoring it`);
129
- }
130
- if (context !== void 0 && context < MIN_SLOT_CONTEXT) {
131
- problems.push(`${at} (${model}): context ${context} is below the ${MIN_SLOT_CONTEXT}-token minimum; dropping the slot`);
132
- return;
133
- }
134
- const rawTpm = entry?.tpm;
135
- let tpm;
136
- if (rawTpm !== void 0) {
137
- const parsedTpm = typeof rawTpm === "number" ? rawTpm : Number(rawTpm);
138
- if (Number.isFinite(parsedTpm) && parsedTpm > 0) tpm = parsedTpm;
139
- else problems.push(`${at} (${model}): tpm must be a positive number, got ${JSON.stringify(rawTpm)}; ignoring it`);
140
- }
141
- if (tpm !== void 0 && tpm < MIN_SLOT_TPM) {
142
- problems.push(`${at} (${model}): tpm ${tpm} is below the ${MIN_SLOT_TPM}-token/minute minimum; dropping the slot`);
143
- 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;
144
183
  }
145
- slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {}, notify, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
184
+ return true;
146
185
  });
186
+ const slots = [...primary, ...fallback].map((slot, i) => ({ ...slot, index: i + 1 }));
147
187
  if (slots.length > SLOTS_WARN_AT) {
148
188
  problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
149
189
  }
150
- if (slots.length === 0) {
151
- 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`);
152
192
  return { slots: FALLBACK_SLOTS, problems };
153
193
  }
154
194
  return { slots, problems };
155
195
  }
156
196
  function parseTextSlots(text, origin) {
157
197
  const entries = [];
198
+ const fallbackEntries = [];
199
+ let section = "primary";
158
200
  for (const raw of text.split(/\r?\n/)) {
201
+ if (isFallbackMarker(raw)) {
202
+ section = "fallback";
203
+ continue;
204
+ }
159
205
  const line = raw.split("#")[0].trim();
160
206
  if (!line) continue;
161
207
  const [model, ...rest] = line.split(/\s+/);
162
208
  let weight;
209
+ let ceiling;
163
210
  let bucket;
164
211
  let notify;
165
212
  let context;
@@ -169,6 +216,13 @@ function parseTextSlots(text, origin) {
169
216
  if (notify === void 0) notify = token.slice("notify:".length);
170
217
  continue;
171
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
+ }
172
226
  if (token.startsWith("c:")) {
173
227
  if (context === void 0) {
174
228
  const parsedContext = Number(token.slice(2));
@@ -189,9 +243,11 @@ function parseTextSlots(text, origin) {
189
243
  weight = token;
190
244
  }
191
245
  }
192
- entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket }, ...notify === void 0 ? {} : { notify }, ...context === void 0 ? {} : { context }, ...tpm === void 0 ? {} : { tpm } });
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);
193
249
  }
194
- return parseSlots({ slots: entries }, origin);
250
+ return parseSlots({ slots: entries, ...fallbackEntries.length > 0 ? { fallbackSlots: fallbackEntries } : {} }, origin);
195
251
  }
196
252
  function parseSlotText(text, origin) {
197
253
  if (text.trimStart().startsWith("{")) {
@@ -296,6 +352,20 @@ function readWeight(entry, at, model, problems) {
296
352
  }
297
353
  return weight;
298
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
+ }
299
369
  var UNAVAILABLE_PATTERN = /endpoint\s+is\s+unavailable/i;
300
370
  var LIMIT_PATTERNS = [
301
371
  /rate[\s_-]?limit/i,
@@ -303,6 +373,7 @@ var LIMIT_PATTERNS = [
303
373
  /too many requests/i,
304
374
  /\b429\b/,
305
375
  /quota/i,
376
+ /budget\s+exhausted|api\s+budget/i,
306
377
  /freelimiterror/i,
307
378
  /overloaded/i,
308
379
  UNAVAILABLE_PATTERN
@@ -346,7 +417,7 @@ function parseRateLimitHeaders(headers) {
346
417
  }
347
418
  return void 0;
348
419
  }
349
- 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;
350
421
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text)) || LIMIT_REACHED_PATTERN.test(text) && !CONTEXT_LIMIT_PATTERN.test(text);
351
422
  var limitReason = (text) => {
352
423
  if (!isLimitText(text)) return void 0;
@@ -436,7 +507,7 @@ function ensureSlots(s, onWarn) {
436
507
  for (const problem of problems) onWarn(problem);
437
508
  return slots;
438
509
  }
439
- 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("|")}`;
440
511
  function reloadSlots(s, onWarn) {
441
512
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
442
513
  const effective = applyLearnedTpm(s, slots, onWarn);
@@ -850,52 +921,54 @@ function snapshot(s) {
850
921
  }
851
922
  var loadOf = (counts, slot) => counts.get(slot) ?? 0;
852
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
+ };
853
932
  function pickSlot(s, snap, neededContext) {
854
933
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
855
- const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
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));
856
938
  if (usable.length === 0) return void 0;
857
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;
858
941
  if (neededContext === void 0) {
859
- for (const slot of usable) {
860
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
861
- }
862
- 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]);
863
943
  }
864
944
  const adequate = usable.filter((slot) => effective(slot) >= neededContext);
865
945
  const adequateKnown = adequate.filter((slot) => Number.isFinite(effective(slot)));
866
946
  const candidates = adequate.length > 0 ? adequate : usable;
947
+ const smallest = (best, slot) => Number.isFinite(effective(slot)) && effective(slot) < effective(best) ? slot : best;
867
948
  if (adequateKnown.length === 0) {
868
949
  const known = usable.filter((slot) => Number.isFinite(effective(slot)));
869
950
  if (known.length > 0) {
870
951
  const largest = Math.max(...known.map((slot) => effective(slot)));
871
952
  const largestSlots = known.filter((slot) => effective(slot) === largest);
872
- for (const slot of largestSlots) {
873
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
874
- }
875
- 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]);
876
954
  }
877
- for (const slot of candidates) {
878
- if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
879
- }
880
- return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
881
- }
882
- const underWeight = candidates.filter((slot) => loadOf(snap.counts, slot.index) < slot.weight);
883
- if (underWeight.length > 0) {
884
- const known = underWeight.filter((slot) => Number.isFinite(effective(slot)));
885
- if (known.length === 0) return underWeight[0];
886
- return known.reduce((best, slot) => effective(slot) < effective(best) ? slot : best, known[0]);
955
+ return pickByPass(candidates, snap.counts) ?? candidates.reduce(leastLoaded, candidates[0]);
887
956
  }
888
- return candidates.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, candidates[0]);
957
+ return pickByPass(candidates, snap.counts, smallest) ?? candidates.reduce(leastLoaded, candidates[0]);
889
958
  }
890
959
  function poolPass(s, snap, neededContext) {
891
960
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
892
- const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
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));
893
965
  if (usable.length === 0) return "none";
894
966
  const effective = (slot) => Math.min(slot.context ?? Number.POSITIVE_INFINITY, slot.tpm ?? Number.POSITIVE_INFINITY);
895
967
  const candidates = neededContext === void 0 ? usable : usable.filter((slot) => effective(slot) >= neededContext);
896
968
  const pool = candidates.length > 0 ? candidates : usable;
897
- if (pool.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
898
- 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;
899
972
  }
900
973
  function agentFor(base, slot) {
901
974
  return `${base}-${slot}`;
@@ -1084,7 +1157,10 @@ export {
1084
1157
  FALLBACK_SLOTS,
1085
1158
  HANG_COOLDOWN_MS,
1086
1159
  HEARTBEAT_PUBLISH_MS,
1160
+ INVALID_REQUEST_COOLDOWN_MS,
1161
+ INVALID_REQUEST_REASON,
1087
1162
  LEARNED_TPM_FILE,
1163
+ MAX_CEILING,
1088
1164
  MAX_CLAIM_AGE_MS,
1089
1165
  MAX_WEIGHT,
1090
1166
  MIN_SLOT_CONTEXT,
@@ -1105,6 +1181,8 @@ export {
1105
1181
  STATE_SWEEP_INTERVAL_MS,
1106
1182
  STUCK_IDLE_MS,
1107
1183
  STUCK_MIN_AGE_MS,
1184
+ TRANSIENT_UPSTREAM_COOLDOWN_MS,
1185
+ TRANSIENT_UPSTREAM_REASON,
1108
1186
  UNAVAILABLE_COOLDOWN_MS,
1109
1187
  UNAVAILABLE_REASON,
1110
1188
  UNBOUND_CLAIM_MS,
@@ -1127,16 +1205,20 @@ export {
1127
1205
  findStuck,
1128
1206
  idleFor,
1129
1207
  init,
1208
+ isAbortText,
1130
1209
  isAuthFailureText,
1131
1210
  isAvailable,
1132
1211
  isContextOverflowText,
1212
+ isFallbackMarker,
1133
1213
  isLimitText,
1134
1214
  isModelMissingText,
1135
1215
  isProbe,
1136
1216
  isQuotaReason,
1137
1217
  isRequestTooLargeText,
1138
1218
  isTransientGatewayText,
1219
+ isTransientUpstreamText,
1139
1220
  isUnavailableText,
1221
+ isUnsupportedParameterText,
1140
1222
  limitReason,
1141
1223
  manualVariant,
1142
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");
@@ -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 both its concurrency budget and its cap. The
27
- pool fills slots in declaration order in one pass; there is no slot cap, and a
28
- model may appear on more than one line — each line is its own slot with its own
29
- weight. A `tier` key is the deprecated spelling of `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
@@ -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 a `weight`. A slot's `weight` is its active-session budget for one pass: the number of concurrent claims it may hold before the next slot is preferred. A *pass* is one declaration-order sweep of the slot list; the pool runs one pass, and a slot's cap is its weight. 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. 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.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "2.3.0-beta.5",
3
+ "version": "2.3.0-beta.6",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 or a weight
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). `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
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 | active-session budget; the pool's capacity is the sum of the weights. Absent = 1 |
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 active-session budget: the number of concurrent claims it
96
- may hold before the next slot is preferred. The pool's capacity is the **sum of
97
- the weights**, so a weight-3 slot may hold 3 concurrent sessions and listing a
98
- model on two lines gives it two independent budgets.
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 is one pass:
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 unavailable. When every usable slot is
105
- at its weight, the pool is at capacity and overflow goes to the least loaded
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.
@@ -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 ACTIVE-SESSION BUDGET FOR ONE PASS: a slot may hold up to its weight in",
18
- "concurrent claims before the next slot is preferred. Slots are filled in declaration",
19
- "order; preference is order, never weight.",
20
- "Routing is ONE ordered pass: each usable slot fills up to its weight, in declaration",
21
- "order. A slot is skipped when it is cooling or unavailable. When every usable slot is at",
22
- "its weight, the pool is at capacity (the sum of the weights) and overflow goes to the",
23
- "least-loaded usable slot; the pool never refuses work.",
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 a model more",
28
- "capacity, list it again on another line with its own weight.",
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",