@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 +107 -0
- package/dist/agents.js +37 -2
- package/dist/index.js +551 -8
- package/dist/pool.js +41 -4
- package/docs/USER-STORIES.md +12 -0
- package/package.json +1 -1
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\`
|
|
74
|
-
spawn the
|
|
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)
|
|
357
|
-
|
|
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.
|
|
400
|
+
const prior = s.strikes.get(model);
|
|
372
401
|
const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
|
|
373
|
-
const
|
|
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\`
|
|
528
|
-
spawn the
|
|
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
|
|
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)
|
|
357
|
-
|
|
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.
|
|
400
|
+
const prior = s.strikes.get(model);
|
|
372
401
|
const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
|
|
373
|
-
const
|
|
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,
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -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