create-byan-agent 2.38.0 → 2.41.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 +165 -0
- package/install/bin/create-byan-agent-v2.js +40 -0
- package/install/lib/codex-autodelegate-setup.js +100 -0
- package/install/templates/.claude/hooks/codex-autodelegate.js +74 -0
- package/install/templates/.claude/hooks/lib/autodelegate-decision.js +152 -0
- package/install/templates/.claude/hooks/lib/perf-routing.js +47 -0
- package/install/templates/.claude/hooks/lib/strict-config.json +14 -0
- package/install/templates/.claude/hooks/lib/usage-estimator.js +262 -0
- package/install/templates/.claude/hooks/tier-script-guard.js +185 -0
- package/install/templates/.claude/rules/native-workflows.md +33 -7
- package/install/templates/.claude/settings.json +13 -0
- package/install/templates/.claude/skills/byan-byan/SKILL.md +7 -2
- package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +3 -1
- package/install/templates/.claude/skills/byan-strict/SKILL.md +9 -0
- package/install/templates/.claude/workflows/sprint-planning.js +2 -2
- package/install/templates/.claude/workflows/testarch-trace.js +3 -2
- package/install/templates/.githooks/pre-commit +18 -0
- package/install/templates/_byan/_config/strict-mode.yaml +25 -0
- package/install/templates/_byan/agent/byan/byan-soul.md +40 -0
- package/install/templates/_byan/mcp/byan-mcp-server/bin/byan-tier-script.js +52 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/dispatch.js +22 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/native-tiers.js +26 -9
- package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +40 -2
- package/install/templates/_byan/mcp/byan-mcp-server/lib/tier-script.js +104 -0
- package/install/templates/_byan/mcp/byan-mcp-server/lib/workflows-lint.js +92 -6
- package/install/templates/_byan/mcp/byan-mcp-server/server.js +22 -7
- package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +3 -3
- package/install/templates/dist/skill-bundles/byan-byan.zip +0 -0
- package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
- package/install/templates/dist/skill-bundles/byan-strict.zip +0 -0
- package/install/templates/docs/native-workflows-contract.md +55 -12
- package/package.json +1 -1
- package/src/loadbalancer/capability-matrix.js +14 -0
- package/src/loadbalancer/degradation-ladder.js +121 -0
- package/src/loadbalancer/loadbalancer.default.yaml +23 -1
- package/src/loadbalancer/mcp-server.js +200 -18
- package/src/loadbalancer/providers/codex-provider.js +260 -0
- package/src/loadbalancer/providers/factory.js +36 -0
- package/src/loadbalancer/subscription-window.js +142 -0
- package/src/loadbalancer/switch-tolerance.js +63 -0
- package/src/loadbalancer/tools/index.js +17 -2
|
@@ -96,20 +96,29 @@ tier vocabulary, the leaf classifier, and the model map.
|
|
|
96
96
|
| Tier | `opts.model` | Used for |
|
|
97
97
|
|------|--------------|----------|
|
|
98
98
|
| `deep` | **omitted** (inherit the session model) | implement, verify, analysis — the default |
|
|
99
|
-
| `balanced` | `sonnet` |
|
|
99
|
+
| `balanced` | `sonnet` | MECHANICAL verification, opt-in only via the `mech-` label prefix |
|
|
100
100
|
| `cheap` | `haiku` | a pure exploration leaf: read / load / parse / detect |
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
Three hard rules:
|
|
103
103
|
|
|
104
104
|
- **No pin-up.** `deep` is an omission, not `model: 'opus'`. Omitting lets a
|
|
105
105
|
leaf inherit whatever the session runs — Opus by default, Sonnet if the user
|
|
106
106
|
chose Sonnet. Pinning a fixed high tier would override that and could silently
|
|
107
107
|
downgrade a Sonnet/Opus session's heavy leaf.
|
|
108
|
-
- **Only exploration
|
|
109
|
-
unambiguous read/extract work. `classifyLeaf` keys off the LABEL
|
|
110
|
-
is too noisy — an exploration leaf often says "report what you
|
|
111
|
-
Protected types (implementation / verification / analysis) and any
|
|
112
|
-
label default to `deep`.
|
|
108
|
+
- **Only exploration and mech- downgrade.** A leaf is pinned to `cheap` only
|
|
109
|
+
when it is unambiguous read/extract work. `classifyLeaf` keys off the LABEL
|
|
110
|
+
(the prompt is too noisy — an exploration leaf often says "report what you
|
|
111
|
+
found"). Protected types (implementation / verification / analysis) and any
|
|
112
|
+
unknown label default to `deep`.
|
|
113
|
+
- **`mech-` is a held declaration.** The `balanced` tier is reachable ONLY
|
|
114
|
+
through the explicit `mech-` label prefix (`mech-validate-json`): a binary,
|
|
115
|
+
judgment-free check — JSON parses, schema matches, lint passes — whose FIXING
|
|
116
|
+
(if any) happens in a different leaf. The prefix wins over keyword
|
|
117
|
+
classification (that is the point of the opt-in), and the linter holds the
|
|
118
|
+
script to it: a `mech-` leaf with no model (`mechanical-without-model`) or
|
|
119
|
+
with `haiku` (`mechanical-below-tier`) is a hard violation. Verification that
|
|
120
|
+
reads MEANING (content vs requirements, coverage adequacy, adversarial
|
|
121
|
+
review) is not mechanical and stays deep.
|
|
113
122
|
|
|
114
123
|
The classifier is permissive (it labels by keyword), so it is a FLOOR, not a
|
|
115
124
|
ceiling: the linter forbids downgrading a protected leaf, but it does not force
|
|
@@ -146,13 +155,15 @@ verbatim/gate sink, making the re-read net-negative or marginal.
|
|
|
146
155
|
Enforcement (because the in-session hooks do not fire inside a script):
|
|
147
156
|
|
|
148
157
|
- `workflows-lint.js` -> `modelRoutingViolations` rejects (a) a `model:` value
|
|
149
|
-
that is not a known downgrade tier, (b) a downgrade on a
|
|
150
|
-
(`protected-leaf-downgraded`), (c) a downgrade with no in-object label
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
that is not a known downgrade tier, (b) a downgrade on a protected leaf
|
|
159
|
+
(`protected-leaf-downgraded`), (c) a downgrade with no in-object label,
|
|
160
|
+
(d) `haiku` on a `mech-` leaf (`mechanical-below-tier`); and
|
|
161
|
+
`mechanicalLabelViolations` rejects a `mech-` leaf with no model at all
|
|
162
|
+
(`mechanical-without-model`). Both are part of `validateContract`, so
|
|
163
|
+
`byan-lint-workflows` and the pre-commit gate enforce them.
|
|
153
164
|
- `test/native-routing-integration.test.js` pins the invariant on the SHIPPED
|
|
154
165
|
scripts: every script passes the contract, and every downgrade sits on an
|
|
155
|
-
exploration leaf.
|
|
166
|
+
exploration or `mech-` leaf.
|
|
156
167
|
- `workflows-lint.js` -> `untieredExplorationViolations` is the SYMMETRIC
|
|
157
168
|
advisory: it surfaces an exploration-labelled leaf that runs deep (a possible
|
|
158
169
|
saving). It is DELIBERATELY OUT of `validateContract` — forcing those leaves to
|
|
@@ -162,6 +173,38 @@ Enforcement (because the in-session hooks do not fire inside a script):
|
|
|
162
173
|
knob (the native `agent()` / Agent API exposes only `model`), so model tier is
|
|
163
174
|
the sole token lever and effort-by-complexity reduces to model-by-complexity.
|
|
164
175
|
|
|
176
|
+
## Ad-hoc scripts — the tier gate at the Workflow chokepoint
|
|
177
|
+
|
|
178
|
+
The repo linter only sees committed `.claude/workflows/*.js`. A script authored
|
|
179
|
+
inline for one run (the "adversarial review" pattern) is invisible to it and
|
|
180
|
+
used to run every leaf on the session model. It crosses exactly one enforceable
|
|
181
|
+
chokepoint before executing — the Workflow tool invocation — and that is where
|
|
182
|
+
the gate now sits:
|
|
183
|
+
|
|
184
|
+
| Piece | File | Role |
|
|
185
|
+
|-------|------|------|
|
|
186
|
+
| Engine | `lib/tier-script.js` | per-leaf report (`analyzeScript`) + pure gate decision (`decideTierGate`) against native-tiers; parsing stays in `workflows-lint.js` (`extractLabelledLeaves`) |
|
|
187
|
+
| Hook | `.claude/hooks/tier-script-guard.js` | PreToolUse (matcher `Workflow`), CJS shell around the engine |
|
|
188
|
+
| CLI | `bin/byan-tier-script.js` | standalone report on any script file; exit 0 clean/acknowledged, 1 gaps, 2 violations |
|
|
189
|
+
| Batch aid | `byan_dispatch { leaves: [...] }` | per-leaf `opts.model` BEFORE the script is written |
|
|
190
|
+
| Ledger | `_byan-output/tier-ledger.jsonl` | every gate decision + per-model histogram — the measurement basis for token gains |
|
|
191
|
+
|
|
192
|
+
Gate behaviour, deliberately narrow:
|
|
193
|
+
|
|
194
|
+
- **Deny once, with the exact fix.** Undecided exploration/`mech-` leaves deny
|
|
195
|
+
the invocation ONE time, listing each leaf and the value to write. An
|
|
196
|
+
identical resubmission passes — the gate forces a decision, it does not trap
|
|
197
|
+
the turn (same one-regen shape as the autobench Stop hook).
|
|
198
|
+
- **It rewrites nothing.** An auto-stamped model on a misclassified leaf would
|
|
199
|
+
be the STRICT-2 No Downgrade regression; the author keeps the pen.
|
|
200
|
+
- **Acknowledgment marker.** `// BYAN-TIER: reviewed` in the script asserts the
|
|
201
|
+
deep choices are deliberate; the gate then passes and logs `acknowledged`.
|
|
202
|
+
- **Registry passes.** A name-only invocation resolves to a committed script the
|
|
203
|
+
pre-commit linter already owns.
|
|
204
|
+
- **Escape hatch.** `touch .byan-tier/off` disables gating; the ledger still
|
|
205
|
+
records `escape-hatch` so misses stay auditable. Deny-once memory lives in the
|
|
206
|
+
gitignored `.byan-tier/` sidecar, keyed by script hash.
|
|
207
|
+
|
|
165
208
|
If a future runtime needs full model ids instead of the `haiku`/`sonnet`
|
|
166
209
|
aliases, `TIER_MODEL` in `native-tiers.js` is the only edit; the linter then
|
|
167
210
|
flags every script literal that drifts from it, so the fan-out stays bounded.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-byan-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.41.0",
|
|
4
4
|
"description": "BYAN v2.8 - Intelligent AI agent creator with ELO trust system + scientific fact-check + Hermes universal dispatcher + native Claude Code integration (hooks, skills, MCP server). Multi-platform (Claude Code, Codex). Merise Agile + TDD + 71 Mantras. ~54% LLM cost savings.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -35,6 +35,20 @@ const DEFAULT_CAPABILITIES = {
|
|
|
35
35
|
tool_use: true,
|
|
36
36
|
image_input: true,
|
|
37
37
|
},
|
|
38
|
+
codex: {
|
|
39
|
+
file_edit: true,
|
|
40
|
+
file_read: true,
|
|
41
|
+
bash: true,
|
|
42
|
+
web_search: false,
|
|
43
|
+
mcp_tools: true,
|
|
44
|
+
subagents: false,
|
|
45
|
+
git: true,
|
|
46
|
+
streaming: false, // codex exec is request/response (JSONL events, not token stream)
|
|
47
|
+
multi_turn: true, // thread id enables continuation
|
|
48
|
+
max_context_tokens: 256000,
|
|
49
|
+
tool_use: true,
|
|
50
|
+
image_input: false,
|
|
51
|
+
},
|
|
38
52
|
byan_api: {
|
|
39
53
|
file_edit: false,
|
|
40
54
|
file_read: false,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* degradation-ladder — the 4-rung switch policy that answers the 5h pain.
|
|
3
|
+
*
|
|
4
|
+
* Driven by the subscription-window tracker (F2), NOT by 429s: it moves work to
|
|
5
|
+
* the secondary pool as the primary's rolling 5h budget heats up, BEFORE the wall
|
|
6
|
+
* — the whole point ("j'en ai marre de reach ma limite 5h"). It obeys the red
|
|
7
|
+
* line (F4, switch-tolerance): only delegable work crosses to Codex; BYAN's
|
|
8
|
+
* judgment/identity work stays on the primary, and queues rather than denatures
|
|
9
|
+
* when the primary is truly spent.
|
|
10
|
+
*
|
|
11
|
+
* Pure: takes a snapshot { primary, secondaries, pools: { name: poolState } } and
|
|
12
|
+
* a task nature; returns a routing decision. poolState is
|
|
13
|
+
* { windowProximity (0-1|null), pressureRecommendation ('ok'|'caution'|'switch_now'),
|
|
14
|
+
* canAccept (bool), usable (bool) }. No clocks, no I/O — the caller assembles the
|
|
15
|
+
* snapshot from LoadBalancerLive.getWindowStates() + tracker states.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { isDelegable } = require('./switch-tolerance');
|
|
19
|
+
|
|
20
|
+
const RUNGS = Object.freeze({
|
|
21
|
+
HEALTHY: 'HEALTHY',
|
|
22
|
+
PRIMARY_HOT: 'PRIMARY_HOT',
|
|
23
|
+
PRIMARY_EXHAUSTED: 'PRIMARY_EXHAUSTED',
|
|
24
|
+
ALL_EXHAUSTED: 'ALL_EXHAUSTED',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const CAUTION = 0.5;
|
|
28
|
+
const SWITCH_NOW = 0.8;
|
|
29
|
+
|
|
30
|
+
function poolExhausted(p) {
|
|
31
|
+
if (!p) return true;
|
|
32
|
+
if (p.canAccept === false) return true;
|
|
33
|
+
if (p.pressureRecommendation === 'switch_now') return true;
|
|
34
|
+
if (typeof p.windowProximity === 'number' && p.windowProximity >= SWITCH_NOW) return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function poolHot(p) {
|
|
39
|
+
if (!p || poolExhausted(p)) return false;
|
|
40
|
+
if (p.pressureRecommendation === 'caution') return true;
|
|
41
|
+
if (typeof p.windowProximity === 'number' && p.windowProximity >= CAUTION) return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function poolUsable(p) {
|
|
46
|
+
return !!p && p.usable !== false && p.canAccept !== false && !poolExhausted(p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function firstUsableSecondary(snapshot) {
|
|
50
|
+
for (const name of snapshot.secondaries || []) {
|
|
51
|
+
if (poolUsable(snapshot.pools[name])) return name;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// System-level rung (independent of the task nature).
|
|
57
|
+
function computeRung(snapshot) {
|
|
58
|
+
const primary = snapshot.pools[snapshot.primary];
|
|
59
|
+
const usableSecondary = firstUsableSecondary(snapshot);
|
|
60
|
+
|
|
61
|
+
if (poolExhausted(primary)) {
|
|
62
|
+
return {
|
|
63
|
+
rung: usableSecondary ? RUNGS.PRIMARY_EXHAUSTED : RUNGS.ALL_EXHAUSTED,
|
|
64
|
+
usableSecondary,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (poolHot(primary)) return { rung: RUNGS.PRIMARY_HOT, usableSecondary };
|
|
68
|
+
return { rung: RUNGS.HEALTHY, usableSecondary };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Routing decision for ONE task, given its nature. Returns
|
|
72
|
+
// { action: 'route'|'queue', target: providerName|null, rung, delegable, reason }.
|
|
73
|
+
function decideRoute(snapshot, nature) {
|
|
74
|
+
const { rung, usableSecondary } = computeRung(snapshot);
|
|
75
|
+
const primary = snapshot.primary;
|
|
76
|
+
const primaryPool = snapshot.pools[primary];
|
|
77
|
+
const delegable = isDelegable(nature);
|
|
78
|
+
|
|
79
|
+
// Red line: primary-only work (judgment/identity) never crosses to a secondary.
|
|
80
|
+
if (!delegable) {
|
|
81
|
+
if (primaryPool && primaryPool.canAccept !== false) {
|
|
82
|
+
return {
|
|
83
|
+
action: 'route',
|
|
84
|
+
target: primary,
|
|
85
|
+
rung,
|
|
86
|
+
delegable: false,
|
|
87
|
+
reason: `primary-only nature '${nature}' stays on ${primary} (red line: no denaturing), even at rung ${rung}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
action: 'queue',
|
|
92
|
+
target: null,
|
|
93
|
+
rung,
|
|
94
|
+
delegable: false,
|
|
95
|
+
reason: `primary '${primary}' cannot accept and nature '${nature}' is primary-only — queue rather than denature by crossing to a secondary`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Delegable work follows the pressure.
|
|
100
|
+
if (rung === RUNGS.HEALTHY) {
|
|
101
|
+
return { action: 'route', target: primary, rung, delegable: true, reason: `primary ${primary} healthy — no switch needed` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (usableSecondary) {
|
|
105
|
+
return {
|
|
106
|
+
action: 'route',
|
|
107
|
+
target: usableSecondary,
|
|
108
|
+
rung,
|
|
109
|
+
delegable: true,
|
|
110
|
+
reason: `primary ${primary} at rung ${rung} — delegable '${nature}' offloaded to ${usableSecondary}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No usable secondary. Ride the primary while it still accepts, else queue.
|
|
115
|
+
if (primaryPool && primaryPool.canAccept !== false) {
|
|
116
|
+
return { action: 'route', target: primary, rung, delegable: true, reason: `no usable secondary at rung ${rung} — riding ${primary} (degraded)` };
|
|
117
|
+
}
|
|
118
|
+
return { action: 'queue', target: null, rung, delegable: true, reason: `all pools exhausted at rung ${rung} — queue with backoff` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { computeRung, decideRoute, RUNGS, poolExhausted, poolHot, poolUsable, CAUTION, SWITCH_NOW };
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
providers:
|
|
5
5
|
copilot:
|
|
6
|
+
# Optional pool. Needs @github/copilot-sdk, an EXTERNAL optional dep NOT pinned
|
|
7
|
+
# in package.json (no fabricated version): the provider degrades to disabled if
|
|
8
|
+
# the SDK is absent. Install it manually to enable this pool.
|
|
6
9
|
enabled: true
|
|
7
10
|
sdk: "@github/copilot-sdk"
|
|
8
11
|
transport: "json-rpc"
|
|
@@ -20,13 +23,32 @@ providers:
|
|
|
20
23
|
worker: "claude-haiku-4.5"
|
|
21
24
|
agent: "claude-sonnet-4.5"
|
|
22
25
|
|
|
26
|
+
codex:
|
|
27
|
+
# OpenAI Codex pool. Backed by the `codex` SYSTEM CLI (codex-cli), spawned in
|
|
28
|
+
# headless `codex exec --json` mode — NOT an npm SDK, so nothing to install via
|
|
29
|
+
# package.json; the provider degrades to disabled if the binary is absent.
|
|
30
|
+
# Two auth pools back the same binary: CODEX_API_KEY (per-token, no weekly cap)
|
|
31
|
+
# wins when set, else the ChatGPT-subscription session (~/.codex/auth.json,
|
|
32
|
+
# the 5h + weekly window). Sandbox is bounded for automation.
|
|
33
|
+
enabled: true
|
|
34
|
+
bin: "codex"
|
|
35
|
+
transport: "cli-exec"
|
|
36
|
+
auth_env: "CODEX_API_KEY"
|
|
37
|
+
sandbox: "read-only"
|
|
38
|
+
max_context_tokens: 256000
|
|
39
|
+
# Plain gpt-5.x is the entitled family on a ChatGPT subscription; -codex ids
|
|
40
|
+
# are API-key only (rejected on subscription). See resolveCodexModel.
|
|
41
|
+
models:
|
|
42
|
+
worker: "gpt-5.4"
|
|
43
|
+
agent: "gpt-5.4"
|
|
44
|
+
|
|
23
45
|
byan_api:
|
|
24
46
|
enabled: false
|
|
25
47
|
url: "http://localhost:3737"
|
|
26
48
|
auth_env: "BYAN_API_TOKEN"
|
|
27
49
|
|
|
28
50
|
primary: "claude"
|
|
29
|
-
fallback_order: ["copilot", "byan_api"]
|
|
51
|
+
fallback_order: ["codex", "copilot", "byan_api"]
|
|
30
52
|
|
|
31
53
|
rate_limits:
|
|
32
54
|
window_ms: 60000
|
|
@@ -24,12 +24,22 @@ const { RateLimitTracker } = require('./rate-limit-tracker');
|
|
|
24
24
|
const { Metrics } = require('./metrics');
|
|
25
25
|
const { VelocityEstimator } = require('./velocity-estimator');
|
|
26
26
|
const { calculatePressure, formatPressureSummary } = require('./pressure-score');
|
|
27
|
+
const { buildProviders } = require('./providers/factory');
|
|
28
|
+
const { SessionBridge } = require('./session-bridge');
|
|
29
|
+
const { GracefulDegradation } = require('./graceful-degradation');
|
|
30
|
+
const { SubscriptionWindow } = require('./subscription-window');
|
|
31
|
+
const { decideRoute } = require('./degradation-ladder');
|
|
27
32
|
const { EventEmitter } = require('events');
|
|
28
33
|
|
|
29
34
|
const VERSION = '0.2.0';
|
|
30
35
|
|
|
31
36
|
class LoadBalancerLive extends EventEmitter {
|
|
32
|
-
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} config - loaded loadbalancer config
|
|
39
|
+
* @param {object} [deps] - injectable seams for tests: { providers, bridge,
|
|
40
|
+
* degradation, windows }. Omitted in production -> built from config.
|
|
41
|
+
*/
|
|
42
|
+
constructor(config, deps = {}) {
|
|
33
43
|
super();
|
|
34
44
|
this.config = config;
|
|
35
45
|
this.activeProvider = config.primary;
|
|
@@ -38,6 +48,7 @@ class LoadBalancerLive extends EventEmitter {
|
|
|
38
48
|
|
|
39
49
|
this.trackers = {};
|
|
40
50
|
this.velocities = {};
|
|
51
|
+
this.windows = {}; // subscription-window tracker per pool (F2 -> F3 wiring)
|
|
41
52
|
this.metrics = new Metrics();
|
|
42
53
|
|
|
43
54
|
const rlOpts = config.rate_limits || {};
|
|
@@ -60,12 +71,61 @@ class LoadBalancerLive extends EventEmitter {
|
|
|
60
71
|
this.velocities[name].on('threshold_warning', (evt) => {
|
|
61
72
|
this.emit('velocity_warning', evt);
|
|
62
73
|
});
|
|
74
|
+
|
|
75
|
+
// Subscription-window burn tracker per pool (5h + weekly). Budgets are
|
|
76
|
+
// optional per-provider config; absent -> honest null proximity.
|
|
77
|
+
this.windows[name] = (deps.windows && deps.windows[name]) || new SubscriptionWindow(name, {
|
|
78
|
+
windowTokenBudget: prov.window_token_budget || null,
|
|
79
|
+
weeklyTokenBudget: prov.weekly_token_budget || null,
|
|
80
|
+
});
|
|
63
81
|
}
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
// Real execution surface (LB-01/LB-04/LB-STATE unblocked): a provider
|
|
85
|
+
// registry, a session bridge for cross-provider context transfer, and the
|
|
86
|
+
// graceful-degradation queue wired to this emitter's rate_limit_change events.
|
|
87
|
+
this.providers = deps.providers || buildProviders(config);
|
|
88
|
+
this.bridge = deps.bridge || (deps.store ? new SessionBridge({ store: deps.store, maxTokens: config.sessions?.context_summary_max_tokens }) : null);
|
|
89
|
+
this._initializedProviders = new Set();
|
|
90
|
+
this.degradation = deps.degradation || new GracefulDegradation({ lb: this });
|
|
91
|
+
|
|
66
92
|
this.metrics.attachToLoadBalancer(this);
|
|
67
93
|
}
|
|
68
94
|
|
|
95
|
+
// Order of pools to try: preferred first (if not 'auto'), then primary, then
|
|
96
|
+
// configured fallback order. Deduped, enabled-only.
|
|
97
|
+
_providerOrder(prefer) {
|
|
98
|
+
const order = [];
|
|
99
|
+
if (prefer && prefer !== 'auto') order.push(prefer);
|
|
100
|
+
order.push(this.config.primary, ...(this.config.fallback_order || []));
|
|
101
|
+
return [...new Set(order)].filter((n) => this.trackers[n]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async _ensureAvailable(name) {
|
|
105
|
+
const provider = this.providers[name];
|
|
106
|
+
if (!provider) return false;
|
|
107
|
+
if (!this._initializedProviders.has(name)) {
|
|
108
|
+
try {
|
|
109
|
+
await provider.initialize();
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
this._initializedProviders.add(name);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return await provider.isAvailable();
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_recordWindowUsage(name, response) {
|
|
123
|
+
const w = this.windows[name];
|
|
124
|
+
if (w && typeof w.recordFromResponse === 'function') {
|
|
125
|
+
w.recordFromResponse(response, Date.now());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
69
129
|
getTracker(provider) {
|
|
70
130
|
return this.trackers[provider] || null;
|
|
71
131
|
}
|
|
@@ -201,45 +261,164 @@ class LoadBalancerLive extends EventEmitter {
|
|
|
201
261
|
return result;
|
|
202
262
|
}
|
|
203
263
|
|
|
204
|
-
|
|
264
|
+
// Real send (LB-01 unblocked): walk the provider order, skip pools that are
|
|
265
|
+
// rate-limited (tracker) or unauthenticated/absent (isAvailable), call the
|
|
266
|
+
// first healthy one, record success/429 + window usage. When NONE can serve,
|
|
267
|
+
// return a graceful degraded result (never a throw, never a stub message).
|
|
268
|
+
async send(opts = {}) {
|
|
269
|
+
for (const name of this._providerOrder(opts.preferProvider)) {
|
|
270
|
+
const tracker = this.trackers[name];
|
|
271
|
+
const provider = this.providers[name];
|
|
272
|
+
if (!provider || !tracker || !tracker.canAcceptRequest()) continue;
|
|
273
|
+
if (!(await this._ensureAvailable(name))) continue;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const res = await provider.send({ prompt: opts.prompt, model: opts.model, sessionId: opts.sessionId });
|
|
277
|
+
if (res && res.rateLimited) {
|
|
278
|
+
this.record429(name, res.rateLimitHeaders || {});
|
|
279
|
+
continue; // try the next pool
|
|
280
|
+
}
|
|
281
|
+
this.recordSuccess(name);
|
|
282
|
+
this._recordWindowUsage(name, res);
|
|
283
|
+
this.activeProvider = name;
|
|
284
|
+
return { ...res, provider: name, degraded: false };
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// Hard failure on this pool: record and try the next one.
|
|
287
|
+
this.record429(name, { source: 'send_error' });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
205
292
|
return {
|
|
206
293
|
provider: this.activeProvider,
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
294
|
+
content: null,
|
|
295
|
+
degraded: true,
|
|
296
|
+
rateLimited: true,
|
|
297
|
+
reason: 'no provider available (all rate-limited, unauthenticated, or absent)',
|
|
210
298
|
};
|
|
211
299
|
}
|
|
212
300
|
|
|
213
|
-
|
|
301
|
+
// Real switch (LB-04 unblocked): flip the active pool + record history, and —
|
|
302
|
+
// when a session bridge is configured — transfer the portable context so the
|
|
303
|
+
// target pool resumes with the summary/decisions, not a cold start. Without a
|
|
304
|
+
// bridge, the switch still happens; contextTransferred is honestly false.
|
|
305
|
+
async switchProvider(opts = {}) {
|
|
214
306
|
const prev = this.activeProvider;
|
|
215
|
-
|
|
307
|
+
const target = opts.target;
|
|
308
|
+
|
|
309
|
+
let injectionPrompt = null;
|
|
310
|
+
let contextTransferred = false;
|
|
311
|
+
if (this.bridge && opts.sessionId && this.providers[prev]) {
|
|
312
|
+
try {
|
|
313
|
+
const transfer = await this.bridge.transfer(
|
|
314
|
+
this.providers[prev], target, opts.sessionId, opts.reason || 'manual_switch'
|
|
315
|
+
);
|
|
316
|
+
injectionPrompt = transfer.injectionPrompt;
|
|
317
|
+
contextTransferred = true;
|
|
318
|
+
} catch {
|
|
319
|
+
contextTransferred = false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.activeProvider = target;
|
|
216
324
|
const entry = {
|
|
217
325
|
timestamp: new Date().toISOString(),
|
|
218
326
|
from: prev,
|
|
219
|
-
to:
|
|
327
|
+
to: target,
|
|
220
328
|
reason: opts.reason || 'manual_switch',
|
|
329
|
+
contextTransferred,
|
|
221
330
|
};
|
|
222
331
|
this.switchHistory.push(entry);
|
|
223
332
|
this.emit('switch', entry);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
from: prev,
|
|
227
|
-
to: opts.target,
|
|
228
|
-
reason: opts.reason,
|
|
229
|
-
contextTransferred: false,
|
|
230
|
-
message: 'SessionBridge not yet integrated (LB-04). No context transfer.',
|
|
231
|
-
};
|
|
333
|
+
|
|
334
|
+
return { switched: true, from: prev, to: target, reason: opts.reason, contextTransferred, injectionPrompt };
|
|
232
335
|
}
|
|
233
336
|
|
|
337
|
+
// Real context read (LB-STATE unblocked): the portable context via the bridge
|
|
338
|
+
// when a store is configured; otherwise the honest current-provider view with a
|
|
339
|
+
// note that no session store is wired (no fabricated "not yet integrated" stub).
|
|
234
340
|
async getSessionContext(sessionId) {
|
|
341
|
+
const id = sessionId || 'current';
|
|
342
|
+
if (this.bridge && sessionId && this.providers[this.activeProvider]) {
|
|
343
|
+
try {
|
|
344
|
+
const context = await this.bridge.extract(this.providers[this.activeProvider], sessionId);
|
|
345
|
+
return { sessionId, provider: this.activeProvider, context };
|
|
346
|
+
} catch {
|
|
347
|
+
/* fall through to the no-context view */
|
|
348
|
+
}
|
|
349
|
+
}
|
|
235
350
|
return {
|
|
236
|
-
sessionId:
|
|
351
|
+
sessionId: id,
|
|
237
352
|
provider: this.activeProvider,
|
|
238
353
|
context: null,
|
|
239
|
-
|
|
354
|
+
note: 'no session store configured; context transfer is unavailable this run',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Per-pool subscription-window burn (F2), for the degradation ladder (F5) and
|
|
359
|
+
// the budget surface (F6).
|
|
360
|
+
getWindowStates(now = Date.now()) {
|
|
361
|
+
const out = {};
|
|
362
|
+
for (const [name, w] of Object.entries(this.windows)) {
|
|
363
|
+
out[name] = w.getState(now);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
getDegradationStatus() {
|
|
369
|
+
return this.degradation ? this.degradation.getStatus() : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Unified cross-pool budget view — the anti-"limite atteinte" dashboard. Per
|
|
373
|
+
// pool: rolling-5h + weekly burn, proximity (null when no budget is configured
|
|
374
|
+
// -> honest estimate), ETA, recommendation. Plus the current ladder rung. The
|
|
375
|
+
// note states the two hard truths plainly: this is an ESTIMATE (no provider
|
|
376
|
+
// exposes a machine-readable 5h quota), and load-balancing DOUBLES the ceiling,
|
|
377
|
+
// it does not remove it (Codex has its own 5h window).
|
|
378
|
+
getBudget(now = Date.now()) {
|
|
379
|
+
const windows = this.getWindowStates(now);
|
|
380
|
+
const pools = {};
|
|
381
|
+
for (const [name, w] of Object.entries(windows)) {
|
|
382
|
+
pools[name] = {
|
|
383
|
+
windowTokens: w.windowTokens,
|
|
384
|
+
windowProximity: w.windowProximity,
|
|
385
|
+
weekTokens: w.weekTokens,
|
|
386
|
+
weekProximity: w.weekProximity,
|
|
387
|
+
etaMinutes: w.etaMinutes === Infinity ? null : w.etaMinutes,
|
|
388
|
+
recommendation: w.recommendation,
|
|
389
|
+
budgetConfigured: w.windowBudget != null,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
pools,
|
|
394
|
+
rung: this.planRoute('exploration', now).rung,
|
|
395
|
+
note: 'estimate from observed token usage (no provider exposes a machine-readable 5h quota); load-balancing doubles the ceiling across pools, it does not remove it',
|
|
240
396
|
};
|
|
241
397
|
}
|
|
242
398
|
|
|
399
|
+
// Advisory routing plan for a task of a given nature: assembles the ladder
|
|
400
|
+
// snapshot from the window tracker + pressure + circuit-breaker state and
|
|
401
|
+
// returns the degradation decision (route to a pool, or queue). Side-effect
|
|
402
|
+
// free (uses canAccept, does not probe provider availability), so it is safe
|
|
403
|
+
// to call for planning; the actual send() still verifies isAvailable.
|
|
404
|
+
planRoute(nature, now = Date.now()) {
|
|
405
|
+
const quota = this.getQuota();
|
|
406
|
+
const windows = this.getWindowStates(now);
|
|
407
|
+
const secondaries = (this.config.fallback_order || []).filter((n) => this.trackers[n]);
|
|
408
|
+
const pools = {};
|
|
409
|
+
for (const name of [this.config.primary, ...secondaries]) {
|
|
410
|
+
const tracker = this.trackers[name];
|
|
411
|
+
if (!tracker) continue;
|
|
412
|
+
pools[name] = {
|
|
413
|
+
windowProximity: windows[name] ? windows[name].windowProximity : null,
|
|
414
|
+
pressureRecommendation: quota[name] ? quota[name].recommendation : 'ok',
|
|
415
|
+
canAccept: tracker.canAcceptRequest(),
|
|
416
|
+
usable: tracker.canAcceptRequest(),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return decideRoute({ primary: this.config.primary, secondaries, pools }, nature);
|
|
420
|
+
}
|
|
421
|
+
|
|
243
422
|
destroy() {
|
|
244
423
|
for (const tracker of Object.values(this.trackers)) {
|
|
245
424
|
tracker.destroy();
|
|
@@ -247,6 +426,9 @@ class LoadBalancerLive extends EventEmitter {
|
|
|
247
426
|
for (const ve of Object.values(this.velocities)) {
|
|
248
427
|
ve.destroy();
|
|
249
428
|
}
|
|
429
|
+
if (this.degradation && typeof this.degradation.destroy === 'function') {
|
|
430
|
+
this.degradation.destroy();
|
|
431
|
+
}
|
|
250
432
|
this.removeAllListeners();
|
|
251
433
|
}
|
|
252
434
|
}
|