@lawaty/lacode 0.1.10 → 0.2.0

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/README.md CHANGED
@@ -284,6 +284,7 @@ option is optional:
284
284
  | `modelsFile` | — | An explicit slot file; short-circuits the chain below. |
285
285
  | `reapMs` | `30000` | Hang-reaper interval. |
286
286
  | `selfUpdate` | `true` | Check for a newer release at most once per ~6h and install it in the background; it applies on the next opencode start. `false` — or `LACODE_NO_SELFUPDATE=1` — turns the check off. |
287
+ | `omniroute` | — | Optional [OmniRoute](#configuring-omniroute) gateway integration. Absent or `{ enabled: false }` changes nothing. |
287
288
 
288
289
  ### Staying up to date
289
290
 
@@ -315,6 +316,105 @@ allowlist anywhere in the package, and no cost check of any kind: only the
315
316
  structure is validated. The bundled preset is built from free models because
316
317
  that is a sensible default, not because the pool requires it.
317
318
 
319
+ ### Configuring OmniRoute
320
+
321
+ [OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a local
322
+ OpenAI-compatible AI gateway (default `http://localhost:20128/v1`) that routes a
323
+ request across many providers with fallback and "combos". LaCode can register it
324
+ as an opencode provider for you. It is **off by default**: if you do not set
325
+ `omniroute.enabled: true`, nothing below happens and LaCode makes no request.
326
+
327
+ ```jsonc
328
+ {
329
+ "plugin": [
330
+ ["@lawaty/lacode", {
331
+ "omniroute": {
332
+ "enabled": true,
333
+ "baseURL": "http://localhost:20128/v1",
334
+ "apiKey": "sk_omniroute", // optional; OMNIROUTE_API_KEY overrides it
335
+ "combo": "static-best-free", // optional: let OmniRoute do the routing
336
+ "models": ["cc/claude-sonnet-4-6", "gemini-3-flash"], // hard allowlist
337
+ "tiers": {
338
+ "primary": ["cc/claude-sonnet-4-6"],
339
+ "backup": ["gemini-3-flash"]
340
+ },
341
+ "stickyPerSession": true,
342
+ "healthCheck": true,
343
+ "timeoutMs": 5000
344
+ }
345
+ }]
346
+ ]
347
+ }
348
+ ```
349
+
350
+ | Field | Default | Meaning |
351
+ | --- | --- | --- |
352
+ | `enabled` | `false` | Turns the integration on. |
353
+ | `baseURL` | `http://localhost:20128/v1` | Gateway base URL, normalised to exactly one `/v1`. |
354
+ | `apiKey` | — | Bearer key. **`OMNIROUTE_API_KEY` overrides this value.** The key is never logged. |
355
+ | `combo` | — | An OmniRoute combo (or `auto`) used as the routing model. Always exposed first. |
356
+ | `models` | — | A **hard allowlist**: only these ids are ever exposed, even if the gateway lists more. |
357
+ | `tiers` | — | `{ "primary": [...], "backup": [...] }` preference order over models the allowlist already permits. |
358
+ | `stickyPerSession` | `true` | Keep a session on one model while that model is healthy. |
359
+ | `healthCheck` | `true` | Probe `GET /models` at startup; a failed check disables the integration for that run only. |
360
+ | `timeoutMs` | `5000` | Budget for LaCode's own calls to the gateway. |
361
+
362
+ An enabled but invalid block (bad `baseURL`, a non-array `models`, …) is rejected
363
+ as a whole: LaCode logs one warning naming the problem and continues exactly as
364
+ if the integration were off. It never crashes opencode.
365
+
366
+ **What it registers.** On the first `config` pass LaCode health-checks the
367
+ gateway, reads `GET /models`, filters it through your `models` allowlist (or
368
+ exposes everything the gateway lists when no allowlist is set), and writes a
369
+ `provider.omniroute` entry — `npm: "@ai-sdk/openai-compatible"` with your
370
+ `baseURL` / `apiKey` — so `omniroute/<model-id>` becomes selectable. If you
371
+ already have a `provider.omniroute` block, LaCode leaves it completely untouched
372
+ and warns; it also never adds a `whitelist`. Because opencode loads
373
+ `@ai-sdk/openai-compatible` itself, no extra dependency is installed.
374
+
375
+ **Combo vs. allowlist.** Set `combo` and OmniRoute does the fallback for you (let
376
+ it route). Without a combo, LaCode exposes the allowlist ordered by `tiers`
377
+ (primary first), tracks a rolling latency and error rate per model, and skips a
378
+ model while it is cooling after a transient failure — 429, 408 and 5xx, honouring
379
+ `Retry-After` and otherwise using capped exponential backoff. Non-transient
380
+ errors (400, 401, 403, 404) are surfaced to you, never failed over.
381
+
382
+ **OmniRoute becomes the pool's primary slot.** This is the point of the
383
+ integration: when the gateway is healthy, LaCode replaces the pool's **first
384
+ slot** — the one the pool takes first — with
385
+ `omniroute/<combo | first allowlisted model | auto>`, inheriting that slot's
386
+ weight, so pooled mechanical work (exploration, implementation, cartography)
387
+ drains through OmniRoute before it reaches the bundled opencode/opencode-go
388
+ ("zen") models. On the default `pool.txt` shown below, gemini's slot becomes
389
+ OmniRoute — same slot count, same weights — and bunny / step-5 / big-pickle keep
390
+ their places behind it. The replacement is skipped, leaving the pool exactly as
391
+ it was, if the gateway is disabled, unreachable, returns no usable models, or if
392
+ your own pool already routes on the `omniroute` provider (LaCode never stacks a
393
+ second slot on a provider you already chose). See
394
+ [docs/USER-STORIES.md](docs/USER-STORIES.md) US-28.
395
+
396
+ **A note on mid-session switching.** opencode 1.18.34 exposes no hook to change
397
+ an agent's main model mid-request, so LaCode cannot swap models inside a running
398
+ session; use an OmniRoute `combo` (or `auto`) if you want per-request fallback.
399
+ LaCode's selection engine decides which models are exposed and in what order, and
400
+ its cooldown/backoff rules are unit-tested.
401
+
402
+ **Getting a key.** Open the OmniRoute dashboard, go to API Manager / API Keys and
403
+ copy a key into `OMNIROUTE_API_KEY` (recommended) or `omniroute.apiKey`. On a
404
+ local instance with `REQUIRE_API_KEY=false`, the literal placeholder
405
+ `sk_omniroute` is accepted.
406
+
407
+ **Troubleshooting**
408
+
409
+ | Symptom | Cause | Fix |
410
+ | --- | --- | --- |
411
+ | `omniroute health check failed` at startup | Gateway not running, wrong `baseURL`, or a startup race | Start OmniRoute on `http://localhost:20128` and restart opencode. The integration is disabled for that run only, not permanently. |
412
+ | `no usable models` | The gateway returned an empty list, or every id was filtered out by `models` | `curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/v1/models` and check `models`/`combo` against the returned ids. |
413
+ | `401` / `Invalid API key` | `REQUIRE_API_KEY=true` and the key is missing or wrong | Create a key in the dashboard, export `OMNIROUTE_API_KEY`, or set `REQUIRE_API_KEY=false` locally and use `sk_omniroute`. |
414
+ | `404` on a URL containing `/v1/v1/` | A `baseURL` that already carried `/v1` was given twice | LaCode normalises this, so it should not come from LaCode; check a hand-written `provider.omniroute` block, which LaCode leaves untouched. |
415
+ | Provider registered but models are missing | You already had `provider.omniroute` | Remove it and let LaCode write it, or add the models yourself — LaCode never modifies an existing provider. |
416
+ | Pooled work still runs on the bundled models | The gateway was unhealthy/disabled at startup, or your own pool already routes on `omniroute` | Check the startup log for `omniroute health check failed` / `no usable models`; if you added your own `omniroute/…` pool slot, remove it to let LaCode place one. |
417
+
318
418
  ### Which models run which agent
319
419
 
320
420
  Only the **pool** writes a model onto a pooled agent, from whichever source wins
package/dist/agents.js CHANGED
@@ -35,6 +35,8 @@ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
35
35
  var MAX_SLOTS = 4;
36
36
  var COOLDOWN_BASE_MS = 60 * 1e3;
37
37
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
38
+ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
39
+ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
38
40
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
39
41
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
40
42
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
@@ -70,8 +72,31 @@ Every question of the form "what does this file do", "where is X handled", or
70
72
  that proves it. Never one whole refactor handed over in a single piece \u2014
71
73
  see \`# Task size\` below.
72
74
 
73
- The agents you spawn are the pooled \`explore\` / \`implement\` bases \u2014
74
- spawn the bare name and let the pool route, never a \`-1\`/\`-2\` slot variant.
75
+ The agents you spawn are the pooled \`explore\` / \`implement\` / \`context\`
76
+ bases. **Always spawn the BARE base name and let the plugin choose the slot;
77
+ never name a \`-N\` variant** (\`explore-1\`..\`explore-4\`, \`implement-1\`..,
78
+ \`context-1\`..). The plugin owns which model every pooled spawn runs on: it
79
+ picks the next slot from the pool's weights, current load and cooldowns, and
80
+ rewrites your spawn to that variant for you. Naming a variant bypasses that
81
+ decision, defeats failover, and can pin work to a cooling or unhealthy model.
82
+ If you catch yourself choosing a slot number, you are doing the plugin's job \u2014
83
+ spawn \`explore\` and let it decide.
84
+
85
+ # When the pool is unavailable
86
+
87
+ The pooled agents are the ONLY route to investigation and implementation. If the
88
+ pool cannot serve a spawn \u2014 every slot cooling or exhausted, spawns repeatedly
89
+ failing, or a runtime message saying no slot is available \u2014 that is a STOP
90
+ condition, not a reason to do the work yourself.
91
+
92
+ **Never fall back to reading, grepping, exploring or implementing on your own.**
93
+ Do not "just quickly" inspect a file because a spawn failed. Stop, say what
94
+ failed (slot, model, error), and ASK the user how to proceed: wait for a slot to
95
+ recover, change the pool, or obtain explicit permission to work without
96
+ subagents. A failed spawn is never permission, and silence is not consent.
97
+
98
+ \`edit\`/\`write\`/\`bash\` stay yours for applying and verifying what a subagent
99
+ returned \u2014 never as a substitute for a spawn that could not run.
75
100
 
76
101
  # Task size
77
102
 
@@ -175,6 +200,16 @@ When you need to understand the codebase:
175
200
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
176
201
  3. @explore will return its findings \u2014 you process them and plan accordingly
177
202
 
203
+ Always spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`) and let
204
+ the plugin choose the slot. Never name a \`-N\` variant (\`explore-1\`..\`explore-4\`);
205
+ doing so bypasses the pool's weights, load and cooldowns, which is the one thing
206
+ the plugin exists to decide.
207
+
208
+ If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
209
+ failures \u2014 do NOT fall back to reading or planning from your own inspection.
210
+ Stop and ASK the user how to proceed; a failed spawn is not permission to
211
+ investigate yourself.
212
+
178
213
  Repository context: before delegating investigation for a non-trivial task,
179
214
  tell the exploration agent to first check \`.opencode/context/\` in this project
180
215
  (using \`ls\`, never \`glob\` \u2014 it skips hidden directories)
package/dist/index.js CHANGED
@@ -36,7 +36,10 @@ var MAX_WEIGHT = 100;
36
36
  var MAX_SLOTS = 4;
37
37
  var COOLDOWN_BASE_MS = 60 * 1e3;
38
38
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
39
+ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
40
+ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
39
41
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
42
+ var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
40
43
  var BASES = ["explore", "implement", "context"];
41
44
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
42
45
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
@@ -238,6 +241,7 @@ function state(opts = {}) {
238
241
  catalog: /* @__PURE__ */ new Map(),
239
242
  catalogAt: 0,
240
243
  cooling: /* @__PURE__ */ new Map(),
244
+ strikes: /* @__PURE__ */ new Map(),
241
245
  activity: /* @__PURE__ */ new Map(),
242
246
  reaped: 0,
243
247
  reaping: false,
@@ -246,19 +250,51 @@ function state(opts = {}) {
246
250
  dir,
247
251
  modelsFile,
248
252
  inlineModels: models,
253
+ primarySlot: null,
249
254
  origin: "unresolved",
250
255
  slots: []
251
256
  };
252
257
  }
253
258
  return g[key];
254
259
  }
260
+ function replacePrimarySlot(base, primary) {
261
+ const problems = [];
262
+ const model = typeof primary?.model === "string" ? primary.model.trim() : "";
263
+ if (!model.includes("/")) {
264
+ problems.push(`primary slot: model must be written provider/model-id, got "${model}"`);
265
+ return { slots: base, problems };
266
+ }
267
+ const provider = providerOf(model);
268
+ if (base.some((slot) => providerOf(slot.model) === provider)) {
269
+ problems.push(`primary slot ${model}: provider ${provider} is already in the pool; the primary slot is skipped`);
270
+ return { slots: base, problems };
271
+ }
272
+ if (base.length === 0) return { slots: [{ index: 1, model, weight: Math.max(1, primary.weight ?? 1) }], problems };
273
+ let weight = primary.weight;
274
+ if (weight !== void 0 && (typeof weight !== "number" || !Number.isFinite(weight) || weight <= 0)) {
275
+ problems.push(`primary slot ${model}: weight must be a positive number; inheriting the replaced slot's weight`);
276
+ weight = void 0;
277
+ }
278
+ const slots = base.map((slot, i) => i === 0 ? { ...slot, model, weight: weight ?? slot.weight } : slot);
279
+ return { slots, problems };
280
+ }
281
+ function setPrimarySlot(s, primary) {
282
+ s.primarySlot = primary;
283
+ if (s.slots.length > 0) s.slots = [];
284
+ }
255
285
  function ensureSlots(s, onWarn) {
256
286
  if (s.slots.length > 0) return s.slots;
257
287
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
258
- s.slots = slots;
288
+ let resolved = slots;
289
+ if (s.primarySlot !== null) {
290
+ const replaced = replacePrimarySlot(slots, s.primarySlot);
291
+ resolved = replaced.slots;
292
+ problems.push(...replaced.problems);
293
+ }
294
+ s.slots = resolved;
259
295
  s.origin = origin;
260
296
  for (const problem of problems) onWarn(problem);
261
- return slots;
297
+ return resolved;
262
298
  }
263
299
  var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
264
300
  var selfFile = (id) => `claims.${id}.json`;
@@ -294,6 +330,14 @@ function publish(s) {
294
330
  s.dirReady = false;
295
331
  }
296
332
  }
333
+ var isPidAlive = (pid) => {
334
+ try {
335
+ process.kill(pid, 0);
336
+ return true;
337
+ } catch (error) {
338
+ return error.code === "EPERM";
339
+ }
340
+ };
297
341
  function readSiblings(s) {
298
342
  const siblings = [];
299
343
  let entries;
@@ -327,6 +371,13 @@ function readSiblings(s) {
327
371
  }
328
372
  continue;
329
373
  }
374
+ if (/^\d+$/.test(match[1]) && !isPidAlive(Number(match[1]))) {
375
+ try {
376
+ unlinkSync(path4);
377
+ } catch {
378
+ }
379
+ continue;
380
+ }
330
381
  try {
331
382
  const parsed = JSON.parse(readFileSync2(path4, "utf8"));
332
383
  const claims = Object.values(parsed).filter(
@@ -340,10 +391,13 @@ function readSiblings(s) {
340
391
  }
341
392
  function readLimits(s) {
342
393
  const cooling = /* @__PURE__ */ new Map();
394
+ const memory = /* @__PURE__ */ new Map();
343
395
  let entries;
344
396
  try {
345
397
  entries = readdirSync(s.dir);
346
398
  } catch {
399
+ s.cooling = cooling;
400
+ s.strikes = memory;
347
401
  return cooling;
348
402
  }
349
403
  const now = Date.now();
@@ -353,8 +407,14 @@ function readLimits(s) {
353
407
  try {
354
408
  const strike = JSON.parse(readFileSync2(path4, "utf8"));
355
409
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
356
- if (strike.until > now) cooling.set(strike.model, strike);
357
- else unlinkSync(path4);
410
+ if (strike.until > now) {
411
+ cooling.set(strike.model, strike);
412
+ memory.set(strike.model, strike);
413
+ } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
414
+ memory.set(strike.model, strike);
415
+ } else {
416
+ unlinkSync(path4);
417
+ }
358
418
  } catch {
359
419
  try {
360
420
  unlinkSync(path4);
@@ -363,16 +423,21 @@ function readLimits(s) {
363
423
  }
364
424
  }
365
425
  s.cooling = cooling;
426
+ s.strikes = memory;
366
427
  return cooling;
367
428
  }
368
429
  function recordStrike(s, model, reason, stated) {
369
430
  if (!s.dirReady) return;
370
431
  const now = Date.now();
371
- const prior = s.cooling.get(model);
432
+ const prior = s.strikes.get(model);
372
433
  const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
373
- const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
434
+ const quota = isQuotaReason(reason);
435
+ const base = quota ? QUOTA_COOLDOWN_BASE_MS : COOLDOWN_BASE_MS;
436
+ const cap = quota ? QUOTA_COOLDOWN_MAX_MS : COOLDOWN_MAX_MS;
437
+ const backoff = Math.min(cap, base * 2 ** (strikes - 1));
374
438
  const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
375
439
  s.cooling.set(model, strike);
440
+ s.strikes.set(model, strike);
376
441
  const path4 = join2(s.dir, limitFile(model));
377
442
  try {
378
443
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
@@ -389,6 +454,7 @@ function coolUnavailable(s, model) {
389
454
  if (prior && prior.until > until) return prior;
390
455
  const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
391
456
  s.cooling.set(model, strike);
457
+ s.strikes.set(model, strike);
392
458
  const path4 = join2(s.dir, limitFile(model));
393
459
  try {
394
460
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
@@ -524,8 +590,31 @@ Every question of the form "what does this file do", "where is X handled", or
524
590
  that proves it. Never one whole refactor handed over in a single piece \u2014
525
591
  see \`# Task size\` below.
526
592
 
527
- The agents you spawn are the pooled \`explore\` / \`implement\` bases \u2014
528
- spawn the bare name and let the pool route, never a \`-1\`/\`-2\` slot variant.
593
+ The agents you spawn are the pooled \`explore\` / \`implement\` / \`context\`
594
+ bases. **Always spawn the BARE base name and let the plugin choose the slot;
595
+ never name a \`-N\` variant** (\`explore-1\`..\`explore-4\`, \`implement-1\`..,
596
+ \`context-1\`..). The plugin owns which model every pooled spawn runs on: it
597
+ picks the next slot from the pool's weights, current load and cooldowns, and
598
+ rewrites your spawn to that variant for you. Naming a variant bypasses that
599
+ decision, defeats failover, and can pin work to a cooling or unhealthy model.
600
+ If you catch yourself choosing a slot number, you are doing the plugin's job \u2014
601
+ spawn \`explore\` and let it decide.
602
+
603
+ # When the pool is unavailable
604
+
605
+ The pooled agents are the ONLY route to investigation and implementation. If the
606
+ pool cannot serve a spawn \u2014 every slot cooling or exhausted, spawns repeatedly
607
+ failing, or a runtime message saying no slot is available \u2014 that is a STOP
608
+ condition, not a reason to do the work yourself.
609
+
610
+ **Never fall back to reading, grepping, exploring or implementing on your own.**
611
+ Do not "just quickly" inspect a file because a spawn failed. Stop, say what
612
+ failed (slot, model, error), and ASK the user how to proceed: wait for a slot to
613
+ recover, change the pool, or obtain explicit permission to work without
614
+ subagents. A failed spawn is never permission, and silence is not consent.
615
+
616
+ \`edit\`/\`write\`/\`bash\` stay yours for applying and verifying what a subagent
617
+ returned \u2014 never as a substitute for a spawn that could not run.
529
618
 
530
619
  # Task size
531
620
 
@@ -629,6 +718,16 @@ When you need to understand the codebase:
629
718
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
630
719
  3. @explore will return its findings \u2014 you process them and plan accordingly
631
720
 
721
+ Always spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`) and let
722
+ the plugin choose the slot. Never name a \`-N\` variant (\`explore-1\`..\`explore-4\`);
723
+ doing so bypasses the pool's weights, load and cooldowns, which is the one thing
724
+ the plugin exists to decide.
725
+
726
+ If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
727
+ failures \u2014 do NOT fall back to reading or planning from your own inspection.
728
+ Stop and ASK the user how to proceed; a failed spawn is not permission to
729
+ investigate yourself.
730
+
632
731
  Repository context: before delegating investigation for a non-trivial task,
633
732
  tell the exploration agent to first check \`.opencode/context/\` in this project
634
733
  (using \`ls\`, never \`glob\` \u2014 it skips hidden directories)
@@ -1282,9 +1381,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1282
1381
  `hung ${claim.v} on ${claim.m} after ${Math.round((Date.now() - claim.t) / 1e3)}s with ${Math.round(idleFor(s, claim, Date.now()) / 1e3)}s of silence; ${killed ? `aborted session ${claim.g}` : "no session to abort"}`,
1283
1382
  { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: killed }
1284
1383
  );
1285
- const prior = s.cooling.get(claim.m);
1286
1384
  const now = Date.now();
1287
- const strike = recordStrike(s, claim.m, "hang", prior && prior.until > now ? prior.until - now : HANG_FALLBACK_MS);
1385
+ const prior = s.cooling.get(claim.m);
1386
+ if (prior && prior.until > now) continue;
1387
+ const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1288
1388
  if (strike) await log("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1289
1389
  }
1290
1390
  } catch (error) {
@@ -1492,6 +1592,7 @@ priority is weight: the heaviest slot under its ceiling takes the next spawn (FU
1492
1592
  dispose: async () => {
1493
1593
  clearInterval(timer);
1494
1594
  s.own.clear();
1595
+ publish(s);
1495
1596
  }
1496
1597
  };
1497
1598
  });
@@ -2151,6 +2252,486 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2151
2252
  };
2152
2253
  });
2153
2254
 
2255
+ // src/lib/omniroute.ts
2256
+ var DEFAULT_BASE_URL = "http://localhost:20128/v1";
2257
+ var ENV_API_KEY = "OMNIROUTE_API_KEY";
2258
+ var DEFAULT_TIMEOUT_MS = 5e3;
2259
+ var DEFAULT_CACHE_TTL_MS = 6e4;
2260
+ var COOLDOWN_BASE_MS2 = 1e3;
2261
+ var COOLDOWN_MAX_MS2 = 15 * 60 * 1e3;
2262
+ var MODELS_PATH = "/models";
2263
+ var dedupe = (ids) => {
2264
+ const out = [];
2265
+ for (const id of ids) if (!out.includes(id)) out.push(id);
2266
+ return out;
2267
+ };
2268
+ var isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
2269
+ var isStringArray = (value) => Array.isArray(value) && value.every((entry) => isNonEmptyString(entry));
2270
+ function normalizeBaseURL(raw) {
2271
+ if (typeof raw !== "string") return null;
2272
+ const trimmed = raw.trim();
2273
+ if (trimmed === "") return null;
2274
+ let url;
2275
+ try {
2276
+ url = new URL(trimmed);
2277
+ } catch {
2278
+ return null;
2279
+ }
2280
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
2281
+ let path4 = url.pathname.replace(/\/+$/, "");
2282
+ while (path4.endsWith("/v1")) path4 = path4.slice(0, -"/v1".length);
2283
+ return `${url.origin}${path4}/v1`;
2284
+ }
2285
+ function parseConfig(raw, env = process.env) {
2286
+ if (typeof raw !== "object" || raw === null) return { enabled: false, config: null, problems: [] };
2287
+ const block = raw;
2288
+ if (block.enabled !== true) return { enabled: false, config: null, problems: [] };
2289
+ const problems = [];
2290
+ let baseURL = DEFAULT_BASE_URL;
2291
+ if (block.baseURL !== void 0) {
2292
+ const normalized = normalizeBaseURL(block.baseURL);
2293
+ if (normalized === null) problems.push("omniroute.baseURL is not a valid http(s) URL");
2294
+ else baseURL = normalized;
2295
+ }
2296
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
2297
+ if (block.timeoutMs !== void 0) {
2298
+ const value = block.timeoutMs;
2299
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
2300
+ problems.push("omniroute.timeoutMs must be a positive number");
2301
+ } else {
2302
+ timeoutMs = value;
2303
+ }
2304
+ }
2305
+ let stickyPerSession = true;
2306
+ if (block.stickyPerSession !== void 0) {
2307
+ if (typeof block.stickyPerSession !== "boolean") problems.push("omniroute.stickyPerSession must be a boolean");
2308
+ else stickyPerSession = block.stickyPerSession;
2309
+ }
2310
+ let healthCheck = true;
2311
+ if (block.healthCheck !== void 0) {
2312
+ if (typeof block.healthCheck !== "boolean") problems.push("omniroute.healthCheck must be a boolean");
2313
+ else healthCheck = block.healthCheck;
2314
+ }
2315
+ let combo;
2316
+ if (block.combo !== void 0) {
2317
+ if (!isNonEmptyString(block.combo)) problems.push("omniroute.combo must be a non-empty string");
2318
+ else combo = block.combo.trim();
2319
+ }
2320
+ let models;
2321
+ if (block.models !== void 0) {
2322
+ if (!isStringArray(block.models)) problems.push("omniroute.models must be an array of non-empty strings");
2323
+ else models = dedupe(block.models.map((id) => id.trim()));
2324
+ }
2325
+ let tiers = { primary: [], backup: [] };
2326
+ if (block.tiers !== void 0) {
2327
+ const value = block.tiers;
2328
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2329
+ problems.push("omniroute.tiers must be an object with primary/backup string arrays");
2330
+ } else {
2331
+ const read = (list) => {
2332
+ if (list === void 0) return [];
2333
+ if (!isStringArray(list)) return null;
2334
+ return dedupe(list.map((id) => id.trim()));
2335
+ };
2336
+ const group = value;
2337
+ const primary = read(group.primary);
2338
+ const backup = read(group.backup);
2339
+ if (primary === null || backup === null) {
2340
+ problems.push("omniroute.tiers must be an object with primary/backup string arrays");
2341
+ } else {
2342
+ tiers = { primary, backup };
2343
+ }
2344
+ }
2345
+ }
2346
+ if (models !== void 0) {
2347
+ const allowed = new Set(models);
2348
+ tiers = {
2349
+ primary: tiers.primary.filter((id) => allowed.has(id)),
2350
+ backup: tiers.backup.filter((id) => allowed.has(id))
2351
+ };
2352
+ }
2353
+ let apiKey;
2354
+ if (block.apiKey !== void 0 && typeof block.apiKey !== "string") {
2355
+ problems.push("omniroute.apiKey must be a string when set");
2356
+ }
2357
+ const fromEnv = env[ENV_API_KEY];
2358
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") apiKey = fromEnv.trim();
2359
+ else if (typeof block.apiKey === "string" && block.apiKey.trim() !== "") apiKey = block.apiKey.trim();
2360
+ if (problems.length > 0) return { enabled: true, config: null, problems };
2361
+ const config = {
2362
+ baseURL,
2363
+ tiers,
2364
+ stickyPerSession,
2365
+ healthCheck,
2366
+ timeoutMs
2367
+ };
2368
+ if (apiKey !== void 0) config.apiKey = apiKey;
2369
+ if (combo !== void 0) config.combo = combo;
2370
+ if (models !== void 0) config.models = models;
2371
+ return { enabled: true, config, problems: [] };
2372
+ }
2373
+ function filterModels(ids, config) {
2374
+ const out = [];
2375
+ const push = (id) => {
2376
+ if (id.trim() === "") return;
2377
+ if (!out.includes(id)) out.push(id);
2378
+ };
2379
+ if (config.combo !== void 0) push(config.combo);
2380
+ if (config.models !== void 0) {
2381
+ for (const id of config.models) if (ids.includes(id)) push(id);
2382
+ } else {
2383
+ for (const id of ids) push(id);
2384
+ }
2385
+ return out;
2386
+ }
2387
+ function orderByTier(ids, tiers) {
2388
+ const present = new Set(ids);
2389
+ const out = [];
2390
+ const push = (id) => {
2391
+ if (!out.includes(id)) out.push(id);
2392
+ };
2393
+ for (const id of tiers.primary) if (present.has(id)) push(id);
2394
+ for (const id of tiers.backup) if (present.has(id)) push(id);
2395
+ for (const id of ids) push(id);
2396
+ return out;
2397
+ }
2398
+ function isTransientStatus(status) {
2399
+ if (status === 429 || status === 408) return true;
2400
+ return status >= 500 && status <= 599;
2401
+ }
2402
+ function backoffMs(strikes, capMs = COOLDOWN_MAX_MS2) {
2403
+ const count = Number.isFinite(strikes) ? strikes : 1;
2404
+ return Math.min(COOLDOWN_BASE_MS2 * 2 ** (count - 1), capMs);
2405
+ }
2406
+ function cooldownUntil(opts) {
2407
+ const cap = opts.capMs ?? COOLDOWN_MAX_MS2;
2408
+ const now = opts.now ?? Date.now();
2409
+ const retryAfterMs2 = opts.retryAfterMs;
2410
+ if (typeof retryAfterMs2 === "number" && Number.isFinite(retryAfterMs2) && retryAfterMs2 > 0) {
2411
+ return now + Math.min(retryAfterMs2, cap);
2412
+ }
2413
+ const strikes = opts.strikes ?? 1;
2414
+ return now + backoffMs(Math.max(1, strikes), cap);
2415
+ }
2416
+ var rateOf = (stat) => {
2417
+ const attempts = stat.requests + stat.errors;
2418
+ return attempts > 0 ? stat.errors / attempts : 0;
2419
+ };
2420
+ var ttftOf = (stat) => stat.ttftEwma ?? Number.POSITIVE_INFINITY;
2421
+ function createSelector(config, deps = {}) {
2422
+ const now = deps.now ?? Date.now;
2423
+ const stats = /* @__PURE__ */ new Map();
2424
+ const sticky = /* @__PURE__ */ new Map();
2425
+ let cands = [];
2426
+ const ensure = (modelID) => {
2427
+ let stat = stats.get(modelID);
2428
+ if (stat === void 0) {
2429
+ stat = { requests: 0, errors: 0, strikes: 0, cooldownUntil: 0 };
2430
+ stats.set(modelID, stat);
2431
+ }
2432
+ return stat;
2433
+ };
2434
+ const cooling = (modelID) => {
2435
+ const stat = stats.get(modelID);
2436
+ return stat !== void 0 && stat.cooldownUntil > now();
2437
+ };
2438
+ const better = (challenger, challengerIndex, best, bestIndex) => {
2439
+ const challengerRate = rateOf(challenger);
2440
+ const bestRate = rateOf(best);
2441
+ if (challengerRate !== bestRate) return challengerRate < bestRate;
2442
+ const challengerTtft = ttftOf(challenger);
2443
+ const bestTtft = ttftOf(best);
2444
+ if (challengerTtft !== bestTtft) return challengerTtft < bestTtft;
2445
+ return challengerIndex < bestIndex;
2446
+ };
2447
+ return {
2448
+ setCandidates(ids) {
2449
+ const next = dedupe(ids.filter((id) => typeof id === "string" && id.trim() !== ""));
2450
+ cands = next;
2451
+ for (const id of next) ensure(id);
2452
+ },
2453
+ candidates() {
2454
+ return [...cands];
2455
+ },
2456
+ select(sessionID) {
2457
+ const list = cands;
2458
+ if (list.length === 0) return null;
2459
+ if (config.stickyPerSession) {
2460
+ const held = sticky.get(sessionID);
2461
+ if (held !== void 0 && list.includes(held) && !cooling(held)) return held;
2462
+ }
2463
+ let bestID = null;
2464
+ let bestStat = null;
2465
+ let bestIndex = -1;
2466
+ for (const [index, id] of list.entries()) {
2467
+ const stat = ensure(id);
2468
+ if (stat.cooldownUntil > now()) continue;
2469
+ if (bestStat === null || bestID === null || better(stat, index, bestStat, bestIndex)) {
2470
+ bestID = id;
2471
+ bestStat = stat;
2472
+ bestIndex = index;
2473
+ }
2474
+ }
2475
+ if (bestID === null) return null;
2476
+ if (config.stickyPerSession) sticky.set(sessionID, bestID);
2477
+ return bestID;
2478
+ },
2479
+ stickyFor(sessionID) {
2480
+ return sticky.get(sessionID) ?? null;
2481
+ },
2482
+ recordSuccess(sessionID, modelID, ttftMs) {
2483
+ void sessionID;
2484
+ const stat = ensure(modelID);
2485
+ stat.requests += 1;
2486
+ if (typeof ttftMs === "number" && Number.isFinite(ttftMs) && ttftMs >= 0) {
2487
+ stat.ttftEwma = stat.ttftEwma === void 0 ? ttftMs : stat.ttftEwma * 0.6 + ttftMs * 0.4;
2488
+ }
2489
+ },
2490
+ recordFailure(sessionID, modelID, opts = {}) {
2491
+ const stat = ensure(modelID);
2492
+ stat.errors += 1;
2493
+ if (opts.message !== void 0) stat.lastFailure = opts.message;
2494
+ const transient = opts.transient ?? (opts.status === void 0 ? true : isTransientStatus(opts.status));
2495
+ if (!transient) return;
2496
+ stat.strikes += 1;
2497
+ const until = cooldownUntil({ retryAfterMs: opts.retryAfterMs, strikes: stat.strikes, now: now() });
2498
+ if (until > stat.cooldownUntil) stat.cooldownUntil = until;
2499
+ if (sticky.get(sessionID) === modelID) sticky.delete(sessionID);
2500
+ },
2501
+ isCooling(modelID) {
2502
+ return cooling(modelID);
2503
+ },
2504
+ stats() {
2505
+ const out = {};
2506
+ for (const [id, stat] of stats) out[id] = { ...stat };
2507
+ return out;
2508
+ }
2509
+ };
2510
+ }
2511
+ var statusReason = (status) => {
2512
+ const reasons = {
2513
+ 400: "bad request",
2514
+ 401: "unauthorized",
2515
+ 403: "forbidden",
2516
+ 404: "not found",
2517
+ 408: "request timeout",
2518
+ 429: "rate limited",
2519
+ 500: "server error",
2520
+ 502: "bad gateway",
2521
+ 503: "unavailable",
2522
+ 504: "gateway timeout"
2523
+ };
2524
+ if (Number.isFinite(status) && reasons[status] !== void 0) return reasons[status];
2525
+ return status >= 500 ? "server error" : "http error";
2526
+ };
2527
+ var entryID = (entry) => {
2528
+ if (typeof entry === "string") return entry.trim() === "" ? null : entry.trim();
2529
+ if (entry !== null && typeof entry === "object") {
2530
+ const id = entry.id;
2531
+ if (typeof id === "string") return id.trim() === "" ? null : id.trim();
2532
+ }
2533
+ return null;
2534
+ };
2535
+ var listEntries = (body) => {
2536
+ if (Array.isArray(body)) return body;
2537
+ if (body === null || typeof body !== "object") return null;
2538
+ const obj = body;
2539
+ if (Array.isArray(obj.data)) return obj.data;
2540
+ if (Array.isArray(obj.models)) return obj.models;
2541
+ return null;
2542
+ };
2543
+ async function fetchModels(opts) {
2544
+ const fetchImpl = opts.fetchImpl ?? fetch;
2545
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2546
+ const url = `${opts.baseURL}${MODELS_PATH}`;
2547
+ const controller = new AbortController();
2548
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2549
+ try {
2550
+ const headers = {};
2551
+ if (opts.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
2552
+ const response = await fetchImpl(url, { headers, signal: controller.signal });
2553
+ const status = typeof response?.status === "number" ? response.status : Number.NaN;
2554
+ const ok = typeof response?.ok === "boolean" ? response.ok : Number.isFinite(status) ? status >= 200 && status < 300 : true;
2555
+ if (!ok) throw new Error(`omniroute GET ${url} failed: HTTP ${status} ${statusReason(status)}`);
2556
+ let body;
2557
+ try {
2558
+ body = await response.json();
2559
+ } catch {
2560
+ throw new Error(`omniroute GET ${url} returned a non-JSON body`);
2561
+ }
2562
+ const entries = listEntries(body);
2563
+ if (entries === null) throw new Error(`omniroute GET ${url} returned an unsupported body shape`);
2564
+ const out = [];
2565
+ for (const entry of entries) {
2566
+ const id = entryID(entry);
2567
+ if (id !== null && !out.includes(id)) out.push(id);
2568
+ }
2569
+ return out;
2570
+ } catch (error) {
2571
+ if (controller.signal.aborted) throw new Error(`omniroute GET ${url} timed out after ${timeoutMs}ms`);
2572
+ throw error;
2573
+ } finally {
2574
+ clearTimeout(timer);
2575
+ }
2576
+ }
2577
+ async function checkHealth(opts) {
2578
+ try {
2579
+ await fetchModels(opts);
2580
+ return true;
2581
+ } catch {
2582
+ return false;
2583
+ }
2584
+ }
2585
+ function createModelCache(opts) {
2586
+ const ttlMs = opts.ttlMs ?? DEFAULT_CACHE_TTL_MS;
2587
+ const now = opts.now ?? Date.now;
2588
+ let cached = null;
2589
+ let cachedAt = 0;
2590
+ return {
2591
+ async get(force) {
2592
+ if (!force && cached !== null && now() - cachedAt < ttlMs) return [...cached];
2593
+ try {
2594
+ const ids = await fetchModels(opts);
2595
+ cached = ids;
2596
+ cachedAt = now();
2597
+ return [...ids];
2598
+ } catch (error) {
2599
+ if (cached !== null) return [...cached];
2600
+ throw error;
2601
+ }
2602
+ },
2603
+ invalidate() {
2604
+ cached = null;
2605
+ cachedAt = 0;
2606
+ }
2607
+ };
2608
+ }
2609
+ function redactApiKey(text, apiKey) {
2610
+ if (typeof apiKey !== "string" || apiKey === "") return text;
2611
+ return text.split(apiKey).join("[redacted]");
2612
+ }
2613
+
2614
+ // src/plugins/omniroute.ts
2615
+ var SERVICE4 = "omniroute";
2616
+ var omniRouteHooks = async (input, options, deps = {}) => {
2617
+ const opts = options ?? {};
2618
+ const parsed = parseConfig(opts.omniroute, deps.env ?? process.env);
2619
+ const log = (level, message) => input.client.app.log({ body: { service: SERVICE4, level, message } }).catch(() => {
2620
+ });
2621
+ const warned = /* @__PURE__ */ new Set();
2622
+ const warnOnce = async (message) => {
2623
+ if (warned.has(message)) return;
2624
+ warned.add(message);
2625
+ await log("warn", message);
2626
+ };
2627
+ if (!parsed.enabled) return {};
2628
+ if (parsed.config === null) {
2629
+ await warnOnce(`omniroute is enabled but unusable: ${parsed.problems.join("; ")}`);
2630
+ return {};
2631
+ }
2632
+ const config = parsed.config;
2633
+ const cache = createModelCache({
2634
+ baseURL: config.baseURL,
2635
+ apiKey: config.apiKey,
2636
+ timeoutMs: config.timeoutMs,
2637
+ fetchImpl: deps.fetchImpl,
2638
+ now: deps.now
2639
+ });
2640
+ const selector = createSelector(config, { now: deps.now });
2641
+ let disabled = false;
2642
+ let poolModel = null;
2643
+ const probe = async () => {
2644
+ if (config.healthCheck) {
2645
+ const healthy = await checkHealth({
2646
+ baseURL: config.baseURL,
2647
+ apiKey: config.apiKey,
2648
+ timeoutMs: config.timeoutMs,
2649
+ fetchImpl: deps.fetchImpl
2650
+ });
2651
+ if (!healthy) {
2652
+ disabled = true;
2653
+ await warnOnce(`omniroute health check failed at ${config.baseURL}; falling back to lacode's normal behaviour for this run`);
2654
+ return;
2655
+ }
2656
+ }
2657
+ let ids;
2658
+ try {
2659
+ ids = await cache.get();
2660
+ } catch (error) {
2661
+ disabled = true;
2662
+ await warnOnce(
2663
+ redactApiKey(
2664
+ `omniroute is unreachable at ${config.baseURL}: ${error instanceof Error ? error.message : String(error)}; falling back for this run`,
2665
+ config.apiKey
2666
+ )
2667
+ );
2668
+ return;
2669
+ }
2670
+ const candidates = orderByTier(filterModels(ids, config), config.tiers);
2671
+ if (candidates.length === 0) {
2672
+ disabled = true;
2673
+ await warnOnce(`omniroute returned no usable models at ${config.baseURL}; falling back for this run`);
2674
+ return;
2675
+ }
2676
+ selector.setCandidates(candidates);
2677
+ const chosen = config.combo ?? (config.models !== void 0 && config.models.length > 0 ? candidates[0] : "auto");
2678
+ poolModel = `omniroute/${chosen}`;
2679
+ await log("info", `omniroute: ${candidates.length} model(s) from ${config.baseURL}: ${candidates.join(", ")}`);
2680
+ };
2681
+ let started = null;
2682
+ const initialize = () => started ??= probe();
2683
+ const injectProvider = async (cfg, warn) => {
2684
+ const providers = cfg.provider ??= {};
2685
+ if (providers.omniroute !== void 0) {
2686
+ await warn("omniroute provider is already defined in your config; lacode left it untouched");
2687
+ return;
2688
+ }
2689
+ const exposed = selector.candidates();
2690
+ const target = poolModel !== null ? poolModel.slice("omniroute/".length) : null;
2691
+ const ids = target !== null && !exposed.includes(target) ? [target, ...exposed] : exposed;
2692
+ const models = {};
2693
+ for (const id of ids) models[id] = { name: id };
2694
+ providers.omniroute = {
2695
+ name: "OmniRoute",
2696
+ // The documented opencode registration mechanism for a gateway speaking
2697
+ // the OpenAI `/v1` API: opencode loads this package itself from the user's
2698
+ // own install, so lacode needs no dependency to expose OmniRoute. No
2699
+ // `capabilities` are declared on purpose -- opencode ignores custom
2700
+ // capabilities for openai-compatible providers, so writing one would be
2701
+ // decoration that reads like a promise.
2702
+ npm: "@ai-sdk/openai-compatible",
2703
+ options: {
2704
+ baseURL: config.baseURL,
2705
+ // Absent rather than blank: an empty bearer on a keyless gateway is a
2706
+ // 401 that looks like a configuration error.
2707
+ ...config.apiKey ? { apiKey: config.apiKey } : {},
2708
+ timeout: config.timeoutMs
2709
+ },
2710
+ models
2711
+ };
2712
+ if (poolModel !== null) {
2713
+ const ps = state(options ?? {});
2714
+ setPrimarySlot(ps, { model: poolModel });
2715
+ }
2716
+ };
2717
+ return {
2718
+ config: async (cfg) => {
2719
+ try {
2720
+ await initialize();
2721
+ if (disabled) return;
2722
+ await injectProvider(cfg, warnOnce);
2723
+ } catch (error) {
2724
+ await warnOnce(
2725
+ redactApiKey(
2726
+ `omniroute config injection failed: ${error instanceof Error ? error.message : String(error)}`,
2727
+ config.apiKey
2728
+ )
2729
+ );
2730
+ }
2731
+ }
2732
+ };
2733
+ };
2734
+
2154
2735
  // src/lib/selfupdate.ts
2155
2736
  import { spawn as spawn2 } from "node:child_process";
2156
2737
  import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
@@ -2443,6 +3024,7 @@ var LaCode = (async (input, options) => {
2443
3024
  const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
2444
3025
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
2445
3026
  const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
3027
+ const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
2446
3028
  const config = async (cfg) => {
2447
3029
  try {
2448
3030
  const injected = applyAgents(cfg, agentSet);
@@ -2456,6 +3038,7 @@ var LaCode = (async (input, options) => {
2456
3038
  if (applyInstructions(cfg).added) {
2457
3039
  await log("info", "instructions: added the standing context-map rule (no user instructions were present)");
2458
3040
  }
3041
+ await omniroute.config?.(cfg);
2459
3042
  await pool.config?.(cfg);
2460
3043
  } catch (error) {
2461
3044
  await log("error", `config hook failed: ${String(error)}`);
@@ -2484,6 +3067,7 @@ var LaCode = (async (input, options) => {
2484
3067
  await autoupdate.dispose?.();
2485
3068
  await notifier.dispose?.();
2486
3069
  await selfupdate.dispose?.();
3070
+ await omniroute.dispose?.();
2487
3071
  }
2488
3072
  };
2489
3073
  });
package/dist/pool.js CHANGED
@@ -36,7 +36,10 @@ var MAX_WEIGHT = 100;
36
36
  var MAX_SLOTS = 4;
37
37
  var COOLDOWN_BASE_MS = 60 * 1e3;
38
38
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
39
+ var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
40
+ var QUOTA_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1e3;
39
41
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
42
+ var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
40
43
  var BASES = ["explore", "implement", "context"];
41
44
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
42
45
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
@@ -238,6 +241,7 @@ function state(opts = {}) {
238
241
  catalog: /* @__PURE__ */ new Map(),
239
242
  catalogAt: 0,
240
243
  cooling: /* @__PURE__ */ new Map(),
244
+ strikes: /* @__PURE__ */ new Map(),
241
245
  activity: /* @__PURE__ */ new Map(),
242
246
  reaped: 0,
243
247
  reaping: false,
@@ -246,19 +250,51 @@ function state(opts = {}) {
246
250
  dir,
247
251
  modelsFile,
248
252
  inlineModels: models,
253
+ primarySlot: null,
249
254
  origin: "unresolved",
250
255
  slots: []
251
256
  };
252
257
  }
253
258
  return g[key];
254
259
  }
260
+ function replacePrimarySlot(base, primary) {
261
+ const problems = [];
262
+ const model = typeof primary?.model === "string" ? primary.model.trim() : "";
263
+ if (!model.includes("/")) {
264
+ problems.push(`primary slot: model must be written provider/model-id, got "${model}"`);
265
+ return { slots: base, problems };
266
+ }
267
+ const provider = providerOf(model);
268
+ if (base.some((slot) => providerOf(slot.model) === provider)) {
269
+ problems.push(`primary slot ${model}: provider ${provider} is already in the pool; the primary slot is skipped`);
270
+ return { slots: base, problems };
271
+ }
272
+ if (base.length === 0) return { slots: [{ index: 1, model, weight: Math.max(1, primary.weight ?? 1) }], problems };
273
+ let weight = primary.weight;
274
+ if (weight !== void 0 && (typeof weight !== "number" || !Number.isFinite(weight) || weight <= 0)) {
275
+ problems.push(`primary slot ${model}: weight must be a positive number; inheriting the replaced slot's weight`);
276
+ weight = void 0;
277
+ }
278
+ const slots = base.map((slot, i) => i === 0 ? { ...slot, model, weight: weight ?? slot.weight } : slot);
279
+ return { slots, problems };
280
+ }
281
+ function setPrimarySlot(s, primary) {
282
+ s.primarySlot = primary;
283
+ if (s.slots.length > 0) s.slots = [];
284
+ }
255
285
  function ensureSlots(s, onWarn) {
256
286
  if (s.slots.length > 0) return s.slots;
257
287
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
258
- s.slots = slots;
288
+ let resolved = slots;
289
+ if (s.primarySlot !== null) {
290
+ const replaced = replacePrimarySlot(slots, s.primarySlot);
291
+ resolved = replaced.slots;
292
+ problems.push(...replaced.problems);
293
+ }
294
+ s.slots = resolved;
259
295
  s.origin = origin;
260
296
  for (const problem of problems) onWarn(problem);
261
- return slots;
297
+ return resolved;
262
298
  }
263
299
  var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
264
300
  var selfFile = (id) => `claims.${id}.json`;
@@ -294,6 +330,14 @@ function publish(s) {
294
330
  s.dirReady = false;
295
331
  }
296
332
  }
333
+ var isPidAlive = (pid) => {
334
+ try {
335
+ process.kill(pid, 0);
336
+ return true;
337
+ } catch (error) {
338
+ return error.code === "EPERM";
339
+ }
340
+ };
297
341
  function readSiblings(s) {
298
342
  const siblings = [];
299
343
  let entries;
@@ -327,6 +371,13 @@ function readSiblings(s) {
327
371
  }
328
372
  continue;
329
373
  }
374
+ if (/^\d+$/.test(match[1]) && !isPidAlive(Number(match[1]))) {
375
+ try {
376
+ unlinkSync(path);
377
+ } catch {
378
+ }
379
+ continue;
380
+ }
330
381
  try {
331
382
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
332
383
  const claims = Object.values(parsed).filter(
@@ -340,10 +391,13 @@ function readSiblings(s) {
340
391
  }
341
392
  function readLimits(s) {
342
393
  const cooling = /* @__PURE__ */ new Map();
394
+ const memory = /* @__PURE__ */ new Map();
343
395
  let entries;
344
396
  try {
345
397
  entries = readdirSync(s.dir);
346
398
  } catch {
399
+ s.cooling = cooling;
400
+ s.strikes = memory;
347
401
  return cooling;
348
402
  }
349
403
  const now = Date.now();
@@ -353,8 +407,14 @@ function readLimits(s) {
353
407
  try {
354
408
  const strike = JSON.parse(readFileSync2(path, "utf8"));
355
409
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
356
- if (strike.until > now) cooling.set(strike.model, strike);
357
- else unlinkSync(path);
410
+ if (strike.until > now) {
411
+ cooling.set(strike.model, strike);
412
+ memory.set(strike.model, strike);
413
+ } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
414
+ memory.set(strike.model, strike);
415
+ } else {
416
+ unlinkSync(path);
417
+ }
358
418
  } catch {
359
419
  try {
360
420
  unlinkSync(path);
@@ -363,16 +423,21 @@ function readLimits(s) {
363
423
  }
364
424
  }
365
425
  s.cooling = cooling;
426
+ s.strikes = memory;
366
427
  return cooling;
367
428
  }
368
429
  function recordStrike(s, model, reason, stated) {
369
430
  if (!s.dirReady) return;
370
431
  const now = Date.now();
371
- const prior = s.cooling.get(model);
432
+ const prior = s.strikes.get(model);
372
433
  const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
373
- const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
434
+ const quota = isQuotaReason(reason);
435
+ const base = quota ? QUOTA_COOLDOWN_BASE_MS : COOLDOWN_BASE_MS;
436
+ const cap = quota ? QUOTA_COOLDOWN_MAX_MS : COOLDOWN_MAX_MS;
437
+ const backoff = Math.min(cap, base * 2 ** (strikes - 1));
374
438
  const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
375
439
  s.cooling.set(model, strike);
440
+ s.strikes.set(model, strike);
376
441
  const path = join2(s.dir, limitFile(model));
377
442
  try {
378
443
  writeFileSync(`${path}.tmp`, JSON.stringify(strike));
@@ -389,6 +454,7 @@ function coolUnavailable(s, model) {
389
454
  if (prior && prior.until > until) return prior;
390
455
  const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
391
456
  s.cooling.set(model, strike);
457
+ s.strikes.set(model, strike);
392
458
  const path = join2(s.dir, limitFile(model));
393
459
  try {
394
460
  writeFileSync(`${path}.tmp`, JSON.stringify(strike));
@@ -511,6 +577,8 @@ export {
511
577
  ORPHAN_TMP_MS,
512
578
  PACKAGE_ROOT,
513
579
  PRESETS_DIR,
580
+ QUOTA_COOLDOWN_BASE_MS,
581
+ QUOTA_COOLDOWN_MAX_MS,
514
582
  STUCK_IDLE_MS,
515
583
  STUCK_MIN_AGE_MS,
516
584
  UNAVAILABLE_COOLDOWN_MS,
@@ -530,6 +598,7 @@ export {
530
598
  init,
531
599
  isAvailable,
532
600
  isLimitText,
601
+ isQuotaReason,
533
602
  isUnavailableText,
534
603
  noteActivity,
535
604
  parseSlotText,
@@ -546,9 +615,11 @@ export {
546
615
  refreshCatalog,
547
616
  release,
548
617
  releaseStuck,
618
+ replacePrimarySlot,
549
619
  resolveModels,
550
620
  retryAfterMs,
551
621
  selfFile,
622
+ setPrimarySlot,
552
623
  slotFor,
553
624
  snapshot,
554
625
  splitModel,
@@ -140,6 +140,17 @@ correct exactly when a whitelist exists (it is a real restriction, and the pool
140
140
  models would otherwise resolve to nothing) and is not merely unnecessary when one
141
141
  does not.
142
142
 
143
+ ### US-28: A local gateway (OmniRoute) becomes the pool's primary slot
144
+ **As a** user running a local OpenAI-compatible gateway, **I want** LaCode to drain the pool's mechanical work through it first, **so that** the bundled opencode/opencode-go ("zen") models stop carrying that load.
145
+
146
+ **Story ID(s) for tests:** US-28
147
+ **Acceptance criteria:**
148
+ - [ ] Given `omniroute.enabled` with a reachable gateway and a `combo` or `models` choice, When the config hook runs, Then slot 1 of the pool is `omniroute/<combo | first allowlisted model>` — replacing the first slot's model IN PLACE and inheriting that slot's weight — so it is the model the pool takes first.
149
+ - [ ] Given OmniRoute is disabled, enabled with an invalid block, unhealthy, or returns no usable models, When the pool resolves, Then no slot is changed and the resolved slots are exactly what the chain produced — the behaviour before the integration existed.
150
+ - [ ] Given the user's own pool already routes on the `omniroute` provider, When the integration is healthy, Then the first slot is left exactly as written and no second slot is stacked on that provider.
151
+ - [ ] Given the primary slot is injected, When the pool resolves, Then the number of slots is unchanged and every slot after the first is byte-identical to what the chain produced — nothing is dropped or renumbered, because the replacement creates no extra slot.
152
+ - [ ] Given an `omniroute` slot is injected, When the pool binds its variants, Then it does not warn that the provider is missing, because OmniRoute registers the provider before the pool's config hook runs.
153
+
143
154
  ---
144
155
 
145
156
  ## Epic C — Hang detection and auto-recovery
@@ -413,6 +424,7 @@ table was filled in.
413
424
  | US-5 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-test.mjs`, `tests/pool-models-test.mjs` |
414
425
  | US-6 | B | `tests/stories/b-provider-resilience.test.mjs` |
415
426
  | US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
427
+ | US-28 | B | `tests/stories/i-omniroute.test.mjs` |
416
428
  | US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
417
429
  | US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
418
430
  | US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
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",