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