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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodexProvider — wraps the OpenAI Codex CLI (`codex exec`) as a load-balancer pool.
|
|
3
|
+
*
|
|
4
|
+
* Unlike ClaudeProvider / CopilotProvider (npm SDKs), Codex ships as a SYSTEM CLI
|
|
5
|
+
* (`/usr/bin/codex`, codex-cli). So this provider spawns the binary in headless
|
|
6
|
+
* mode (`codex exec --json`) and parses the JSON Lines stream, rather than
|
|
7
|
+
* require()-ing a package. If the binary is absent the provider degrades to
|
|
8
|
+
* initialized=false, exactly like a missing SDK — the load-balancer then skips it.
|
|
9
|
+
*
|
|
10
|
+
* Two auth pools back the same binary (the arbitrage the subscription tracker
|
|
11
|
+
* reads): CODEX_API_KEY (billed per token, no weekly cap) wins when set, else the
|
|
12
|
+
* ChatGPT-subscription session in ~/.codex/auth.json (the 5h + weekly window).
|
|
13
|
+
*
|
|
14
|
+
* Entitled-model rule (live-verified on codex-cli 0.101 against a ChatGPT
|
|
15
|
+
* subscription): the OpenAI backend REJECTS every `-codex`-suffixed model on a
|
|
16
|
+
* subscription account ("The '<model>' model is not supported when using Codex
|
|
17
|
+
* with a ChatGPT account") — those ids are API-key only. Only the plain gpt-5.x
|
|
18
|
+
* family (e.g. gpt-5.4) is entitled on a subscription. So the default model is a
|
|
19
|
+
* plain entitled id, and resolveCodexModel normalizes a `-codex` request to that
|
|
20
|
+
* default when the active pool is the subscription. On an API key, `-codex`
|
|
21
|
+
* models bill fine and pass through untouched.
|
|
22
|
+
*
|
|
23
|
+
* Security posture for automation: a bounded sandbox (`-s`, default read-only)
|
|
24
|
+
* keeps a delegated run safe; a workspace-write run is opt-in via config. There
|
|
25
|
+
* is no approval flag — `codex exec` is already non-interactive, so no `-a` is
|
|
26
|
+
* passed (it would be a hard "unexpected argument" error; see send()). The API
|
|
27
|
+
* key is read from the environment and passed to the child process env; it is
|
|
28
|
+
* never logged, never written to disk, never placed in argv.
|
|
29
|
+
*
|
|
30
|
+
* Testability: parseCodexJsonl and detectCodexAuth are pure; the spawn is behind
|
|
31
|
+
* this._runCodex and the binary probe behind this._probeBinary, so the unit tests
|
|
32
|
+
* never touch the real CLI, the network, or the user's OpenAI quota.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const { spawn } = require('child_process');
|
|
39
|
+
const { BaseProvider } = require('./base-provider');
|
|
40
|
+
|
|
41
|
+
// Parse a `codex exec --json` JSON Lines stream into { content, usage, threadId }.
|
|
42
|
+
// Defensive by construction: blank lines and non-JSON noise are skipped (the CLI
|
|
43
|
+
// may emit progress/log lines), agent-message items are concatenated in order,
|
|
44
|
+
// and usage falls back to zero (never undefined) so downstream accounting is safe.
|
|
45
|
+
function parseCodexJsonl(stdout) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
48
|
+
let threadId = null;
|
|
49
|
+
|
|
50
|
+
for (const rawLine of String(stdout || '').split('\n')) {
|
|
51
|
+
const line = rawLine.trim();
|
|
52
|
+
if (!line) continue;
|
|
53
|
+
let ev;
|
|
54
|
+
try {
|
|
55
|
+
ev = JSON.parse(line);
|
|
56
|
+
} catch {
|
|
57
|
+
continue; // non-JSON progress/log noise
|
|
58
|
+
}
|
|
59
|
+
if (!ev || typeof ev !== 'object') continue;
|
|
60
|
+
|
|
61
|
+
if (ev.type === 'thread.started' && ev.thread && ev.thread.id) {
|
|
62
|
+
threadId = ev.thread.id;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Agent output: item events carrying a text payload. The item schema varies
|
|
67
|
+
// by codex version, so read text from the common shapes.
|
|
68
|
+
const item = ev.item || ev;
|
|
69
|
+
const itemType = item && item.type;
|
|
70
|
+
if (itemType === 'agent_message' || itemType === 'assistant_message' || itemType === 'message') {
|
|
71
|
+
const text = item.text || item.content || item.message;
|
|
72
|
+
if (typeof text === 'string' && text) parts.push(text);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ev.type === 'turn.completed' && ev.usage) {
|
|
77
|
+
const u = ev.usage;
|
|
78
|
+
const input = Number(u.input_tokens ?? u.input ?? 0) || 0;
|
|
79
|
+
const output = Number(u.output_tokens ?? u.output ?? 0) || 0;
|
|
80
|
+
usage = { inputTokens: input, outputTokens: output, totalTokens: input + output };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { content: parts.join('\n'), usage, threadId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Decide which OpenAI pool backs the CLI. API key wins (per-token, no weekly cap);
|
|
88
|
+
// else the ChatGPT-subscription session file; else no auth. Pure — env and fs are
|
|
89
|
+
// injected so it is testable without a real home dir.
|
|
90
|
+
function detectCodexAuth({ env = process.env, fs: fsImpl = fs, home = os.homedir() } = {}) {
|
|
91
|
+
if (env.CODEX_API_KEY) return 'api-key';
|
|
92
|
+
const authPath = path.join(home, '.codex', 'auth.json');
|
|
93
|
+
try {
|
|
94
|
+
if (fsImpl.existsSync(authPath)) return 'subscription';
|
|
95
|
+
} catch {
|
|
96
|
+
/* fall through */
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Rate-limit / quota-exhaustion detection from a failed codex exec. Codex has no
|
|
102
|
+
// machine-readable quota (OpenAI issue #10233), so we key off the stderr text of
|
|
103
|
+
// a non-zero exit. Conservative: only clear usage-limit language counts as a
|
|
104
|
+
// rate-limit (a soft, retryable signal); any other failure is a hard error.
|
|
105
|
+
const RATE_LIMIT_RE = /(usage limit|rate limit|rate_limit|quota|429|too many requests|limit reached)/i;
|
|
106
|
+
|
|
107
|
+
function isRateLimitFailure({ code, stderr }) {
|
|
108
|
+
return code !== 0 && RATE_LIMIT_RE.test(String(stderr || ''));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The plain gpt-5.x id verified entitled on a ChatGPT subscription (codex-cli
|
|
112
|
+
// 0.101). The CLI/companion default is a `-codex` id that a subscription rejects,
|
|
113
|
+
// so this is the safe default and the subscription remap target.
|
|
114
|
+
const SUBSCRIPTION_DEFAULT_MODEL = 'gpt-5.4';
|
|
115
|
+
|
|
116
|
+
// Pick the model that will actually be accepted by the active auth pool. On a
|
|
117
|
+
// subscription, a `-codex`-suffixed id is API-only and would be rejected, so it
|
|
118
|
+
// is remapped to the known-entitled default (we only remap what we KNOW fails,
|
|
119
|
+
// and only to the one id verified to work — we do not guess stripped variants).
|
|
120
|
+
// On an API key, or for an already-plain id, the request passes through. Pure.
|
|
121
|
+
function resolveCodexModel({ requested, authPool } = {}) {
|
|
122
|
+
const model = String(requested || '').trim();
|
|
123
|
+
if (!model) return SUBSCRIPTION_DEFAULT_MODEL;
|
|
124
|
+
if (authPool === 'subscription' && /-codex$/i.test(model)) {
|
|
125
|
+
return SUBSCRIPTION_DEFAULT_MODEL;
|
|
126
|
+
}
|
|
127
|
+
return model;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class CodexProvider extends BaseProvider {
|
|
131
|
+
constructor(providerConfig = {}) {
|
|
132
|
+
super('codex', providerConfig);
|
|
133
|
+
this._auth = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Probe whether the codex binary is on PATH (spawn `codex --version`). Seam for
|
|
137
|
+
// tests. Resolves boolean, never throws.
|
|
138
|
+
async _probeBinary() {
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
let done = false;
|
|
141
|
+
const finish = (ok) => { if (!done) { done = true; resolve(ok); } };
|
|
142
|
+
try {
|
|
143
|
+
const bin = this.config.bin || 'codex';
|
|
144
|
+
const child = spawn(bin, ['--version'], { stdio: 'ignore' });
|
|
145
|
+
child.on('error', () => finish(false));
|
|
146
|
+
child.on('close', (code) => finish(code === 0));
|
|
147
|
+
} catch {
|
|
148
|
+
finish(false);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_detectAuth() {
|
|
154
|
+
return detectCodexAuth({});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async initialize() {
|
|
158
|
+
const present = await this._probeBinary();
|
|
159
|
+
this.initialized = present === true;
|
|
160
|
+
if (this.initialized) this._auth = this._detectAuth();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
authPool() {
|
|
164
|
+
return this._detectAuth();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async isAvailable() {
|
|
168
|
+
if (!this.initialized) return false;
|
|
169
|
+
return this._detectAuth() !== null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Spawn `codex exec --json` and collect { stdout, stderr, code }. Seam for tests.
|
|
173
|
+
// The prompt is passed on stdin (never argv — avoids leaking it into ps output).
|
|
174
|
+
async _runCodex(args, input, env) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
let child;
|
|
177
|
+
try {
|
|
178
|
+
child = spawn(this.config.bin || 'codex', args, {
|
|
179
|
+
env: { ...process.env, ...env },
|
|
180
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
reject(err);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
let stdout = '';
|
|
187
|
+
let stderr = '';
|
|
188
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
189
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
190
|
+
child.on('error', reject);
|
|
191
|
+
child.on('close', (code) => resolve({ stdout, stderr, code }));
|
|
192
|
+
if (input) child.stdin.write(input);
|
|
193
|
+
child.stdin.end();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async send(opts) {
|
|
198
|
+
if (!this.initialized) throw new Error('CodexProvider not initialized');
|
|
199
|
+
|
|
200
|
+
const start = Date.now();
|
|
201
|
+
const requested = opts.model || this.config.models?.agent || SUBSCRIPTION_DEFAULT_MODEL;
|
|
202
|
+
// Normalize to an id the active auth pool will accept (a -codex model is
|
|
203
|
+
// API-only; a subscription would reject it). See resolveCodexModel.
|
|
204
|
+
const model = resolveCodexModel({ requested, authPool: this._detectAuth() });
|
|
205
|
+
const sandbox = this.config.sandbox || 'read-only';
|
|
206
|
+
|
|
207
|
+
// Non-interactive automation contract, verified against codex-cli 0.101.0
|
|
208
|
+
// `codex exec --help`: exec is ALREADY non-interactive (reads the prompt from
|
|
209
|
+
// stdin, prints JSONL events) so there is no `-a`/approval flag on the exec
|
|
210
|
+
// subcommand — passing one is a hard "unexpected argument" error. The bounded
|
|
211
|
+
// sandbox (`-s read-only` by default) is what keeps a delegated run safe; a
|
|
212
|
+
// workspace-write run is an explicit config opt-in.
|
|
213
|
+
const args = ['exec', '--json', '-s', sandbox, '-m', model];
|
|
214
|
+
|
|
215
|
+
const { stdout, stderr, code } = await this._runCodex(args, opts.prompt, {});
|
|
216
|
+
|
|
217
|
+
if (isRateLimitFailure({ code, stderr })) {
|
|
218
|
+
return {
|
|
219
|
+
provider: this.name,
|
|
220
|
+
content: null,
|
|
221
|
+
model,
|
|
222
|
+
rateLimitHeaders: null,
|
|
223
|
+
latencyMs: Date.now() - start,
|
|
224
|
+
rateLimited: true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (code !== 0) {
|
|
229
|
+
throw new Error(`codex exec failed (exit ${code}): ${String(stderr).split('\n')[0]}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const parsed = parseCodexJsonl(stdout);
|
|
233
|
+
return {
|
|
234
|
+
provider: this.name,
|
|
235
|
+
content: parsed.content,
|
|
236
|
+
model,
|
|
237
|
+
threadId: parsed.threadId,
|
|
238
|
+
usage: parsed.usage,
|
|
239
|
+
rateLimitHeaders: null,
|
|
240
|
+
latencyMs: Date.now() - start,
|
|
241
|
+
rateLimited: false,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
getCapabilities() {
|
|
246
|
+
return {
|
|
247
|
+
streaming: false, // exec is request/response; --json streams events, not tokens
|
|
248
|
+
tools: true, // codex runs tools in its sandbox
|
|
249
|
+
multiTurn: true, // thread id enables continuation
|
|
250
|
+
maxContextTokens: this.config.max_context_tokens || 256000,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async destroy() {
|
|
255
|
+
this._auth = null;
|
|
256
|
+
await super.destroy();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { CodexProvider, parseCodexJsonl, detectCodexAuth, isRateLimitFailure, resolveCodexModel, SUBSCRIPTION_DEFAULT_MODEL };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider factory — build the { name: ProviderInstance } registry from config.
|
|
3
|
+
*
|
|
4
|
+
* The one place that knows which provider class backs each config name. The
|
|
5
|
+
* LoadBalancer engine and the MCP shell both consume this instead of hand-wiring
|
|
6
|
+
* instances, so adding a pool (codex) is one line here, not N call sites.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { ClaudeProvider } = require('./claude-provider');
|
|
10
|
+
const { CopilotProvider } = require('./copilot-provider');
|
|
11
|
+
const { CodexProvider } = require('./codex-provider');
|
|
12
|
+
const { ByanApiProvider } = require('./byan-api-provider');
|
|
13
|
+
|
|
14
|
+
const PROVIDER_CLASSES = Object.freeze({
|
|
15
|
+
claude: ClaudeProvider,
|
|
16
|
+
copilot: CopilotProvider,
|
|
17
|
+
codex: CodexProvider,
|
|
18
|
+
byan_api: ByanApiProvider,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// buildProviders(config) -> { name: instance } for every ENABLED provider whose
|
|
22
|
+
// name is known. Unknown names are skipped (never fatal) so a typo in config
|
|
23
|
+
// degrades to "that pool is absent", not a crash.
|
|
24
|
+
function buildProviders(config) {
|
|
25
|
+
const out = {};
|
|
26
|
+
const providers = (config && config.providers) || {};
|
|
27
|
+
for (const [name, section] of Object.entries(providers)) {
|
|
28
|
+
if (section && section.enabled === false) continue;
|
|
29
|
+
const Cls = PROVIDER_CLASSES[name];
|
|
30
|
+
if (!Cls) continue;
|
|
31
|
+
out[name] = new Cls(section || {});
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { buildProviders, PROVIDER_CLASSES };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubscriptionWindow — per-pool burn against the rolling 5h + weekly caps.
|
|
3
|
+
*
|
|
4
|
+
* pressure-score.js answers "am I getting 429'd right now" (an API burst signal
|
|
5
|
+
* from the circuit breaker). This module answers the ORTHOGONAL question the 5h
|
|
6
|
+
* subscription pain is actually about: "how much of my rolling 5-hour and weekly
|
|
7
|
+
* budget have I already burned on this pool" (Claude Pro/Max = chat+code shared;
|
|
8
|
+
* Codex = local+cloud shared). The degradation ladder (F5) reads THIS to switch
|
|
9
|
+
* delegable work to the other pool BEFORE the wall, not after the first 429.
|
|
10
|
+
*
|
|
11
|
+
* Honest by construction: neither Claude nor Codex exposes a machine-readable 5h
|
|
12
|
+
* quota (OpenAI issue #10233). So this is an ESTIMATE from observed token usage.
|
|
13
|
+
* Without a configured budget it reports raw burn + velocity and a NULL proximity
|
|
14
|
+
* (never a fabricated percentage); with a budget it reports proximity + an ETA to
|
|
15
|
+
* exhaustion at recent velocity. The surface (F6) reconciles against /usage.
|
|
16
|
+
*
|
|
17
|
+
* The core (computeWindowState) is pure — `now` is injected, no Date.now — so the
|
|
18
|
+
* rolling windows are deterministic under test.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
22
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
const DEFAULT_VELOCITY_WINDOW_MS = 15 * 60 * 1000; // recent-rate window for ETA
|
|
24
|
+
|
|
25
|
+
function sumTokensSince(events, cutoff) {
|
|
26
|
+
let total = 0;
|
|
27
|
+
for (const e of events) {
|
|
28
|
+
if (e && e.timestamp > cutoff && Number.isFinite(e.tokens)) total += e.tokens;
|
|
29
|
+
}
|
|
30
|
+
return total;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clampProximity(tokens, budget) {
|
|
34
|
+
if (!budget || budget <= 0) return null; // no budget => estimate only, never fabricate a %
|
|
35
|
+
return Math.min(1, tokens / budget);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pure. events: [{ timestamp, tokens }]. Returns the window/week burn, proximity
|
|
39
|
+
// (null when the matching budget is absent), and an ETA to window-budget
|
|
40
|
+
// exhaustion at the recent velocity.
|
|
41
|
+
function computeWindowState(events, opts = {}) {
|
|
42
|
+
const {
|
|
43
|
+
now,
|
|
44
|
+
windowMs = FIVE_HOURS_MS,
|
|
45
|
+
weekMs = WEEK_MS,
|
|
46
|
+
windowTokenBudget = null,
|
|
47
|
+
weeklyTokenBudget = null,
|
|
48
|
+
velocityWindowMs = DEFAULT_VELOCITY_WINDOW_MS,
|
|
49
|
+
} = opts;
|
|
50
|
+
|
|
51
|
+
const windowTokens = sumTokensSince(events, now - windowMs);
|
|
52
|
+
const weekTokens = sumTokensSince(events, now - weekMs);
|
|
53
|
+
|
|
54
|
+
const windowProximity = clampProximity(windowTokens, windowTokenBudget);
|
|
55
|
+
const weekProximity = clampProximity(weekTokens, weeklyTokenBudget);
|
|
56
|
+
|
|
57
|
+
// ETA: recent velocity (tokens/min over velocityWindowMs) projected onto the
|
|
58
|
+
// remaining window budget. Infinite when idle or when no budget is known.
|
|
59
|
+
let etaMinutes = Infinity;
|
|
60
|
+
if (windowTokenBudget && windowTokenBudget > 0) {
|
|
61
|
+
const recentTokens = sumTokensSince(events, now - velocityWindowMs);
|
|
62
|
+
const perMin = recentTokens / (velocityWindowMs / 60000);
|
|
63
|
+
const remaining = Math.max(0, windowTokenBudget - windowTokens);
|
|
64
|
+
if (perMin > 0) etaMinutes = Math.round(remaining / perMin);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
windowTokens,
|
|
69
|
+
weekTokens,
|
|
70
|
+
windowProximity,
|
|
71
|
+
weekProximity,
|
|
72
|
+
windowBudget: windowTokenBudget,
|
|
73
|
+
weeklyBudget: weeklyTokenBudget,
|
|
74
|
+
etaMinutes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Recommendation from a proximity in [0,1]. Mirrors pressure-score's bands so the
|
|
79
|
+
// two signals speak the same language to the ladder. Null proximity => unknown.
|
|
80
|
+
function windowRecommendation(proximity) {
|
|
81
|
+
if (proximity === null || proximity === undefined) return 'unknown';
|
|
82
|
+
if (proximity >= 0.8) return 'switch_now';
|
|
83
|
+
if (proximity >= 0.5) return 'caution';
|
|
84
|
+
return 'ok';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class SubscriptionWindow {
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} pool - 'claude' | 'codex' | ...
|
|
90
|
+
* @param {object} opts - { windowTokenBudget, weeklyTokenBudget, windowMs, weekMs, velocityWindowMs }
|
|
91
|
+
*/
|
|
92
|
+
constructor(pool, opts = {}) {
|
|
93
|
+
this.pool = pool;
|
|
94
|
+
this.opts = opts;
|
|
95
|
+
this.weekMs = opts.weekMs || WEEK_MS;
|
|
96
|
+
this.events = [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
record({ tokens, timestamp }) {
|
|
100
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return;
|
|
101
|
+
this.events.push({ tokens, timestamp });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extract usage from a ProviderResponse ({ usage: { totalTokens } }). No-op when
|
|
105
|
+
// the response carries no usage (e.g. a rate-limited response).
|
|
106
|
+
recordFromResponse(response, timestamp) {
|
|
107
|
+
const total = response && response.usage && Number(response.usage.totalTokens);
|
|
108
|
+
if (Number.isFinite(total) && total > 0) this.record({ tokens: total, timestamp });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_prune(now) {
|
|
112
|
+
const cutoff = now - this.weekMs;
|
|
113
|
+
this.events = this.events.filter((e) => e.timestamp > cutoff);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getState(now) {
|
|
117
|
+
this._prune(now);
|
|
118
|
+
const state = computeWindowState(this.events, { now, ...this.opts });
|
|
119
|
+
return {
|
|
120
|
+
pool: this.pool,
|
|
121
|
+
...state,
|
|
122
|
+
recommendation: windowRecommendation(state.windowProximity),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
eventCount() {
|
|
127
|
+
return this.events.length;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
reset() {
|
|
131
|
+
this.events = [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
SubscriptionWindow,
|
|
137
|
+
computeWindowState,
|
|
138
|
+
windowRecommendation,
|
|
139
|
+
sumTokensSince,
|
|
140
|
+
FIVE_HOURS_MS,
|
|
141
|
+
WEEK_MS,
|
|
142
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* switch-tolerance — WHICH task natures may cross to a secondary provider pool.
|
|
3
|
+
*
|
|
4
|
+
* The load-balancer's model tier answers "which model" (native-tiers). THIS
|
|
5
|
+
* answers the orthogonal, identity-critical axis the user drew as the red line:
|
|
6
|
+
* "reduce tokens WITHOUT denaturing BYAN". Degradation (F5) may offload only
|
|
7
|
+
* DELEGABLE work to a secondary pool (Codex): mechanical checks (machine-
|
|
8
|
+
* verifiable output), exploration (read/scan), and implementation (where tests
|
|
9
|
+
* are the objective arbiter, provider-independent). Everything that carries
|
|
10
|
+
* BYAN's judgment or identity — verification (adversarial/semantic), analysis,
|
|
11
|
+
* and anything soul/identity/review/gate — stays on the primary (Claude) whatever
|
|
12
|
+
* the subscription pressure. This module is the single source of that line.
|
|
13
|
+
*
|
|
14
|
+
* Pure and dependency-light: an optional CapabilityMatrix filters secondaries by
|
|
15
|
+
* required capability, but the tolerance decision itself is a static classification.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DELEGABLE_NATURES = Object.freeze(['exploration', 'mechanical', 'implementation']);
|
|
19
|
+
const PRIMARY_ONLY_NATURES = Object.freeze([
|
|
20
|
+
'verification', // adversarial / semantic review = BYAN judgment
|
|
21
|
+
'analysis', // design / risk / evaluation = BYAN judgment
|
|
22
|
+
'soul', // identity
|
|
23
|
+
'identity',
|
|
24
|
+
'review', // the adversarial second pair of eyes stays on the primary
|
|
25
|
+
'gate', // phase / strict gates
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const SWITCH_TOLERANCE = Object.freeze({
|
|
29
|
+
...Object.fromEntries(DELEGABLE_NATURES.map((n) => [n, 'delegable'])),
|
|
30
|
+
...Object.fromEntries(PRIMARY_ONLY_NATURES.map((n) => [n, 'primary-only'])),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Conservative: only an explicitly-delegable nature is delegable. Unknown /
|
|
34
|
+
// missing nature => primary-only (never delegate on a guess — the red line).
|
|
35
|
+
function isDelegable(nature) {
|
|
36
|
+
return SWITCH_TOLERANCE[nature] === 'delegable';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// eligibleProviders(nature, { primary, secondaries, capabilityMatrix, required })
|
|
40
|
+
// -> ordered provider list, primary first. A primary-only nature returns only the
|
|
41
|
+
// primary. A delegable nature returns primary + secondaries that satisfy the
|
|
42
|
+
// required capabilities (when a matrix + required list are supplied).
|
|
43
|
+
function eligibleProviders(nature, opts = {}) {
|
|
44
|
+
const { primary, secondaries = [], capabilityMatrix = null, required = [] } = opts;
|
|
45
|
+
if (!primary) return [];
|
|
46
|
+
if (!isDelegable(nature)) return [primary];
|
|
47
|
+
|
|
48
|
+
let allowed = secondaries;
|
|
49
|
+
if (capabilityMatrix && required.length > 0) {
|
|
50
|
+
allowed = secondaries.filter((name) =>
|
|
51
|
+
required.every((cap) => capabilityMatrix.supports(name, cap))
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return [primary, ...allowed.filter((n) => n !== primary)];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
SWITCH_TOLERANCE,
|
|
59
|
+
DELEGABLE_NATURES,
|
|
60
|
+
PRIMARY_ONLY_NATURES,
|
|
61
|
+
isDelegable,
|
|
62
|
+
eligibleProviders,
|
|
63
|
+
};
|
|
@@ -27,7 +27,7 @@ function createTools(lb) {
|
|
|
27
27
|
properties: {
|
|
28
28
|
prompt: { type: 'string', description: 'The prompt to send' },
|
|
29
29
|
session_id: { type: 'string', description: 'Optional session ID for sticky routing' },
|
|
30
|
-
prefer_provider: { type: 'string', enum: ['claude', 'copilot', 'auto'], description: 'Provider preference (default: auto)' },
|
|
30
|
+
prefer_provider: { type: 'string', enum: ['claude', 'copilot', 'codex', 'auto'], description: 'Provider preference (default: auto)' },
|
|
31
31
|
},
|
|
32
32
|
required: ['prompt'],
|
|
33
33
|
},
|
|
@@ -46,7 +46,7 @@ function createTools(lb) {
|
|
|
46
46
|
inputSchema: {
|
|
47
47
|
type: 'object',
|
|
48
48
|
properties: {
|
|
49
|
-
target_provider: { type: 'string', enum: ['claude', 'copilot'], description: 'Provider to switch to' },
|
|
49
|
+
target_provider: { type: 'string', enum: ['claude', 'copilot', 'codex'], description: 'Provider to switch to' },
|
|
50
50
|
session_id: { type: 'string', description: 'Session to transfer context from' },
|
|
51
51
|
reason: { type: 'string', description: 'Reason for the switch' },
|
|
52
52
|
},
|
|
@@ -117,6 +117,21 @@ function createTools(lb) {
|
|
|
117
117
|
return { content: [{ type: 'text', text: summaries + '\n\n' + JSON.stringify(quota, null, 2) }] };
|
|
118
118
|
},
|
|
119
119
|
},
|
|
120
|
+
{
|
|
121
|
+
name: 'lb_budget',
|
|
122
|
+
description: 'Unified cross-pool subscription budget: rolling-5h + weekly token burn per pool (Claude / Codex), proximity to the cap (null when no budget configured), ETA to exhaustion, and the current degradation rung. The anti-"5h limit reached" dashboard. Honest estimate: no provider exposes a machine-readable 5h quota, and load-balancing doubles the ceiling across pools without removing it.',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: {},
|
|
126
|
+
},
|
|
127
|
+
handler: async () => {
|
|
128
|
+
if (typeof lb.getBudget !== 'function') {
|
|
129
|
+
return { content: [{ type: 'text', text: 'lb_budget requires LoadBalancerLive with the subscription-window tracker.' }] };
|
|
130
|
+
}
|
|
131
|
+
const budget = lb.getBudget();
|
|
132
|
+
return { content: [{ type: 'text', text: JSON.stringify(budget, null, 2) }] };
|
|
133
|
+
},
|
|
134
|
+
},
|
|
120
135
|
];
|
|
121
136
|
}
|
|
122
137
|
|