create-byan-agent 2.39.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 +102 -1
- 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/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/workflows/sprint-planning.js +2 -2
- package/install/templates/.claude/workflows/testarch-trace.js +3 -2
- 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/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 +2 -2
- 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/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
|
@@ -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
|
}
|