@lawaty/lacode 0.1.10 → 0.2.1

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,112 @@ 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 id (or `auto`) to expose as a selectable model even though the gateway does not list it. 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
+ **Using OmniRoute in the pool.** The integration only *registers* the provider —
383
+ it never touches your pool, and it does not place a slot for you. To route pooled
384
+ work (exploration, implementation, cartography) through the gateway, list it in
385
+ your pool like any other model:
386
+
387
+ ```
388
+ # ~/.config/lacode/pool.txt
389
+ omniroute/auto 3
390
+ opencode/space-bunny-free 2
391
+ opencode-go/step-5-preview-free 1
392
+ opencode/big-pickle 1
393
+ ```
394
+
395
+ `omniroute/auto` then takes slot 1 (weight 3) and drains pooled mechanical work
396
+ before it reaches the bundled opencode/opencode-go ("zen") models. Any
397
+ `omniroute/<id>` works, provided the id is exposed — by `combo`/`models`, or the
398
+ whole catalog when neither is set. Because the plugin writes the pool nothing,
399
+ the only thing that changes when the gateway is down is that `omniroute/<id>`
400
+ fails; every other slot keeps working. See
401
+ [docs/USER-STORIES.md](docs/USER-STORIES.md) US-28.
402
+
403
+ **A note on mid-session switching.** opencode 1.18.34 exposes no hook to change
404
+ an agent's main model mid-request, so LaCode cannot swap models inside a running
405
+ session; use an OmniRoute `combo` (or `auto`) if you want per-request fallback.
406
+ LaCode's selection engine decides which models are exposed and in what order, and
407
+ its cooldown/backoff rules are unit-tested.
408
+
409
+ **Getting a key.** Open the OmniRoute dashboard, go to API Manager / API Keys and
410
+ copy a key into `OMNIROUTE_API_KEY` (recommended) or `omniroute.apiKey`. On a
411
+ local instance with `REQUIRE_API_KEY=false`, the literal placeholder
412
+ `sk_omniroute` is accepted.
413
+
414
+ **Troubleshooting**
415
+
416
+ | Symptom | Cause | Fix |
417
+ | --- | --- | --- |
418
+ | `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. |
419
+ | `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. |
420
+ | `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`. |
421
+ | `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. |
422
+ | 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. |
423
+ | Pooled work still runs on the bundled models | The gateway was unhealthy/disabled at startup, or you have no `omniroute/…` slot in your pool | Check the startup log for `omniroute health check failed` / `no usable models`, then add `omniroute/<model>` to `pool.txt` (or `pool.json`) to route pooled work through the gateway. |
424
+
318
425
  ### Which models run which agent
319
426
 
320
427
  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,
@@ -294,6 +298,14 @@ function publish(s) {
294
298
  s.dirReady = false;
295
299
  }
296
300
  }
301
+ var isPidAlive = (pid) => {
302
+ try {
303
+ process.kill(pid, 0);
304
+ return true;
305
+ } catch (error) {
306
+ return error.code === "EPERM";
307
+ }
308
+ };
297
309
  function readSiblings(s) {
298
310
  const siblings = [];
299
311
  let entries;
@@ -327,6 +339,13 @@ function readSiblings(s) {
327
339
  }
328
340
  continue;
329
341
  }
342
+ if (/^\d+$/.test(match[1]) && !isPidAlive(Number(match[1]))) {
343
+ try {
344
+ unlinkSync(path4);
345
+ } catch {
346
+ }
347
+ continue;
348
+ }
330
349
  try {
331
350
  const parsed = JSON.parse(readFileSync2(path4, "utf8"));
332
351
  const claims = Object.values(parsed).filter(
@@ -340,10 +359,13 @@ function readSiblings(s) {
340
359
  }
341
360
  function readLimits(s) {
342
361
  const cooling = /* @__PURE__ */ new Map();
362
+ const memory = /* @__PURE__ */ new Map();
343
363
  let entries;
344
364
  try {
345
365
  entries = readdirSync(s.dir);
346
366
  } catch {
367
+ s.cooling = cooling;
368
+ s.strikes = memory;
347
369
  return cooling;
348
370
  }
349
371
  const now = Date.now();
@@ -353,8 +375,14 @@ function readLimits(s) {
353
375
  try {
354
376
  const strike = JSON.parse(readFileSync2(path4, "utf8"));
355
377
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
356
- if (strike.until > now) cooling.set(strike.model, strike);
357
- else unlinkSync(path4);
378
+ if (strike.until > now) {
379
+ cooling.set(strike.model, strike);
380
+ memory.set(strike.model, strike);
381
+ } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
382
+ memory.set(strike.model, strike);
383
+ } else {
384
+ unlinkSync(path4);
385
+ }
358
386
  } catch {
359
387
  try {
360
388
  unlinkSync(path4);
@@ -363,16 +391,21 @@ function readLimits(s) {
363
391
  }
364
392
  }
365
393
  s.cooling = cooling;
394
+ s.strikes = memory;
366
395
  return cooling;
367
396
  }
368
397
  function recordStrike(s, model, reason, stated) {
369
398
  if (!s.dirReady) return;
370
399
  const now = Date.now();
371
- const prior = s.cooling.get(model);
400
+ const prior = s.strikes.get(model);
372
401
  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));
402
+ const quota = isQuotaReason(reason);
403
+ const base = quota ? QUOTA_COOLDOWN_BASE_MS : COOLDOWN_BASE_MS;
404
+ const cap = quota ? QUOTA_COOLDOWN_MAX_MS : COOLDOWN_MAX_MS;
405
+ const backoff = Math.min(cap, base * 2 ** (strikes - 1));
374
406
  const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
375
407
  s.cooling.set(model, strike);
408
+ s.strikes.set(model, strike);
376
409
  const path4 = join2(s.dir, limitFile(model));
377
410
  try {
378
411
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
@@ -389,6 +422,7 @@ function coolUnavailable(s, model) {
389
422
  if (prior && prior.until > until) return prior;
390
423
  const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
391
424
  s.cooling.set(model, strike);
425
+ s.strikes.set(model, strike);
392
426
  const path4 = join2(s.dir, limitFile(model));
393
427
  try {
394
428
  writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
@@ -524,8 +558,31 @@ Every question of the form "what does this file do", "where is X handled", or
524
558
  that proves it. Never one whole refactor handed over in a single piece \u2014
525
559
  see \`# Task size\` below.
526
560
 
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.
561
+ The agents you spawn are the pooled \`explore\` / \`implement\` / \`context\`
562
+ bases. **Always spawn the BARE base name and let the plugin choose the slot;
563
+ never name a \`-N\` variant** (\`explore-1\`..\`explore-4\`, \`implement-1\`..,
564
+ \`context-1\`..). The plugin owns which model every pooled spawn runs on: it
565
+ picks the next slot from the pool's weights, current load and cooldowns, and
566
+ rewrites your spawn to that variant for you. Naming a variant bypasses that
567
+ decision, defeats failover, and can pin work to a cooling or unhealthy model.
568
+ If you catch yourself choosing a slot number, you are doing the plugin's job \u2014
569
+ spawn \`explore\` and let it decide.
570
+
571
+ # When the pool is unavailable
572
+
573
+ The pooled agents are the ONLY route to investigation and implementation. If the
574
+ pool cannot serve a spawn \u2014 every slot cooling or exhausted, spawns repeatedly
575
+ failing, or a runtime message saying no slot is available \u2014 that is a STOP
576
+ condition, not a reason to do the work yourself.
577
+
578
+ **Never fall back to reading, grepping, exploring or implementing on your own.**
579
+ Do not "just quickly" inspect a file because a spawn failed. Stop, say what
580
+ failed (slot, model, error), and ASK the user how to proceed: wait for a slot to
581
+ recover, change the pool, or obtain explicit permission to work without
582
+ subagents. A failed spawn is never permission, and silence is not consent.
583
+
584
+ \`edit\`/\`write\`/\`bash\` stay yours for applying and verifying what a subagent
585
+ returned \u2014 never as a substitute for a spawn that could not run.
529
586
 
530
587
  # Task size
531
588
 
@@ -629,6 +686,16 @@ When you need to understand the codebase:
629
686
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
630
687
  3. @explore will return its findings \u2014 you process them and plan accordingly
631
688
 
689
+ Always spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`) and let
690
+ the plugin choose the slot. Never name a \`-N\` variant (\`explore-1\`..\`explore-4\`);
691
+ doing so bypasses the pool's weights, load and cooldowns, which is the one thing
692
+ the plugin exists to decide.
693
+
694
+ If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
695
+ failures \u2014 do NOT fall back to reading or planning from your own inspection.
696
+ Stop and ASK the user how to proceed; a failed spawn is not permission to
697
+ investigate yourself.
698
+
632
699
  Repository context: before delegating investigation for a non-trivial task,
633
700
  tell the exploration agent to first check \`.opencode/context/\` in this project
634
701
  (using \`ls\`, never \`glob\` \u2014 it skips hidden directories)
@@ -1282,9 +1349,10 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1282
1349
  `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
1350
  { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: killed }
1284
1351
  );
1285
- const prior = s.cooling.get(claim.m);
1286
1352
  const now = Date.now();
1287
- const strike = recordStrike(s, claim.m, "hang", prior && prior.until > now ? prior.until - now : HANG_FALLBACK_MS);
1353
+ const prior = s.cooling.get(claim.m);
1354
+ if (prior && prior.until > now) continue;
1355
+ const strike = recordStrike(s, claim.m, "hang", HANG_FALLBACK_MS);
1288
1356
  if (strike) await log("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1289
1357
  }
1290
1358
  } catch (error) {
@@ -1492,6 +1560,7 @@ priority is weight: the heaviest slot under its ceiling takes the next spawn (FU
1492
1560
  dispose: async () => {
1493
1561
  clearInterval(timer);
1494
1562
  s.own.clear();
1563
+ publish(s);
1495
1564
  }
1496
1565
  };
1497
1566
  });
@@ -2151,6 +2220,477 @@ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2151
2220
  };
2152
2221
  });
2153
2222
 
2223
+ // src/lib/omniroute.ts
2224
+ var DEFAULT_BASE_URL = "http://localhost:20128/v1";
2225
+ var ENV_API_KEY = "OMNIROUTE_API_KEY";
2226
+ var DEFAULT_TIMEOUT_MS = 5e3;
2227
+ var DEFAULT_CACHE_TTL_MS = 6e4;
2228
+ var COOLDOWN_BASE_MS2 = 1e3;
2229
+ var COOLDOWN_MAX_MS2 = 15 * 60 * 1e3;
2230
+ var MODELS_PATH = "/models";
2231
+ var dedupe = (ids) => {
2232
+ const out = [];
2233
+ for (const id of ids) if (!out.includes(id)) out.push(id);
2234
+ return out;
2235
+ };
2236
+ var isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
2237
+ var isStringArray = (value) => Array.isArray(value) && value.every((entry) => isNonEmptyString(entry));
2238
+ function normalizeBaseURL(raw) {
2239
+ if (typeof raw !== "string") return null;
2240
+ const trimmed = raw.trim();
2241
+ if (trimmed === "") return null;
2242
+ let url;
2243
+ try {
2244
+ url = new URL(trimmed);
2245
+ } catch {
2246
+ return null;
2247
+ }
2248
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
2249
+ let path4 = url.pathname.replace(/\/+$/, "");
2250
+ while (path4.endsWith("/v1")) path4 = path4.slice(0, -"/v1".length);
2251
+ return `${url.origin}${path4}/v1`;
2252
+ }
2253
+ function parseConfig(raw, env = process.env) {
2254
+ if (typeof raw !== "object" || raw === null) return { enabled: false, config: null, problems: [] };
2255
+ const block = raw;
2256
+ if (block.enabled !== true) return { enabled: false, config: null, problems: [] };
2257
+ const problems = [];
2258
+ let baseURL = DEFAULT_BASE_URL;
2259
+ if (block.baseURL !== void 0) {
2260
+ const normalized = normalizeBaseURL(block.baseURL);
2261
+ if (normalized === null) problems.push("omniroute.baseURL is not a valid http(s) URL");
2262
+ else baseURL = normalized;
2263
+ }
2264
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
2265
+ if (block.timeoutMs !== void 0) {
2266
+ const value = block.timeoutMs;
2267
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
2268
+ problems.push("omniroute.timeoutMs must be a positive number");
2269
+ } else {
2270
+ timeoutMs = value;
2271
+ }
2272
+ }
2273
+ let stickyPerSession = true;
2274
+ if (block.stickyPerSession !== void 0) {
2275
+ if (typeof block.stickyPerSession !== "boolean") problems.push("omniroute.stickyPerSession must be a boolean");
2276
+ else stickyPerSession = block.stickyPerSession;
2277
+ }
2278
+ let healthCheck = true;
2279
+ if (block.healthCheck !== void 0) {
2280
+ if (typeof block.healthCheck !== "boolean") problems.push("omniroute.healthCheck must be a boolean");
2281
+ else healthCheck = block.healthCheck;
2282
+ }
2283
+ let combo;
2284
+ if (block.combo !== void 0) {
2285
+ if (!isNonEmptyString(block.combo)) problems.push("omniroute.combo must be a non-empty string");
2286
+ else combo = block.combo.trim();
2287
+ }
2288
+ let models;
2289
+ if (block.models !== void 0) {
2290
+ if (!isStringArray(block.models)) problems.push("omniroute.models must be an array of non-empty strings");
2291
+ else models = dedupe(block.models.map((id) => id.trim()));
2292
+ }
2293
+ let tiers = { primary: [], backup: [] };
2294
+ if (block.tiers !== void 0) {
2295
+ const value = block.tiers;
2296
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2297
+ problems.push("omniroute.tiers must be an object with primary/backup string arrays");
2298
+ } else {
2299
+ const read = (list) => {
2300
+ if (list === void 0) return [];
2301
+ if (!isStringArray(list)) return null;
2302
+ return dedupe(list.map((id) => id.trim()));
2303
+ };
2304
+ const group = value;
2305
+ const primary = read(group.primary);
2306
+ const backup = read(group.backup);
2307
+ if (primary === null || backup === null) {
2308
+ problems.push("omniroute.tiers must be an object with primary/backup string arrays");
2309
+ } else {
2310
+ tiers = { primary, backup };
2311
+ }
2312
+ }
2313
+ }
2314
+ if (models !== void 0) {
2315
+ const allowed = new Set(models);
2316
+ tiers = {
2317
+ primary: tiers.primary.filter((id) => allowed.has(id)),
2318
+ backup: tiers.backup.filter((id) => allowed.has(id))
2319
+ };
2320
+ }
2321
+ let apiKey;
2322
+ if (block.apiKey !== void 0 && typeof block.apiKey !== "string") {
2323
+ problems.push("omniroute.apiKey must be a string when set");
2324
+ }
2325
+ const fromEnv = env[ENV_API_KEY];
2326
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") apiKey = fromEnv.trim();
2327
+ else if (typeof block.apiKey === "string" && block.apiKey.trim() !== "") apiKey = block.apiKey.trim();
2328
+ if (problems.length > 0) return { enabled: true, config: null, problems };
2329
+ const config = {
2330
+ baseURL,
2331
+ tiers,
2332
+ stickyPerSession,
2333
+ healthCheck,
2334
+ timeoutMs
2335
+ };
2336
+ if (apiKey !== void 0) config.apiKey = apiKey;
2337
+ if (combo !== void 0) config.combo = combo;
2338
+ if (models !== void 0) config.models = models;
2339
+ return { enabled: true, config, problems: [] };
2340
+ }
2341
+ function filterModels(ids, config) {
2342
+ const out = [];
2343
+ const push = (id) => {
2344
+ if (id.trim() === "") return;
2345
+ if (!out.includes(id)) out.push(id);
2346
+ };
2347
+ if (config.combo !== void 0) push(config.combo);
2348
+ if (config.models !== void 0) {
2349
+ for (const id of config.models) if (ids.includes(id)) push(id);
2350
+ } else {
2351
+ for (const id of ids) push(id);
2352
+ }
2353
+ return out;
2354
+ }
2355
+ function orderByTier(ids, tiers) {
2356
+ const present = new Set(ids);
2357
+ const out = [];
2358
+ const push = (id) => {
2359
+ if (!out.includes(id)) out.push(id);
2360
+ };
2361
+ for (const id of tiers.primary) if (present.has(id)) push(id);
2362
+ for (const id of tiers.backup) if (present.has(id)) push(id);
2363
+ for (const id of ids) push(id);
2364
+ return out;
2365
+ }
2366
+ function isTransientStatus(status) {
2367
+ if (status === 429 || status === 408) return true;
2368
+ return status >= 500 && status <= 599;
2369
+ }
2370
+ function backoffMs(strikes, capMs = COOLDOWN_MAX_MS2) {
2371
+ const count = Number.isFinite(strikes) ? strikes : 1;
2372
+ return Math.min(COOLDOWN_BASE_MS2 * 2 ** (count - 1), capMs);
2373
+ }
2374
+ function cooldownUntil(opts) {
2375
+ const cap = opts.capMs ?? COOLDOWN_MAX_MS2;
2376
+ const now = opts.now ?? Date.now();
2377
+ const retryAfterMs2 = opts.retryAfterMs;
2378
+ if (typeof retryAfterMs2 === "number" && Number.isFinite(retryAfterMs2) && retryAfterMs2 > 0) {
2379
+ return now + Math.min(retryAfterMs2, cap);
2380
+ }
2381
+ const strikes = opts.strikes ?? 1;
2382
+ return now + backoffMs(Math.max(1, strikes), cap);
2383
+ }
2384
+ var rateOf = (stat) => {
2385
+ const attempts = stat.requests + stat.errors;
2386
+ return attempts > 0 ? stat.errors / attempts : 0;
2387
+ };
2388
+ var ttftOf = (stat) => stat.ttftEwma ?? Number.POSITIVE_INFINITY;
2389
+ function createSelector(config, deps = {}) {
2390
+ const now = deps.now ?? Date.now;
2391
+ const stats = /* @__PURE__ */ new Map();
2392
+ const sticky = /* @__PURE__ */ new Map();
2393
+ let cands = [];
2394
+ const ensure = (modelID) => {
2395
+ let stat = stats.get(modelID);
2396
+ if (stat === void 0) {
2397
+ stat = { requests: 0, errors: 0, strikes: 0, cooldownUntil: 0 };
2398
+ stats.set(modelID, stat);
2399
+ }
2400
+ return stat;
2401
+ };
2402
+ const cooling = (modelID) => {
2403
+ const stat = stats.get(modelID);
2404
+ return stat !== void 0 && stat.cooldownUntil > now();
2405
+ };
2406
+ const better = (challenger, challengerIndex, best, bestIndex) => {
2407
+ const challengerRate = rateOf(challenger);
2408
+ const bestRate = rateOf(best);
2409
+ if (challengerRate !== bestRate) return challengerRate < bestRate;
2410
+ const challengerTtft = ttftOf(challenger);
2411
+ const bestTtft = ttftOf(best);
2412
+ if (challengerTtft !== bestTtft) return challengerTtft < bestTtft;
2413
+ return challengerIndex < bestIndex;
2414
+ };
2415
+ return {
2416
+ setCandidates(ids) {
2417
+ const next = dedupe(ids.filter((id) => typeof id === "string" && id.trim() !== ""));
2418
+ cands = next;
2419
+ for (const id of next) ensure(id);
2420
+ },
2421
+ candidates() {
2422
+ return [...cands];
2423
+ },
2424
+ select(sessionID) {
2425
+ const list = cands;
2426
+ if (list.length === 0) return null;
2427
+ if (config.stickyPerSession) {
2428
+ const held = sticky.get(sessionID);
2429
+ if (held !== void 0 && list.includes(held) && !cooling(held)) return held;
2430
+ }
2431
+ let bestID = null;
2432
+ let bestStat = null;
2433
+ let bestIndex = -1;
2434
+ for (const [index, id] of list.entries()) {
2435
+ const stat = ensure(id);
2436
+ if (stat.cooldownUntil > now()) continue;
2437
+ if (bestStat === null || bestID === null || better(stat, index, bestStat, bestIndex)) {
2438
+ bestID = id;
2439
+ bestStat = stat;
2440
+ bestIndex = index;
2441
+ }
2442
+ }
2443
+ if (bestID === null) return null;
2444
+ if (config.stickyPerSession) sticky.set(sessionID, bestID);
2445
+ return bestID;
2446
+ },
2447
+ stickyFor(sessionID) {
2448
+ return sticky.get(sessionID) ?? null;
2449
+ },
2450
+ recordSuccess(sessionID, modelID, ttftMs) {
2451
+ void sessionID;
2452
+ const stat = ensure(modelID);
2453
+ stat.requests += 1;
2454
+ if (typeof ttftMs === "number" && Number.isFinite(ttftMs) && ttftMs >= 0) {
2455
+ stat.ttftEwma = stat.ttftEwma === void 0 ? ttftMs : stat.ttftEwma * 0.6 + ttftMs * 0.4;
2456
+ }
2457
+ },
2458
+ recordFailure(sessionID, modelID, opts = {}) {
2459
+ const stat = ensure(modelID);
2460
+ stat.errors += 1;
2461
+ if (opts.message !== void 0) stat.lastFailure = opts.message;
2462
+ const transient = opts.transient ?? (opts.status === void 0 ? true : isTransientStatus(opts.status));
2463
+ if (!transient) return;
2464
+ stat.strikes += 1;
2465
+ const until = cooldownUntil({ retryAfterMs: opts.retryAfterMs, strikes: stat.strikes, now: now() });
2466
+ if (until > stat.cooldownUntil) stat.cooldownUntil = until;
2467
+ if (sticky.get(sessionID) === modelID) sticky.delete(sessionID);
2468
+ },
2469
+ isCooling(modelID) {
2470
+ return cooling(modelID);
2471
+ },
2472
+ stats() {
2473
+ const out = {};
2474
+ for (const [id, stat] of stats) out[id] = { ...stat };
2475
+ return out;
2476
+ }
2477
+ };
2478
+ }
2479
+ var statusReason = (status) => {
2480
+ const reasons = {
2481
+ 400: "bad request",
2482
+ 401: "unauthorized",
2483
+ 403: "forbidden",
2484
+ 404: "not found",
2485
+ 408: "request timeout",
2486
+ 429: "rate limited",
2487
+ 500: "server error",
2488
+ 502: "bad gateway",
2489
+ 503: "unavailable",
2490
+ 504: "gateway timeout"
2491
+ };
2492
+ if (Number.isFinite(status) && reasons[status] !== void 0) return reasons[status];
2493
+ return status >= 500 ? "server error" : "http error";
2494
+ };
2495
+ var entryID = (entry) => {
2496
+ if (typeof entry === "string") return entry.trim() === "" ? null : entry.trim();
2497
+ if (entry !== null && typeof entry === "object") {
2498
+ const id = entry.id;
2499
+ if (typeof id === "string") return id.trim() === "" ? null : id.trim();
2500
+ }
2501
+ return null;
2502
+ };
2503
+ var listEntries = (body) => {
2504
+ if (Array.isArray(body)) return body;
2505
+ if (body === null || typeof body !== "object") return null;
2506
+ const obj = body;
2507
+ if (Array.isArray(obj.data)) return obj.data;
2508
+ if (Array.isArray(obj.models)) return obj.models;
2509
+ return null;
2510
+ };
2511
+ async function fetchModels(opts) {
2512
+ const fetchImpl = opts.fetchImpl ?? fetch;
2513
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2514
+ const url = `${opts.baseURL}${MODELS_PATH}`;
2515
+ const controller = new AbortController();
2516
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2517
+ try {
2518
+ const headers = {};
2519
+ if (opts.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
2520
+ const response = await fetchImpl(url, { headers, signal: controller.signal });
2521
+ const status = typeof response?.status === "number" ? response.status : Number.NaN;
2522
+ const ok = typeof response?.ok === "boolean" ? response.ok : Number.isFinite(status) ? status >= 200 && status < 300 : true;
2523
+ if (!ok) throw new Error(`omniroute GET ${url} failed: HTTP ${status} ${statusReason(status)}`);
2524
+ let body;
2525
+ try {
2526
+ body = await response.json();
2527
+ } catch {
2528
+ throw new Error(`omniroute GET ${url} returned a non-JSON body`);
2529
+ }
2530
+ const entries = listEntries(body);
2531
+ if (entries === null) throw new Error(`omniroute GET ${url} returned an unsupported body shape`);
2532
+ const out = [];
2533
+ for (const entry of entries) {
2534
+ const id = entryID(entry);
2535
+ if (id !== null && !out.includes(id)) out.push(id);
2536
+ }
2537
+ return out;
2538
+ } catch (error) {
2539
+ if (controller.signal.aborted) throw new Error(`omniroute GET ${url} timed out after ${timeoutMs}ms`);
2540
+ throw error;
2541
+ } finally {
2542
+ clearTimeout(timer);
2543
+ }
2544
+ }
2545
+ async function checkHealth(opts) {
2546
+ try {
2547
+ await fetchModels(opts);
2548
+ return true;
2549
+ } catch {
2550
+ return false;
2551
+ }
2552
+ }
2553
+ function createModelCache(opts) {
2554
+ const ttlMs = opts.ttlMs ?? DEFAULT_CACHE_TTL_MS;
2555
+ const now = opts.now ?? Date.now;
2556
+ let cached = null;
2557
+ let cachedAt = 0;
2558
+ return {
2559
+ async get(force) {
2560
+ if (!force && cached !== null && now() - cachedAt < ttlMs) return [...cached];
2561
+ try {
2562
+ const ids = await fetchModels(opts);
2563
+ cached = ids;
2564
+ cachedAt = now();
2565
+ return [...ids];
2566
+ } catch (error) {
2567
+ if (cached !== null) return [...cached];
2568
+ throw error;
2569
+ }
2570
+ },
2571
+ invalidate() {
2572
+ cached = null;
2573
+ cachedAt = 0;
2574
+ }
2575
+ };
2576
+ }
2577
+ function redactApiKey(text, apiKey) {
2578
+ if (typeof apiKey !== "string" || apiKey === "") return text;
2579
+ return text.split(apiKey).join("[redacted]");
2580
+ }
2581
+
2582
+ // src/plugins/omniroute.ts
2583
+ var SERVICE4 = "omniroute";
2584
+ var omniRouteHooks = async (input, options, deps = {}) => {
2585
+ const opts = options ?? {};
2586
+ const parsed = parseConfig(opts.omniroute, deps.env ?? process.env);
2587
+ const log = (level, message) => input.client.app.log({ body: { service: SERVICE4, level, message } }).catch(() => {
2588
+ });
2589
+ const warned = /* @__PURE__ */ new Set();
2590
+ const warnOnce = async (message) => {
2591
+ if (warned.has(message)) return;
2592
+ warned.add(message);
2593
+ await log("warn", message);
2594
+ };
2595
+ if (!parsed.enabled) return {};
2596
+ if (parsed.config === null) {
2597
+ await warnOnce(`omniroute is enabled but unusable: ${parsed.problems.join("; ")}`);
2598
+ return {};
2599
+ }
2600
+ const config = parsed.config;
2601
+ const cache = createModelCache({
2602
+ baseURL: config.baseURL,
2603
+ apiKey: config.apiKey,
2604
+ timeoutMs: config.timeoutMs,
2605
+ fetchImpl: deps.fetchImpl,
2606
+ now: deps.now
2607
+ });
2608
+ const selector = createSelector(config, { now: deps.now });
2609
+ let disabled = false;
2610
+ const probe = async () => {
2611
+ if (config.healthCheck) {
2612
+ const healthy = await checkHealth({
2613
+ baseURL: config.baseURL,
2614
+ apiKey: config.apiKey,
2615
+ timeoutMs: config.timeoutMs,
2616
+ fetchImpl: deps.fetchImpl
2617
+ });
2618
+ if (!healthy) {
2619
+ disabled = true;
2620
+ await warnOnce(`omniroute health check failed at ${config.baseURL}; falling back to lacode's normal behaviour for this run`);
2621
+ return;
2622
+ }
2623
+ }
2624
+ let ids;
2625
+ try {
2626
+ ids = await cache.get();
2627
+ } catch (error) {
2628
+ disabled = true;
2629
+ await warnOnce(
2630
+ redactApiKey(
2631
+ `omniroute is unreachable at ${config.baseURL}: ${error instanceof Error ? error.message : String(error)}; falling back for this run`,
2632
+ config.apiKey
2633
+ )
2634
+ );
2635
+ return;
2636
+ }
2637
+ const candidates = orderByTier(filterModels(ids, config), config.tiers);
2638
+ if (candidates.length === 0) {
2639
+ disabled = true;
2640
+ await warnOnce(`omniroute returned no usable models at ${config.baseURL}; falling back for this run`);
2641
+ return;
2642
+ }
2643
+ selector.setCandidates(candidates);
2644
+ await log("info", `omniroute: ${candidates.length} model(s) from ${config.baseURL}: ${candidates.join(", ")}`);
2645
+ };
2646
+ let started = null;
2647
+ const initialize = () => started ??= probe();
2648
+ const injectProvider = async (cfg, warn) => {
2649
+ const providers = cfg.provider ??= {};
2650
+ if (providers.omniroute !== void 0) {
2651
+ await warn("omniroute provider is already defined in your config; lacode left it untouched");
2652
+ return;
2653
+ }
2654
+ const exposed = selector.candidates();
2655
+ const models = {};
2656
+ for (const id of exposed) models[id] = { name: id };
2657
+ providers.omniroute = {
2658
+ name: "OmniRoute",
2659
+ // The documented opencode registration mechanism for a gateway speaking
2660
+ // the OpenAI `/v1` API: opencode loads this package itself from the user's
2661
+ // own install, so lacode needs no dependency to expose OmniRoute. No
2662
+ // `capabilities` are declared on purpose -- opencode ignores custom
2663
+ // capabilities for openai-compatible providers, so writing one would be
2664
+ // decoration that reads like a promise.
2665
+ npm: "@ai-sdk/openai-compatible",
2666
+ options: {
2667
+ baseURL: config.baseURL,
2668
+ // Absent rather than blank: an empty bearer on a keyless gateway is a
2669
+ // 401 that looks like a configuration error.
2670
+ ...config.apiKey ? { apiKey: config.apiKey } : {},
2671
+ timeout: config.timeoutMs
2672
+ },
2673
+ models
2674
+ };
2675
+ };
2676
+ return {
2677
+ config: async (cfg) => {
2678
+ try {
2679
+ await initialize();
2680
+ if (disabled) return;
2681
+ await injectProvider(cfg, warnOnce);
2682
+ } catch (error) {
2683
+ await warnOnce(
2684
+ redactApiKey(
2685
+ `omniroute config injection failed: ${error instanceof Error ? error.message : String(error)}`,
2686
+ config.apiKey
2687
+ )
2688
+ );
2689
+ }
2690
+ }
2691
+ };
2692
+ };
2693
+
2154
2694
  // src/lib/selfupdate.ts
2155
2695
  import { spawn as spawn2 } from "node:child_process";
2156
2696
  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 +2983,7 @@ var LaCode = (async (input, options) => {
2443
2983
  const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
2444
2984
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
2445
2985
  const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
2986
+ const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
2446
2987
  const config = async (cfg) => {
2447
2988
  try {
2448
2989
  const injected = applyAgents(cfg, agentSet);
@@ -2456,6 +2997,7 @@ var LaCode = (async (input, options) => {
2456
2997
  if (applyInstructions(cfg).added) {
2457
2998
  await log("info", "instructions: added the standing context-map rule (no user instructions were present)");
2458
2999
  }
3000
+ await omniroute.config?.(cfg);
2459
3001
  await pool.config?.(cfg);
2460
3002
  } catch (error) {
2461
3003
  await log("error", `config hook failed: ${String(error)}`);
@@ -2484,6 +3026,7 @@ var LaCode = (async (input, options) => {
2484
3026
  await autoupdate.dispose?.();
2485
3027
  await notifier.dispose?.();
2486
3028
  await selfupdate.dispose?.();
3029
+ await omniroute.dispose?.();
2487
3030
  }
2488
3031
  };
2489
3032
  });
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,
@@ -294,6 +298,14 @@ function publish(s) {
294
298
  s.dirReady = false;
295
299
  }
296
300
  }
301
+ var isPidAlive = (pid) => {
302
+ try {
303
+ process.kill(pid, 0);
304
+ return true;
305
+ } catch (error) {
306
+ return error.code === "EPERM";
307
+ }
308
+ };
297
309
  function readSiblings(s) {
298
310
  const siblings = [];
299
311
  let entries;
@@ -327,6 +339,13 @@ function readSiblings(s) {
327
339
  }
328
340
  continue;
329
341
  }
342
+ if (/^\d+$/.test(match[1]) && !isPidAlive(Number(match[1]))) {
343
+ try {
344
+ unlinkSync(path);
345
+ } catch {
346
+ }
347
+ continue;
348
+ }
330
349
  try {
331
350
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
332
351
  const claims = Object.values(parsed).filter(
@@ -340,10 +359,13 @@ function readSiblings(s) {
340
359
  }
341
360
  function readLimits(s) {
342
361
  const cooling = /* @__PURE__ */ new Map();
362
+ const memory = /* @__PURE__ */ new Map();
343
363
  let entries;
344
364
  try {
345
365
  entries = readdirSync(s.dir);
346
366
  } catch {
367
+ s.cooling = cooling;
368
+ s.strikes = memory;
347
369
  return cooling;
348
370
  }
349
371
  const now = Date.now();
@@ -353,8 +375,14 @@ function readLimits(s) {
353
375
  try {
354
376
  const strike = JSON.parse(readFileSync2(path, "utf8"));
355
377
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
356
- if (strike.until > now) cooling.set(strike.model, strike);
357
- else unlinkSync(path);
378
+ if (strike.until > now) {
379
+ cooling.set(strike.model, strike);
380
+ memory.set(strike.model, strike);
381
+ } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
382
+ memory.set(strike.model, strike);
383
+ } else {
384
+ unlinkSync(path);
385
+ }
358
386
  } catch {
359
387
  try {
360
388
  unlinkSync(path);
@@ -363,16 +391,21 @@ function readLimits(s) {
363
391
  }
364
392
  }
365
393
  s.cooling = cooling;
394
+ s.strikes = memory;
366
395
  return cooling;
367
396
  }
368
397
  function recordStrike(s, model, reason, stated) {
369
398
  if (!s.dirReady) return;
370
399
  const now = Date.now();
371
- const prior = s.cooling.get(model);
400
+ const prior = s.strikes.get(model);
372
401
  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));
402
+ const quota = isQuotaReason(reason);
403
+ const base = quota ? QUOTA_COOLDOWN_BASE_MS : COOLDOWN_BASE_MS;
404
+ const cap = quota ? QUOTA_COOLDOWN_MAX_MS : COOLDOWN_MAX_MS;
405
+ const backoff = Math.min(cap, base * 2 ** (strikes - 1));
374
406
  const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
375
407
  s.cooling.set(model, strike);
408
+ s.strikes.set(model, strike);
376
409
  const path = join2(s.dir, limitFile(model));
377
410
  try {
378
411
  writeFileSync(`${path}.tmp`, JSON.stringify(strike));
@@ -389,6 +422,7 @@ function coolUnavailable(s, model) {
389
422
  if (prior && prior.until > until) return prior;
390
423
  const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
391
424
  s.cooling.set(model, strike);
425
+ s.strikes.set(model, strike);
392
426
  const path = join2(s.dir, limitFile(model));
393
427
  try {
394
428
  writeFileSync(`${path}.tmp`, JSON.stringify(strike));
@@ -511,6 +545,8 @@ export {
511
545
  ORPHAN_TMP_MS,
512
546
  PACKAGE_ROOT,
513
547
  PRESETS_DIR,
548
+ QUOTA_COOLDOWN_BASE_MS,
549
+ QUOTA_COOLDOWN_MAX_MS,
514
550
  STUCK_IDLE_MS,
515
551
  STUCK_MIN_AGE_MS,
516
552
  UNAVAILABLE_COOLDOWN_MS,
@@ -530,6 +566,7 @@ export {
530
566
  init,
531
567
  isAvailable,
532
568
  isLimitText,
569
+ isQuotaReason,
533
570
  isUnavailableText,
534
571
  noteActivity,
535
572
  parseSlotText,
@@ -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) is registered as an ordinary provider
144
+ **As a** user running a local OpenAI-compatible gateway, **I want** LaCode to register it so its models are selectable and I can name one in my pool, **so that** *I* decide whether pooled work drains through it instead of the bundled models.
145
+
146
+ **Story ID(s) for tests:** US-28
147
+ **Acceptance criteria:**
148
+ - [ ] Given `omniroute.enabled` with a reachable gateway, When the config hook runs, Then LaCode registers a `provider.omniroute` entry exposing the gateway's models (everything it lists, or filtered by `models`/`combo`), and writes NOTHING into the pool.
149
+ - [ ] Given any pool source (bundled preset, `pool.json`, `pool.txt`, or `options.models`), When OmniRoute is enabled and healthy, Then every slot resolves exactly as it would without the integration — the slot list is unchanged, so opting in means the user lists `omniroute/<model>` themselves.
150
+ - [ ] Given a pool slot naming an `omniroute/<model>`, When the pool resolves and binds, Then that slot routes as an ordinary slot, and the pool does not warn the provider is missing, because OmniRoute registers the provider before the pool's config hook runs.
151
+ - [ ] Given OmniRoute is disabled, enabled with an invalid block, unhealthy, or returns no usable models, When the config hook runs, Then no provider is registered, no pool slot changes, and a failure is warned about once.
152
+ - [ ] Given the gateway is unreachable, When pooled work runs, Then the only thing affected is a slot that names `omniroute/<model>`; every other slot keeps working, because the plugin never put an `omniroute` slot in the pool.
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.1",
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",