@lawaty/lacode 2.1.0-beta.0 → 2.3.0-beta.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/CHANGELOG.md +42 -0
- package/README.md +14 -0
- package/dist/agents.js +2 -0
- package/dist/commands.js +2 -0
- package/dist/index.js +2515 -2110
- package/dist/lacode.js +2 -0
- package/dist/pool.js +194 -18
- package/dist/tui.js +153 -25
- package/dist/tunnel.js +2 -0
- package/docs/CONFIGURATION.md +15 -1
- package/docs/USER-STORIES.md +56 -8
- package/package.json +2 -1
- package/presets/README.md +6 -6
- package/scripts/pool-selection.mjs +281 -0
- package/scripts/refresh-pool.mjs +149 -69
- package/scripts/score-models.mjs +227 -68
package/docs/USER-STORIES.md
CHANGED
|
@@ -30,6 +30,7 @@ document; everything here is testable offline with a stub client and a
|
|
|
30
30
|
| **E — Remote hosts** | Remote SSH notification. Work runs on remote hosts, so notifications raised there die on that host's nonexistent desktop instead of reaching the local one. |
|
|
31
31
|
| **F — Adoption and safety** | Installability. A personal config cannot be adopted safely: it either clobbers what you already have, or needs manual surgery before it runs. |
|
|
32
32
|
| **G — Context and cost** | Cold exploration. Every task starts by re-discovering the structure of the codebase from scratch, so the same layout is re-read — and re-paid for — on every single run. |
|
|
33
|
+
| **K — Autonomy modes** | Stalls. A long run stops for every routine permission, or waits for a human to answer a question the agent could decide itself, even when its own recommendation is clear. |
|
|
33
34
|
|
|
34
35
|
---
|
|
35
36
|
|
|
@@ -88,8 +89,9 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
88
89
|
**Story ID(s) for tests:** US-4
|
|
89
90
|
**Acceptance criteria:**
|
|
90
91
|
- [ ] Given a spawn fails with a rate-limit-shaped error, When the error is serialized and matched, Then the offending model is marked cooling and a cooldown file is written for it.
|
|
91
|
-
- [ ] Given model A is cooling, When the next spawn is routed, Then the pick is a different model
|
|
92
|
-
- [ ] Given
|
|
92
|
+
- [ ] Given model A is cooling and another slot is usable, When the next spawn is routed, Then the pick is a different usable model, never the cooling one.
|
|
93
|
+
- [ ] Given a pooled task completes with an empty result, When the task hook returns, Then that model is benched for `empty response`, the result explains the bench, and the next spawn routes to another slot.
|
|
94
|
+
- [ ] Given every model is cooling, When a spawn is routed, Then the spawn is refused explicitly with a message naming the cooling slots and their reasons, and no model is routed to.
|
|
93
95
|
- [ ] Given a limit error names a reset time, When the cooldown is recorded, Then the stated reset is used instead of a guessed backoff.
|
|
94
96
|
|
|
95
97
|
### US-5: Slots fill in declaration order in one pass, with repeats
|
|
@@ -101,7 +103,7 @@ The reconciliation with US-7 is that **acceptance and endorsement are different
|
|
|
101
103
|
- [ ] Given a model appears on more than one line, When the pool loads, Then each line is its own slot with its own weight, and the later line adds seats at its own position.
|
|
102
104
|
- [ ] Given a slot is cooling or unavailable, When its position comes up, Then it is skipped and the pass continues to the next slot.
|
|
103
105
|
- [ ] Given a claim is released, When the next spawn is routed, Then the slot that freed capacity is preferred again in declaration order; no stored position keeps it waiting.
|
|
104
|
-
- [ ] Given every usable slot is at its weight, When another spawn is routed, Then it goes to the least-loaded usable slot as overflow, and the pool never refuses work.
|
|
106
|
+
- [ ] Given every usable slot is at its weight, When another spawn is routed, Then it goes to the least-loaded usable slot as overflow, and the pool never refuses work while at least one slot is usable.
|
|
105
107
|
- [ ] Given all slots carry equal weight, When spawns are routed, Then the same ordered one-pass fill applies.
|
|
106
108
|
- [ ] Given per-slot load is read, When it is asserted, Then a slot's load never exceeds its weight except through overflow.
|
|
107
109
|
|
|
@@ -167,6 +169,20 @@ A limit does not always arrive as an error. Sometimes the provider accepts the r
|
|
|
167
169
|
- [ ] Given a provider reports a transient upstream outage rather than a limit, When the status is handled, Then the model is cooled for one short flat window instead of the quota window, and the session is still aborted, because an outage lifts on its own and must not bench a healthy model.
|
|
168
170
|
- [ ] Given a transient outage was cooled, When the same model later fails with a real limit, Then the cooldown starts at the first strike rather than an escalated one, because an outage lifts on its own and must not make the next quota window longer.
|
|
169
171
|
|
|
172
|
+
### US-39: The pool refresh completes, retries what failed, and never starves
|
|
173
|
+
**As a** user relying on the scored pool, **I want** the refresh to finish even when the machine reboots, **so that** the pool is never stranded on stale scores and a transient provider failure does not erase a model I was using.
|
|
174
|
+
|
|
175
|
+
A daily scan was killed by a reboot mid-run, and because systemd records a trigger when the service starts, `Persistent=true` did not retry it. The refresh now runs every 6 hours, gets a startup trigger, scans the core providers first under a wall-clock budget, keeps previous rows for providers it could not finish, and preserves the last measured score of a model that failed transiently. A PID lock keeps the 6-hour run and a retry timer from racing.
|
|
176
|
+
|
|
177
|
+
**Story ID(s) for tests:** US-39
|
|
178
|
+
**Acceptance criteria:**
|
|
179
|
+
- [ ] Given the installed timer, When it is rendered, Then it has `OnCalendar=*-*-* 00/6:00:00`, `OnStartupSec=5min`, and `Persistent=true`, and the service has `TimeoutStartSec=infinity`.
|
|
180
|
+
- [ ] Given a scan reaches its wall-clock budget, When the scores are written, Then unscanned and truncated providers keep their previous rows, so a partial run cannot shrink the pool.
|
|
181
|
+
- [ ] Given a model failed transiently (timeout, rate limit, credit), When selection runs, Then its last measured score is retained and a retry is scheduled at the stated reset or 45 minutes later.
|
|
182
|
+
- [ ] Given a permanent failure (401, 403 agreement, 404), When selection runs, Then the model is not retried and does not fall back to a previous score.
|
|
183
|
+
- [ ] Given enough other providers to fill the pool, When selection runs, Then exactly one `opencode` slot is reserved last with weight 2.
|
|
184
|
+
- [ ] Given two refreshes overlap, When the second starts, Then it skips while the lock is held, and a lock left by a dead PID is reclaimed.
|
|
185
|
+
|
|
170
186
|
---
|
|
171
187
|
|
|
172
188
|
## Epic C — Hang detection and auto-recovery
|
|
@@ -531,6 +547,37 @@ A failed summarization used to clear the pending flag and never reschedule, so o
|
|
|
531
547
|
- [ ] Given the sweep runs, When it completes, Then the `event_sequence` and `session` tables are never changed.
|
|
532
548
|
- [ ] Given a run, When it completes, Then one JSON line is appended to `~/.local/share/opencode/lacode/event-prune.jsonl` carrying ts, days, graceHours, cutoff, rows, bytes, aggregates, dryRun and elapsedMs.
|
|
533
549
|
|
|
550
|
+
## Epic K — Autonomy modes (YOLO and YKB)
|
|
551
|
+
|
|
552
|
+
### US-37: YOLO mode approves permissions without stopping
|
|
553
|
+
**As a** user running a long autonomous task, **I want** permission prompts to be approved automatically while YOLO is on, **so that** the agent keeps working instead of waiting for routine approvals, while an explicit `deny` still protects me.
|
|
554
|
+
|
|
555
|
+
Permission prompts are the main thing that breaks a long autonomous run: every `ask` blocks the session until a human answers. YOLO flips that decision to `allow` at the permission hook, keeps an explicit `deny` authoritative, and answers any request that still reaches the event bus. The state is a small file the TUI writes and the server reads on the next decision, so enabling it also clears what is already pending.
|
|
556
|
+
|
|
557
|
+
**Story ID(s) for tests:** US-37
|
|
558
|
+
**Acceptance criteria:**
|
|
559
|
+
- [ ] Given YOLO is on, When the runtime's permission decision is `ask`, Then it becomes `allow` without a prompt, while an existing `deny` or `allow` is left unchanged.
|
|
560
|
+
- [ ] Given YOLO is off, When the runtime's permission decision is `ask`, Then it stays `ask` and still reaches the user.
|
|
561
|
+
- [ ] Given a `permission.asked` event still reaches the event bus while YOLO is on and carries a request id and a session id, When the handler runs, Then the plugin replies `once` for that request; an event missing either id is ignored.
|
|
562
|
+
- [ ] Given YOLO is toggled in the TUI, When the mode file changes, Then the next permission decision sees the new value without a restart, and requests already pending are answered.
|
|
563
|
+
- [ ] Given `yolo: true` in the plugin options and no live mode file, When the plugin starts, Then the file is seeded; after a TUI toggle, the live file wins.
|
|
564
|
+
- [ ] Given the README and configuration reference, Then the `yolo` option and the `/yolo` command are documented alongside `/modes`.
|
|
565
|
+
|
|
566
|
+
### US-38: YKB mode chooses the recommendation instead of asking
|
|
567
|
+
**As a** user who wants the agent to keep moving, **I want** YKB (You Know Better) mode to suppress questions and pick the recommended option itself, **so that** it finishes or blocks on a real dependency rather than waiting for me to choose.
|
|
568
|
+
|
|
569
|
+
A question costs a full turn and a human decision, even when the agent has a clear recommendation. YKB adds a system instruction that tells the agent to choose and continue, and auto-answers any question that still reaches the runtime with its `(Recommended)` option (or the first option when none is marked). A free-text question with no options is left for the user, because there is nothing to choose.
|
|
570
|
+
|
|
571
|
+
**Story ID(s) for tests:** US-38
|
|
572
|
+
**Acceptance criteria:**
|
|
573
|
+
- [ ] Given YKB is on, When a request is built, Then the system prompt carries the YKB instruction exactly once; when YKB is off, the instruction is absent.
|
|
574
|
+
- [ ] Given a question offers options and YKB is on, When the plugin answers it, Then each question selects the option marked `(Recommended)` (case-insensitive, in the label or description), or the first option when none is marked.
|
|
575
|
+
- [ ] Given a question has no options, When YKB sees it, Then it leaves the question for the user and records a warning rather than inventing an answer.
|
|
576
|
+
- [ ] Given YKB is off, When a question is asked, Then the plugin sends no answer.
|
|
577
|
+
- [ ] Given YKB is toggled on in the TUI, When questions are already pending, Then they are answered with the same recommendation rule.
|
|
578
|
+
- [ ] Given YOLO and YKB are both on, When permission and question events arrive, Then each mode acts only on its own event and the other mode's behavior is unchanged.
|
|
579
|
+
- [ ] Given the README and configuration reference, Then the `ykb` option and the `/ykb` and `/modes` commands are documented.
|
|
580
|
+
|
|
534
581
|
---
|
|
535
582
|
|
|
536
583
|
## Coverage
|
|
@@ -555,6 +602,7 @@ table was filled in.
|
|
|
555
602
|
| US-7 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/pool-models-test.mjs` |
|
|
556
603
|
| US-28 | B | `tests/stories/i-omniroute.test.mjs` |
|
|
557
604
|
| US-31 | B | `tests/stories/b-provider-resilience.test.mjs`, `tests/stories/c-hang-recovery.test.mjs` |
|
|
605
|
+
| US-39 | B | `tests/stories/l-pool-refresh.test.mjs` |
|
|
558
606
|
| US-8 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs` |
|
|
559
607
|
| US-9 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-shared-test.mjs` |
|
|
560
608
|
| US-10 | C | `tests/stories/c-hang-recovery.test.mjs`, `tests/pool-hang-test.mjs`, `tests/pool-timer-test.mjs` |
|
|
@@ -581,13 +629,13 @@ table was filled in.
|
|
|
581
629
|
| US-36 | G | `tests/stories/j-timeline-reliability.test.mjs` |
|
|
582
630
|
| US-32 | H | `tests/retire-old-sessions-test.mjs` |
|
|
583
631
|
| US-33 | H | `tests/prune-old-events-test.mjs` |
|
|
632
|
+
| US-37 | K | `tests/stories/k-autonomy-modes.test.mjs` |
|
|
633
|
+
| US-38 | K | `tests/stories/k-autonomy-modes.test.mjs` |
|
|
584
634
|
|
|
585
|
-
The
|
|
635
|
+
The root-level suites under `tests/` are unit-style; the `tests/stories/` suites are
|
|
586
636
|
the behavioural layer, written one epic per file with every test name carrying
|
|
587
637
|
its story id. Run everything with `npm test`.
|
|
588
638
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
all of epic F have tests, and the coverage guard fails the build if any of them
|
|
592
|
-
loses one. The scan covers the `tests/stories/` suites and the root-level
|
|
639
|
+
39/39 stories covered by 370 test case(s); thinnest story has 1.
|
|
640
|
+
No story is untested. The scan covers the `tests/stories/` suites and the root-level
|
|
593
641
|
`tests/*-test.mjs` suites.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0-beta.0",
|
|
4
4
|
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"README.md",
|
|
52
52
|
"docs",
|
|
53
53
|
"scripts/score-models.mjs",
|
|
54
|
+
"scripts/pool-selection.mjs",
|
|
54
55
|
"scripts/refresh-pool.mjs"
|
|
55
56
|
],
|
|
56
57
|
"scripts": {
|
package/presets/README.md
CHANGED
|
@@ -36,15 +36,15 @@ change here takes effect without a restart. Plugin CODE still needs one.
|
|
|
36
36
|
in place, no JSON and no objects:
|
|
37
37
|
|
|
38
38
|
```text
|
|
39
|
-
# provider/model-id weight
|
|
40
|
-
opencode/big-pickle 3
|
|
41
|
-
opencode-go/beam-free 2
|
|
42
|
-
opencode/deepseek-v4.1-flash-free 1
|
|
43
|
-
opencode/glm-5.3-flash-free 1
|
|
39
|
+
# provider/model-id weight c:<context>
|
|
40
|
+
opencode/big-pickle 3 c:200000
|
|
41
|
+
opencode-go/beam-free 2 c:200000
|
|
42
|
+
opencode/deepseek-v4.1-flash-free 1 c:200000
|
|
43
|
+
opencode/glm-5.3-flash-free 1 c:200000
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
`#` starts a comment (whole-line or trailing), blank lines are ignored, and the
|
|
47
|
-
weight is optional (default 1). The rules are exactly the same as the JSON form
|
|
47
|
+
weight is optional (default 1). `c:<context>` is the model's context window in tokens; the router uses it to prefer the smallest model that fits the task, and it is optional for hand-written lines. The rules are exactly the same as the JSON form
|
|
48
48
|
below: any number of slots, a model may repeat on another line, split on the
|
|
49
49
|
first slash only. The loader accepts either shape in the same file, so a leading
|
|
50
50
|
`{` means JSON and anything else means the flat form.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Pure pool selection: which scored models become slots, and when a limited
|
|
2
|
+
// model is worth retrying. Imported by scripts/score-models.mjs; refresh-pool
|
|
3
|
+
// consumes the resulting `selection` and does not repeat this policy.
|
|
4
|
+
|
|
5
|
+
export const MIN_CONTEXT = 100_000
|
|
6
|
+
export const SMALL_CONTEXT_MAX = 200_000
|
|
7
|
+
export const MIN_SCORE = 60
|
|
8
|
+
export const POOL_SIZE = 10
|
|
9
|
+
export const MAX_ZEN = 1
|
|
10
|
+
export const LARGE_RESERVE = 3
|
|
11
|
+
export const MAX_PER_PROVIDER = 2
|
|
12
|
+
export const MAX_PER_PROVIDER_LARGE = 1
|
|
13
|
+
export const DEFAULT_QUOTA_RETRY_MS = 15 * 60 * 1000
|
|
14
|
+
export const CREDIT_RETRY_MS = 6 * 60 * 60 * 1000
|
|
15
|
+
export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000
|
|
16
|
+
export const STATED_COOLDOWN_MAX_MS = 6 * 60 * 60 * 1000
|
|
17
|
+
// A transient failure with no stated reset gets a bounded retry instead of
|
|
18
|
+
// vanishing from the pool until the next full scan. 45 minutes is long enough
|
|
19
|
+
// for a gateway hiccup or a short provider outage to clear, short enough that a
|
|
20
|
+
// recovered model is back well before the 6-hour timer.
|
|
21
|
+
export const SYNTHETIC_RETRY_MS = 45 * 60 * 1000
|
|
22
|
+
|
|
23
|
+
// Scan order, not selection order: the pool's core providers are probed first so
|
|
24
|
+
// a wall-clock budget can never starve them behind a slow paid catalogue. This is
|
|
25
|
+
// ordering only -- never an exclusion.
|
|
26
|
+
export const providerRank = (provider) => {
|
|
27
|
+
const name = String(provider ?? "")
|
|
28
|
+
if (name === "opencode-go" || name === "opencode") return 0
|
|
29
|
+
if (name === "console-openrouter" || name === "openrouter") return 2
|
|
30
|
+
return 1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One classifier, shared by the scorer (live probe outcome) and the selector
|
|
34
|
+
// (persisted row). The reason text is the fallback for rows written before the
|
|
35
|
+
// classifier existed or by a provider that gives only prose.
|
|
36
|
+
const FAILURE_KINDS = [
|
|
37
|
+
["agreement", /model\s+agreement|terms\s+of\s+(?:service|use)|\btos\b/i],
|
|
38
|
+
["credit", /out\s+of\s+credits|insufficient\s+credits|credit\s+balance|requires\s+more\s+credits|payment\s+required|billing|purchase|\b402\b/i],
|
|
39
|
+
["rate-limited", /rate[\s_-]?limit|usage\s+limit|too\s+many\s+requests|overloaded|limits?[\s_-]?(?:reached|exceeded)|free[-\s]?tier|freelimiterror|quota|\b429\b/i],
|
|
40
|
+
["credential", /\b401\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]?api[\s_-]?key/i],
|
|
41
|
+
["not-served", /\b404\b|no\s+such\s+model|model\s+not\s+found|does\s+not\s+exist|is\s+not\s+available|not\s+served|not\s+found/i],
|
|
42
|
+
["timeout", /timeout|timed\s+out|ETIMEDOUT|no\s+answer|no\s+response/i],
|
|
43
|
+
["network", /ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch\s+failed|socket\s+hang\s+up|unreachable/i],
|
|
44
|
+
["server", /\b5\d\d\b|internal\s+server|bad\s+gateway|service\s+unavailable|gateway\s+timeout/i],
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
// A credit exhaustion is transient over hours, not permanent: the balance can be
|
|
48
|
+
// topped up, and the retry schedule is what carries it. Agreement, credential
|
|
49
|
+
// and not-served failures are permanent for this model until a human acts.
|
|
50
|
+
const TRANSIENT_KINDS = new Set(["rate-limited", "credit", "timeout", "network", "server"])
|
|
51
|
+
|
|
52
|
+
export const isTransientKind = (kind) => TRANSIENT_KINDS.has(String(kind ?? "").toLowerCase())
|
|
53
|
+
|
|
54
|
+
export function classifyFailure({ status, reason, kind } = {}) {
|
|
55
|
+
if (kind) return String(kind).toLowerCase()
|
|
56
|
+
const text = String(reason ?? "")
|
|
57
|
+
const code = Number(status)
|
|
58
|
+
if (code === 402) return "credit"
|
|
59
|
+
if (code === 429) return "rate-limited"
|
|
60
|
+
if (code === 401) return "credential"
|
|
61
|
+
if (code === 403) {
|
|
62
|
+
const named = FAILURE_KINDS.find(([, re]) => re.test(text))?.[0]
|
|
63
|
+
return named === "agreement" || named === "credit" ? named : "credential"
|
|
64
|
+
}
|
|
65
|
+
if (code === 404) return "not-served"
|
|
66
|
+
if (code >= 500 && code <= 599) return "server"
|
|
67
|
+
for (const [name, re] of FAILURE_KINDS) if (re.test(text)) return name
|
|
68
|
+
return "unknown"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const isTransientFailure = (row) => {
|
|
72
|
+
if (row?.ok !== false) return false
|
|
73
|
+
if (row.kind) return isTransientKind(row.kind)
|
|
74
|
+
return isTransientKind(classifyFailure({ status: row.status, reason: row.reason }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isLimitText = (text) =>
|
|
78
|
+
/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|free[-\s]?tier|overloaded|limits?[\s_-]?(?:reached|exceeded)|\b429\b/i.test(
|
|
79
|
+
String(text ?? ""),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const isDailyLimit = (text) => /per[\s-]?day|daily|free-models-per-day|requests?\s+per\s+day/i.test(String(text ?? ""))
|
|
83
|
+
|
|
84
|
+
const isCreditText = /out of credits|insufficient credits|credit balance|requires more credits|payment required|billing|purchase|\b402\b/i
|
|
85
|
+
|
|
86
|
+
const bounded = (ms) => (Number.isFinite(ms) && ms > 0 && ms <= STATED_COOLDOWN_MAX_MS ? ms : undefined)
|
|
87
|
+
|
|
88
|
+
export const statedCooldownMs = (text) => {
|
|
89
|
+
const value = String(text ?? "")
|
|
90
|
+
const hm = /resets?\s+in\s+(\d+)\s*(?:hour|hr|h)s?\s*(\d+)?\s*(?:minute|min|m)?s?/i.exec(value)
|
|
91
|
+
if (hm) return bounded((Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1000)
|
|
92
|
+
const m = /resets?\s+in\s+(\d+)\s*(?:minute|min|m)s?/i.exec(value)
|
|
93
|
+
if (m) return bounded(Number(m[1]) * 60 * 1000)
|
|
94
|
+
const s = /retry[\s-]?in\s+(\d+)\s*(?:second|sec|s)s?/i.exec(value)
|
|
95
|
+
if (s) return bounded(Number(s[1]) * 1000)
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const retryAfterHeaderMs = (value) => {
|
|
100
|
+
if (!value) return undefined
|
|
101
|
+
const text = String(value).trim()
|
|
102
|
+
if (/^\d+$/.test(text)) return bounded(Number(text) * 1000)
|
|
103
|
+
const at = Date.parse(text)
|
|
104
|
+
return Number.isFinite(at) ? bounded(at - Date.now()) : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function retryAfterMs(text, header, now = Date.now()) {
|
|
108
|
+
const headerMs = retryAfterHeaderMs(header)
|
|
109
|
+
if (headerMs) return now + headerMs
|
|
110
|
+
const stated = statedCooldownMs(text)
|
|
111
|
+
if (stated) return now + stated
|
|
112
|
+
const normalized = String(text ?? "")
|
|
113
|
+
if (isDailyLimit(text)) {
|
|
114
|
+
const d = new Date(now)
|
|
115
|
+
const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1, 0, 0, 0, 0)
|
|
116
|
+
return next > now ? next : now + MAX_RETRY_DELAY_MS
|
|
117
|
+
}
|
|
118
|
+
if (isCreditText.test(normalized)) return now + CREDIT_RETRY_MS
|
|
119
|
+
if (isLimitText(text)) return now + DEFAULT_QUOTA_RETRY_MS
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rowsOf = (providers) =>
|
|
124
|
+
Array.isArray(providers)
|
|
125
|
+
? providers.flatMap((entry) => (Array.isArray(entry?.rows) ? entry.rows : []))
|
|
126
|
+
: []
|
|
127
|
+
|
|
128
|
+
const providerOf = (model) => String(model ?? "").split("/")[0]
|
|
129
|
+
|
|
130
|
+
export function selectPool({
|
|
131
|
+
providers,
|
|
132
|
+
previousProviders,
|
|
133
|
+
minContext = MIN_CONTEXT,
|
|
134
|
+
minScore = MIN_SCORE,
|
|
135
|
+
poolSize = POOL_SIZE,
|
|
136
|
+
maxZen = MAX_ZEN,
|
|
137
|
+
now = Date.now,
|
|
138
|
+
} = {}) {
|
|
139
|
+
const rankable = (row) =>
|
|
140
|
+
row?.ok === true &&
|
|
141
|
+
typeof row.score === "number" &&
|
|
142
|
+
Number.isFinite(row.score) &&
|
|
143
|
+
row.score >= minScore &&
|
|
144
|
+
(row.context == null || row.context >= minContext)
|
|
145
|
+
const byScore = (a, b) => b.score - a.score || String(a.model).localeCompare(String(b.model))
|
|
146
|
+
const nowMs = typeof now === "function" ? Number(now()) : Number(now)
|
|
147
|
+
const normalize = (row) => {
|
|
148
|
+
if (row?.ok !== false || !isTransientFailure(row)) return row
|
|
149
|
+
const at = Number(row.retryAfter)
|
|
150
|
+
if (Number.isFinite(at) && at > nowMs) return row
|
|
151
|
+
return { ...row, retryAfter: nowMs + SYNTHETIC_RETRY_MS }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const rows = rowsOf(providers).map(normalize)
|
|
155
|
+
const previousRows = rowsOf(previousProviders)
|
|
156
|
+
const currentByModel = new Map(rows.map((row) => [row.model, row]))
|
|
157
|
+
const scannedProviders = new Set(
|
|
158
|
+
(Array.isArray(providers) ? providers : []).map((entry) => String(entry?.provider ?? "")),
|
|
159
|
+
)
|
|
160
|
+
const erroredProviders = new Set(
|
|
161
|
+
(Array.isArray(providers) ? providers : [])
|
|
162
|
+
.filter((entry) => entry?.error)
|
|
163
|
+
.map((entry) => String(entry.provider ?? "")),
|
|
164
|
+
)
|
|
165
|
+
const truncatedProviders = new Set(
|
|
166
|
+
(Array.isArray(providers) ? providers : [])
|
|
167
|
+
.filter((entry) => entry?.truncated)
|
|
168
|
+
.map((entry) => String(entry.provider ?? "")),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
// A model the previous run scored is kept when this run could not reach it
|
|
172
|
+
// for a transient reason, when its provider was not scanned at all (a
|
|
173
|
+
// targeted scan), or when the scan budget cut that provider off. A model the
|
|
174
|
+
// provider says is gone is not kept.
|
|
175
|
+
const fallback = previousRows.filter((row) => {
|
|
176
|
+
if (!rankable(row)) return false
|
|
177
|
+
const current = currentByModel.get(row.model)
|
|
178
|
+
if (current) return isTransientFailure(current)
|
|
179
|
+
const provider = providerOf(row.model)
|
|
180
|
+
return !scannedProviders.has(provider) || truncatedProviders.has(provider) || erroredProviders.has(provider)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const merge = (pick) => {
|
|
184
|
+
const seen = new Set()
|
|
185
|
+
const out = []
|
|
186
|
+
for (const row of [...rows, ...fallback]) {
|
|
187
|
+
if (!pick(row) || !rankable(row) || seen.has(row.model)) continue
|
|
188
|
+
seen.add(row.model)
|
|
189
|
+
out.push(row)
|
|
190
|
+
}
|
|
191
|
+
return out.sort(byScore)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const go = merge((row) => providerOf(row.model) === "opencode-go")
|
|
195
|
+
const other = merge((row) => providerOf(row.model) !== "opencode-go" && providerOf(row.model) !== "opencode")
|
|
196
|
+
const zen = merge((row) => providerOf(row.model) === "opencode")
|
|
197
|
+
|
|
198
|
+
const small = other
|
|
199
|
+
.filter((row) => row.context != null && row.context < SMALL_CONTEXT_MAX)
|
|
200
|
+
.sort(byScore)
|
|
201
|
+
const large = other
|
|
202
|
+
.filter((row) => row.context == null || row.context >= SMALL_CONTEXT_MAX)
|
|
203
|
+
.sort(byScore)
|
|
204
|
+
|
|
205
|
+
const capPerProvider = (rows, max) => {
|
|
206
|
+
const counts = new Map()
|
|
207
|
+
const out = []
|
|
208
|
+
for (const row of rows) {
|
|
209
|
+
const provider = providerOf(row.model)
|
|
210
|
+
const count = counts.get(provider) ?? 0
|
|
211
|
+
if (count >= max) continue
|
|
212
|
+
counts.set(provider, count + 1)
|
|
213
|
+
out.push(row)
|
|
214
|
+
}
|
|
215
|
+
return out
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const cappedSmall = capPerProvider(small, MAX_PER_PROVIDER)
|
|
219
|
+
const cappedLarge = capPerProvider(large, MAX_PER_PROVIDER_LARGE)
|
|
220
|
+
|
|
221
|
+
// Reserve the last slot for zen when a rankable one exists. Without the
|
|
222
|
+
// reservation a full field of other providers squeezes zen out entirely, which
|
|
223
|
+
// is the opposite of "exactly one zen slot at the end".
|
|
224
|
+
const zenReserve = zen.length > 0 ? Math.max(0, Math.min(maxZen, poolSize - go.length)) : 0
|
|
225
|
+
const remaining = Math.max(0, poolSize - go.length - zenReserve)
|
|
226
|
+
const pickedLarge = cappedLarge.slice(0, Math.min(LARGE_RESERVE, remaining))
|
|
227
|
+
const pickedSmall = cappedSmall.slice(0, Math.max(0, remaining - pickedLarge.length))
|
|
228
|
+
|
|
229
|
+
if (pickedLarge.length + pickedSmall.length < remaining) {
|
|
230
|
+
const need = remaining - pickedLarge.length - pickedSmall.length
|
|
231
|
+
pickedLarge.push(...cappedLarge.slice(pickedLarge.length, pickedLarge.length + need))
|
|
232
|
+
}
|
|
233
|
+
if (pickedLarge.length + pickedSmall.length < remaining) {
|
|
234
|
+
const need = remaining - pickedLarge.length - pickedSmall.length
|
|
235
|
+
pickedSmall.push(...cappedSmall.slice(pickedSmall.length, pickedSmall.length + need))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const pickedZen = zen.slice(0, zenReserve)
|
|
239
|
+
|
|
240
|
+
const selection = [
|
|
241
|
+
...go.map((row) => ({ model: row.model, weight: 4, context: row.context ?? null })),
|
|
242
|
+
...pickedSmall.map((row) => ({ model: row.model, weight: 2, context: row.context ?? null })),
|
|
243
|
+
...pickedLarge.map((row) => ({ model: row.model, weight: 1, context: row.context ?? null })),
|
|
244
|
+
...pickedZen.map((row) => ({ model: row.model, weight: 2, bucket: "opencode", context: row.context ?? null })),
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
const warnings = []
|
|
248
|
+
for (const entry of Array.isArray(providers) ? providers : []) {
|
|
249
|
+
if (entry?.error) {
|
|
250
|
+
warnings.push(`${entry.provider}: ${entry.error}`)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
const entryRows = Array.isArray(entry?.rows) ? entry.rows : []
|
|
254
|
+
if (entryRows.length === 0 || entryRows.some(rankable)) continue
|
|
255
|
+
const kinds = new Map()
|
|
256
|
+
for (const row of entryRows) {
|
|
257
|
+
if (row?.ok !== false) continue
|
|
258
|
+
const kind = row.kind ?? classifyFailure({ status: row.status, reason: row.reason })
|
|
259
|
+
kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
|
|
260
|
+
}
|
|
261
|
+
const [dominant, count] = [...kinds.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] ?? [
|
|
262
|
+
"unknown",
|
|
263
|
+
0,
|
|
264
|
+
]
|
|
265
|
+
warnings.push(`${entry.provider}: ${entryRows.length} probed, 0 rankable — dominant failure: ${dominant} (${count})`)
|
|
266
|
+
}
|
|
267
|
+
const total = selection.length
|
|
268
|
+
if (total === 0) warnings.push("no scorable model at or above the minimum score is available")
|
|
269
|
+
else if (total < poolSize) warnings.push(`only ${total} scorable model(s) available, wanted ${poolSize}`)
|
|
270
|
+
|
|
271
|
+
const limited = rows.filter((row) => row?.ok === false && isTransientFailure(row) && Number.isFinite(row.retryAfter))
|
|
272
|
+
const retry =
|
|
273
|
+
limited.length > 0
|
|
274
|
+
? {
|
|
275
|
+
after: new Date(Math.min(...limited.map((row) => row.retryAfter))).toISOString(),
|
|
276
|
+
models: limited.map((row) => row.model),
|
|
277
|
+
}
|
|
278
|
+
: undefined
|
|
279
|
+
|
|
280
|
+
return { selection, warnings, retry }
|
|
281
|
+
}
|