bullswarm 0.1.4
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/LICENSE +21 -0
- package/README.md +67 -0
- package/bin/bullswarm.js +12 -0
- package/connectors/_schema.json +31 -0
- package/connectors/claude-code.json +17 -0
- package/connectors/codex.json +41 -0
- package/connectors/command-code.json +39 -0
- package/connectors/echo-worker.mjs +38 -0
- package/connectors/echo.json +16 -0
- package/connectors/grok.json +38 -0
- package/connectors/opencode2.json +18 -0
- package/mcp/server.mjs +138 -0
- package/package.json +48 -0
- package/skill/SKILL.md +53 -0
- package/src/cli.js +307 -0
- package/src/lib/config.js +107 -0
- package/src/lib/release.js +55 -0
- package/src/lib/route.js +133 -0
- package/src/lib/state.js +105 -0
- package/src/lib/verify.js +126 -0
- package/src/lib/version.js +17 -0
- package/src/lib/watch.js +167 -0
- package/src/meters/claude.js +126 -0
- package/src/meters/codex.js +264 -0
- package/src/meters/command-code.js +218 -0
- package/src/meters/framework.js +128 -0
- package/src/meters/grok.js +200 -0
- package/src/meters/registry.js +85 -0
- package/src/setup.js +272 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// bullswarm codex meter — ChatGPT WHAM usage API.
|
|
2
|
+
// Endpoint + auth flow documented by OpenUsage (MIT, robinebers/openusage);
|
|
3
|
+
// implemented here independently for bullswarm.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
|
10
|
+
const REFRESH_URL = 'https://auth.openai.com/oauth/token';
|
|
11
|
+
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
12
|
+
const FIVE_HOUR_SECONDS = 5 * 60 * 60;
|
|
13
|
+
const SEVEN_DAY_SECONDS = 7 * 24 * 60 * 60;
|
|
14
|
+
const REFRESH_BUFFER_MS = 5 * 60_000;
|
|
15
|
+
|
|
16
|
+
export class CodexMeterError extends Error {
|
|
17
|
+
constructor(message, code) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code; // no_auth | api_key_only | http | parse | network
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function authPath() {
|
|
24
|
+
const home = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
|
|
25
|
+
return path.join(home, 'auth.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadAuth() {
|
|
29
|
+
const p = authPath();
|
|
30
|
+
if (!existsSync(p)) {
|
|
31
|
+
throw new CodexMeterError('Codex not logged in. Run `codex` to authenticate.', 'no_auth');
|
|
32
|
+
}
|
|
33
|
+
let auth;
|
|
34
|
+
try {
|
|
35
|
+
auth = JSON.parse(readFileSync(p, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
throw new CodexMeterError('Codex auth.json unreadable. Re-run `codex` login.', 'no_auth');
|
|
38
|
+
}
|
|
39
|
+
const access = auth.tokens?.access_token?.trim();
|
|
40
|
+
if (!access) {
|
|
41
|
+
if (auth.OPENAI_API_KEY?.trim()) {
|
|
42
|
+
throw new CodexMeterError(
|
|
43
|
+
'Codex usage needs ChatGPT-account auth (API-key-only cannot read subscription usage).',
|
|
44
|
+
'api_key_only',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
throw new CodexMeterError('Codex auth has no access token.', 'no_auth');
|
|
48
|
+
}
|
|
49
|
+
return { auth, path: p };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** JWT exp claim in ms, or null when undecodable. */
|
|
53
|
+
export function accessTokenExpiresAtMs(token) {
|
|
54
|
+
const parts = token.split('.');
|
|
55
|
+
if (parts.length < 2) return null;
|
|
56
|
+
try {
|
|
57
|
+
const pad = '='.repeat((4 - (parts[1].length % 4)) % 4);
|
|
58
|
+
const payload = JSON.parse(Buffer.from(parts[1] + pad, 'base64url').toString('utf8'));
|
|
59
|
+
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function needsRefresh(accessToken, lastRefresh, nowMs = Date.now()) {
|
|
66
|
+
const expMs = accessTokenExpiresAtMs(accessToken);
|
|
67
|
+
if (expMs !== null) return nowMs >= expMs - REFRESH_BUFFER_MS;
|
|
68
|
+
if (!lastRefresh) return false;
|
|
69
|
+
const ms = Date.parse(lastRefresh);
|
|
70
|
+
return Number.isFinite(ms) && nowMs - ms > 8 * 24 * 3600_000;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function refreshAccessToken(auth, filePath) {
|
|
74
|
+
const refresh = auth.tokens?.refresh_token?.trim();
|
|
75
|
+
if (!refresh) return null;
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await fetch(REFRESH_URL, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
81
|
+
body: new URLSearchParams({
|
|
82
|
+
grant_type: 'refresh_token',
|
|
83
|
+
client_id: CLIENT_ID,
|
|
84
|
+
refresh_token: refresh,
|
|
85
|
+
}).toString(),
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!res.ok) return null;
|
|
91
|
+
try {
|
|
92
|
+
const j = await res.json();
|
|
93
|
+
if (typeof j.access_token !== 'string' || !j.access_token) return null;
|
|
94
|
+
const next = {
|
|
95
|
+
...auth,
|
|
96
|
+
tokens: {
|
|
97
|
+
...auth.tokens,
|
|
98
|
+
access_token: j.access_token,
|
|
99
|
+
refresh_token: j.refresh_token ?? auth.tokens?.refresh_token,
|
|
100
|
+
id_token: j.id_token ?? auth.tokens?.id_token,
|
|
101
|
+
},
|
|
102
|
+
last_refresh: new Date().toISOString(),
|
|
103
|
+
};
|
|
104
|
+
// Best-effort write-back so the CLI shares the rotated token.
|
|
105
|
+
try {
|
|
106
|
+
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
107
|
+
} catch {
|
|
108
|
+
/* in-memory token still works */
|
|
109
|
+
}
|
|
110
|
+
return { accessToken: j.access_token, auth: next };
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- pure decoder (unit-tested without network) ------------------------------
|
|
117
|
+
|
|
118
|
+
function numberOf(v) {
|
|
119
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
120
|
+
if (typeof v === 'string' && v.trim()) {
|
|
121
|
+
const n = Number(v);
|
|
122
|
+
if (Number.isFinite(n)) return n;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function windowSeconds(raw) {
|
|
128
|
+
if (!raw) return undefined;
|
|
129
|
+
const sec = numberOf(raw.limit_window_seconds);
|
|
130
|
+
if (sec !== undefined) return sec;
|
|
131
|
+
const minutes = numberOf(raw.window_minutes);
|
|
132
|
+
if (minutes !== undefined) return minutes * 60;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeWindow(raw, headerPercent, nowMs) {
|
|
137
|
+
const used = numberOf(raw?.used_percent) ?? headerPercent;
|
|
138
|
+
if (used === undefined) return null;
|
|
139
|
+
let resetsAt = null;
|
|
140
|
+
const resetUnix = numberOf(raw?.reset_at) ?? numberOf(raw?.resets_at);
|
|
141
|
+
if (resetUnix !== undefined) {
|
|
142
|
+
resetsAt = new Date(resetUnix * 1000).toISOString();
|
|
143
|
+
} else {
|
|
144
|
+
const after = numberOf(raw?.reset_after_seconds);
|
|
145
|
+
if (after !== undefined) resetsAt = new Date(nowMs + after * 1000).toISOString();
|
|
146
|
+
}
|
|
147
|
+
return { utilization: Math.min(100, Math.max(0, used)), resets_at: resetsAt };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decode the WHAM body (+ optional x-codex-*-used-percent headers).
|
|
152
|
+
* Classification prefers explicit limit_window_seconds over slot order:
|
|
153
|
+
* a sole weekly window can appear in the primary slot.
|
|
154
|
+
*/
|
|
155
|
+
export function parseCodexWhamUsage(body, headers = {}, nowMs = Date.now()) {
|
|
156
|
+
if (!body || typeof body !== 'object') {
|
|
157
|
+
throw new CodexMeterError('Codex usage response missing body', 'parse');
|
|
158
|
+
}
|
|
159
|
+
const rateLimit =
|
|
160
|
+
body.rate_limit && typeof body.rate_limit === 'object' ? body.rate_limit : {};
|
|
161
|
+
const headerPrimary = numberOf(headers['x-codex-primary-used-percent']);
|
|
162
|
+
const headerSecondary = numberOf(headers['x-codex-secondary-used-percent']);
|
|
163
|
+
const primary = rateLimit.primary_window ?? null;
|
|
164
|
+
const secondary = rateLimit.secondary_window ?? null;
|
|
165
|
+
|
|
166
|
+
const candidates = [
|
|
167
|
+
{ raw: primary, headerPercent: headerPrimary, fallback: 'five_hour' },
|
|
168
|
+
{ raw: secondary, headerPercent: headerSecondary, fallback: 'seven_day' },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
let fiveHour = null;
|
|
172
|
+
let sevenDay = null;
|
|
173
|
+
for (const c of candidates) {
|
|
174
|
+
const secs = windowSeconds(c.raw);
|
|
175
|
+
const w = normalizeWindow(c.raw, c.headerPercent, nowMs);
|
|
176
|
+
if (!w) continue;
|
|
177
|
+
if (secs === FIVE_HOUR_SECONDS) fiveHour ??= w;
|
|
178
|
+
else if (secs === SEVEN_DAY_SECONDS) sevenDay ??= w;
|
|
179
|
+
}
|
|
180
|
+
if (!fiveHour || !sevenDay) {
|
|
181
|
+
for (const c of candidates) {
|
|
182
|
+
const secs = windowSeconds(c.raw);
|
|
183
|
+
if (secs === FIVE_HOUR_SECONDS || secs === SEVEN_DAY_SECONDS) continue;
|
|
184
|
+
const w = normalizeWindow(c.raw, c.headerPercent, nowMs);
|
|
185
|
+
if (!w) continue;
|
|
186
|
+
if (c.fallback === 'five_hour' && !fiveHour) fiveHour = w;
|
|
187
|
+
else if (c.fallback === 'seven_day' && !sevenDay) sevenDay = w;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const planRaw = body.plan_type;
|
|
192
|
+
return {
|
|
193
|
+
five_hour: fiveHour ?? { utilization: null, resets_at: null },
|
|
194
|
+
seven_day: sevenDay ?? { utilization: null, resets_at: null },
|
|
195
|
+
plan_type: typeof planRaw === 'string' && planRaw.trim() ? planRaw.trim() : null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- live fetch ----------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
async function fetchUsageResponse(accessToken, accountId) {
|
|
202
|
+
let res;
|
|
203
|
+
try {
|
|
204
|
+
res = await fetch(USAGE_URL, {
|
|
205
|
+
headers: {
|
|
206
|
+
Authorization: `Bearer ${accessToken}`,
|
|
207
|
+
Accept: 'application/json',
|
|
208
|
+
'User-Agent': 'bullswarm',
|
|
209
|
+
...(accountId?.trim() ? { 'ChatGPT-Account-Id': accountId.trim() } : {}),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
} catch (err) {
|
|
213
|
+
throw new CodexMeterError(`Network error reaching Codex usage: ${err.message}`, 'network');
|
|
214
|
+
}
|
|
215
|
+
const headers = {};
|
|
216
|
+
res.headers.forEach((value, key) => (headers[key.toLowerCase()] = value));
|
|
217
|
+
let body = null;
|
|
218
|
+
try {
|
|
219
|
+
body = await res.json();
|
|
220
|
+
} catch {
|
|
221
|
+
/* parse layer errors if needed */
|
|
222
|
+
}
|
|
223
|
+
return { status: res.status, body, headers };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function fetchCodexUsage() {
|
|
227
|
+
const loaded = loadAuth();
|
|
228
|
+
let auth = loaded.auth;
|
|
229
|
+
let access = auth.tokens.access_token.trim();
|
|
230
|
+
const accountId = auth.tokens?.account_id;
|
|
231
|
+
|
|
232
|
+
if (needsRefresh(access, auth.last_refresh)) {
|
|
233
|
+
const refreshed = await refreshAccessToken(auth, loaded.path);
|
|
234
|
+
if (refreshed) {
|
|
235
|
+
access = refreshed.accessToken;
|
|
236
|
+
auth = refreshed.auth;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let { status, body, headers } = await fetchUsageResponse(access, accountId);
|
|
241
|
+
if (status === 401 || status === 403) {
|
|
242
|
+
const refreshed = await refreshAccessToken(auth, loaded.path);
|
|
243
|
+
if (refreshed) {
|
|
244
|
+
access = refreshed.accessToken;
|
|
245
|
+
({ status, body, headers } = await fetchUsageResponse(access, accountId));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (status < 200 || status >= 300) {
|
|
249
|
+
throw new CodexMeterError(
|
|
250
|
+
`Codex usage returned HTTP ${status}`,
|
|
251
|
+
status === 401 || status === 403 ? 'no_auth' : 'http',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const windows = parseCodexWhamUsage(body, headers);
|
|
256
|
+
return {
|
|
257
|
+
captured_at: new Date().toISOString(),
|
|
258
|
+
pool: 'codex',
|
|
259
|
+
five_hour: windows.five_hour,
|
|
260
|
+
seven_day: windows.seven_day,
|
|
261
|
+
monthly: null,
|
|
262
|
+
plan_type: windows.plan_type,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// bullswarm command-code meter — Command Code alpha billing endpoints
|
|
2
|
+
// (the same ones the CLI's /usage overlay calls). Monthly credits plus
|
|
3
|
+
// 5-hour and weekly rate-limit windows.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_API_BASE = 'https://api.commandcode.ai';
|
|
10
|
+
const CREDITS_PATH = '/alpha/billing/credits';
|
|
11
|
+
const SUBSCRIPTIONS_PATH = '/alpha/billing/subscriptions';
|
|
12
|
+
|
|
13
|
+
const PLAN_NAMES = {
|
|
14
|
+
'individual-go': 'Go',
|
|
15
|
+
'individual-goat': 'GOAT',
|
|
16
|
+
'individual-pro': 'Pro',
|
|
17
|
+
'individual-pro-v1': 'Pro',
|
|
18
|
+
'individual-provider': 'Provider',
|
|
19
|
+
'individual-max': 'Max',
|
|
20
|
+
'individual-ultra': 'Ultra',
|
|
21
|
+
'teams-pro': 'Teams Pro',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const PLAN_CREDITS = {
|
|
25
|
+
'individual-go': 10,
|
|
26
|
+
'individual-goat': 70,
|
|
27
|
+
'individual-pro': 30,
|
|
28
|
+
'individual-pro-v1': 80,
|
|
29
|
+
'individual-provider': 15,
|
|
30
|
+
'individual-max': 150,
|
|
31
|
+
'individual-ultra': 300,
|
|
32
|
+
'teams-pro': 40,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class CommandCodeMeterError extends Error {
|
|
36
|
+
constructor(message, code) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.code = code; // no_auth | http | parse | network
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function authPath() {
|
|
43
|
+
const home =
|
|
44
|
+
process.env.COMMANDCODE_HOME?.trim() || path.join(os.homedir(), '.commandcode');
|
|
45
|
+
return path.join(home, 'auth.json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadApiKey() {
|
|
49
|
+
const p = authPath();
|
|
50
|
+
if (!existsSync(p)) {
|
|
51
|
+
throw new CommandCodeMeterError('Command Code not logged in. Run `cmd login`.', 'no_auth');
|
|
52
|
+
}
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(readFileSync(p, 'utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
throw new CommandCodeMeterError('Command Code auth.json unreadable.', 'no_auth');
|
|
58
|
+
}
|
|
59
|
+
const key = typeof raw.apiKey === 'string' ? raw.apiKey.trim() : '';
|
|
60
|
+
if (!key) {
|
|
61
|
+
throw new CommandCodeMeterError('Command Code auth has no API key.', 'no_auth');
|
|
62
|
+
}
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- pure decoders ---------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export function parseCommandCodeCredits(body) {
|
|
69
|
+
if (!body || typeof body !== 'object') {
|
|
70
|
+
throw new CommandCodeMeterError('credits response missing body', 'parse');
|
|
71
|
+
}
|
|
72
|
+
const credits = body.credits;
|
|
73
|
+
if (!credits || typeof credits !== 'object') {
|
|
74
|
+
throw new CommandCodeMeterError('credits response missing credits object', 'parse');
|
|
75
|
+
}
|
|
76
|
+
const asCredit = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
77
|
+
return {
|
|
78
|
+
remaining: asCredit(credits.monthlyCredits),
|
|
79
|
+
purchased: asCredit(credits.purchasedCredits) ?? 0,
|
|
80
|
+
free: asCredit(credits.freeCredits) ?? 0,
|
|
81
|
+
planId: typeof credits.planId === 'string' && credits.planId.trim() ? credits.planId.trim() : null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseCommandCodeWindow(raw) {
|
|
86
|
+
if (!raw || typeof raw !== 'object') return { utilization: null, resets_at: null };
|
|
87
|
+
const used = Number(raw.used);
|
|
88
|
+
const cap = Number(raw.cap);
|
|
89
|
+
let utilization = null;
|
|
90
|
+
if (Number.isFinite(used) && Number.isFinite(cap) && cap > 0) {
|
|
91
|
+
utilization = Math.min(100, Math.max(0, (used / cap) * 100));
|
|
92
|
+
}
|
|
93
|
+
let resets_at = null;
|
|
94
|
+
const resetAt = Number(raw.resetAt);
|
|
95
|
+
if (Number.isFinite(resetAt) && resetAt > 0) {
|
|
96
|
+
const iso = new Date(resetAt).toISOString();
|
|
97
|
+
resets_at = Number.isNaN(Date.parse(iso)) ? null : iso;
|
|
98
|
+
}
|
|
99
|
+
return { utilization, resets_at };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseCommandCodeWindows(body) {
|
|
103
|
+
const empty = { utilization: null, resets_at: null };
|
|
104
|
+
const limits = body?.windowLimits;
|
|
105
|
+
if (!limits || typeof limits !== 'object') return { five_hour: empty, seven_day: empty };
|
|
106
|
+
return {
|
|
107
|
+
five_hour: parseCommandCodeWindow(limits.fiveHour),
|
|
108
|
+
seven_day: parseCommandCodeWindow(limits.weekly),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function planMonthlyCredits(planId) {
|
|
113
|
+
if (typeof planId !== 'string' || !planId.trim()) return null;
|
|
114
|
+
const key = planId.trim().toLowerCase().replace(/_/g, '-');
|
|
115
|
+
if (PLAN_CREDITS[key] !== undefined) return PLAN_CREDITS[key];
|
|
116
|
+
const match = Object.keys(PLAN_NAMES)
|
|
117
|
+
.sort((a, b) => b.length - a.length)
|
|
118
|
+
.find((p) => key.startsWith(p));
|
|
119
|
+
return match ? PLAN_CREDITS[match] ?? null : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseCommandCodeSubscription(body) {
|
|
123
|
+
const data = body?.data;
|
|
124
|
+
const planId = typeof data?.planId === 'string' && data.planId.trim() ? data.planId.trim() : null;
|
|
125
|
+
const periodEnd = data?.currentPeriodEnd;
|
|
126
|
+
return {
|
|
127
|
+
planId,
|
|
128
|
+
plan_type: planId ? (PLAN_NAMES[planId.toLowerCase()] ?? planId) : null,
|
|
129
|
+
currentPeriodEnd:
|
|
130
|
+
typeof periodEnd === 'string' && Number.isFinite(Date.parse(periodEnd))
|
|
131
|
+
? new Date(periodEnd).toISOString()
|
|
132
|
+
: null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Monthly window from remaining credits + plan allocation. */
|
|
137
|
+
export function computeMonthly({ credits, subscription }) {
|
|
138
|
+
const planId = subscription.planId ?? credits.planId;
|
|
139
|
+
const limit = planMonthlyCredits(planId);
|
|
140
|
+
const remaining = credits.remaining;
|
|
141
|
+
let used = null;
|
|
142
|
+
let utilization = null;
|
|
143
|
+
if (limit != null && limit > 0 && remaining != null) {
|
|
144
|
+
used = Math.round(Math.max(0, limit - remaining) * 100) / 100;
|
|
145
|
+
utilization = Math.min(100, Math.max(0, (used / limit) * 100));
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
monthly: { utilization, resets_at: subscription.currentPeriodEnd },
|
|
149
|
+
monthly_quota: {
|
|
150
|
+
used,
|
|
151
|
+
limit,
|
|
152
|
+
remaining: remaining == null ? null : Math.round(Math.max(0, remaining) * 100) / 100,
|
|
153
|
+
unit: 'credits',
|
|
154
|
+
},
|
|
155
|
+
plan_type: subscription.plan_type,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- live fetch -----------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
async function fetchJson(url, key) {
|
|
162
|
+
let res;
|
|
163
|
+
try {
|
|
164
|
+
res = await fetch(url, {
|
|
165
|
+
headers: {
|
|
166
|
+
Authorization: `Bearer ${key}`,
|
|
167
|
+
Accept: 'application/json',
|
|
168
|
+
'User-Agent': 'bullswarm',
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
throw new CommandCodeMeterError(`Network error reaching Command Code billing: ${err.message}`, 'network');
|
|
173
|
+
}
|
|
174
|
+
let body = null;
|
|
175
|
+
try {
|
|
176
|
+
body = await res.json();
|
|
177
|
+
} catch {
|
|
178
|
+
/* leave null */
|
|
179
|
+
}
|
|
180
|
+
return { status: res.status, body };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function fetchCommandCodeUsage() {
|
|
184
|
+
const key = loadApiKey();
|
|
185
|
+
const base = (process.env.COMMANDCODE_API_URL?.trim() || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
186
|
+
|
|
187
|
+
const creditsRes = await fetchJson(`${base}${CREDITS_PATH}`, key);
|
|
188
|
+
if (creditsRes.status === 401 || creditsRes.status === 403) {
|
|
189
|
+
throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'no_auth');
|
|
190
|
+
}
|
|
191
|
+
if (creditsRes.status < 200 || creditsRes.status >= 300) {
|
|
192
|
+
throw new CommandCodeMeterError(`Command Code credits HTTP ${creditsRes.status}`, 'http');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const credits = parseCommandCodeCredits(creditsRes.body);
|
|
196
|
+
const windows = parseCommandCodeWindows(creditsRes.body);
|
|
197
|
+
|
|
198
|
+
let subscription = { planId: null, plan_type: null, currentPeriodEnd: null };
|
|
199
|
+
try {
|
|
200
|
+
const subRes = await fetchJson(`${base}${SUBSCRIPTIONS_PATH}`, key);
|
|
201
|
+
if (subRes.status >= 200 && subRes.status < 300) {
|
|
202
|
+
subscription = parseCommandCodeSubscription(subRes.body);
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
/* best-effort */
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const monthly = computeMonthly({ credits, subscription });
|
|
209
|
+
return {
|
|
210
|
+
captured_at: new Date().toISOString(),
|
|
211
|
+
pool: 'command-code',
|
|
212
|
+
five_hour: windows.five_hour,
|
|
213
|
+
seven_day: windows.seven_day,
|
|
214
|
+
monthly: monthly.monthly,
|
|
215
|
+
monthly_quota: monthly.monthly_quota,
|
|
216
|
+
plan_type: monthly.plan_type,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// bullswarm meters — live subscription usage per pool.
|
|
2
|
+
//
|
|
3
|
+
// Doctrine:
|
|
4
|
+
// M1. Numbers come from the PROVIDER, never from session logs or
|
|
5
|
+
// declarations when a reader exists. Declared meters are the last
|
|
6
|
+
// resort and are labeled as such.
|
|
7
|
+
// M2. elapsed% derives from the provider's resets_at minus the window
|
|
8
|
+
// length — never from a locally assumed window start.
|
|
9
|
+
// M3. Weekly/monthly windows pace routing; 5h windows are burst gates
|
|
10
|
+
// only (block dispatch near exhaustion, never pace by them).
|
|
11
|
+
// M4. Readers fail closed: an unreadable response is an error, not a
|
|
12
|
+
// zero. A stale cached reading is shown with its age.
|
|
13
|
+
// M5. Auth tokens are read from each CLI's native store; refresh
|
|
14
|
+
// write-back is best-effort so the CLI keeps working.
|
|
15
|
+
|
|
16
|
+
export const WINDOW_MS = {
|
|
17
|
+
'5h': 5 * 3600_000,
|
|
18
|
+
weekly: 7 * 24 * 3600_000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Compute pace for one window from a provider reading. */
|
|
22
|
+
export function windowPace({ usedPct, resetsAtMs, windowMs, nowMs = Date.now() }) {
|
|
23
|
+
if (![usedPct, resetsAtMs, windowMs].every(Number.isFinite) || windowMs <= 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const startMs = resetsAtMs - windowMs;
|
|
27
|
+
const elapsedPct = Math.max(0, Math.min(100, ((nowMs - startMs) / windowMs) * 100));
|
|
28
|
+
const used = Math.max(0, Math.min(100, usedPct));
|
|
29
|
+
return {
|
|
30
|
+
usedPct: Math.round(used * 10) / 10,
|
|
31
|
+
elapsedPct: Math.round(elapsedPct * 10) / 10,
|
|
32
|
+
// surplus = elapsed − used; higher = more quota expiring unspent.
|
|
33
|
+
surplus: Math.round((elapsedPct - used) * 10) / 10,
|
|
34
|
+
resetsAt: new Date(resetsAtMs).toISOString(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pace a snapshot per doctrine M3:
|
|
40
|
+
* - pacing window = weekly ?? monthly ?? none (never 5h)
|
|
41
|
+
* - burst gate = 5h utilization >= BURST_BLOCK_PCT blocks dispatch
|
|
42
|
+
*/
|
|
43
|
+
export const BURST_BLOCK_PCT = 90;
|
|
44
|
+
|
|
45
|
+
export function paceSnapshot(snapshot, nowMs = Date.now()) {
|
|
46
|
+
if (!snapshot) return { pacing: null, burstGate: false, windows: {} };
|
|
47
|
+
|
|
48
|
+
const windows = {};
|
|
49
|
+
for (const kind of ['five_hour', 'seven_day', 'monthly']) {
|
|
50
|
+
const w = snapshot[kind];
|
|
51
|
+
if (!w || w.utilization == null) continue;
|
|
52
|
+
const resetsAtMs = w.resets_at ? Date.parse(w.resets_at) : NaN;
|
|
53
|
+
const windowMs =
|
|
54
|
+
kind === 'five_hour' ? WINDOW_MS['5h']
|
|
55
|
+
: kind === 'monthly' ? monthlyWindowMs(resetsAtMs)
|
|
56
|
+
: WINDOW_MS.weekly;
|
|
57
|
+
windows[kind] = windowPace({
|
|
58
|
+
usedPct: w.utilization,
|
|
59
|
+
resetsAtMs,
|
|
60
|
+
windowMs,
|
|
61
|
+
nowMs,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pacing = windows.seven_day ?? windows.monthly ?? null;
|
|
66
|
+
const fiveHourUsed = snapshot.five_hour?.utilization;
|
|
67
|
+
const burstGate =
|
|
68
|
+
Number.isFinite(fiveHourUsed) && fiveHourUsed >= BURST_BLOCK_PCT;
|
|
69
|
+
|
|
70
|
+
return { pacing, burstGate, windows };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** UTC calendar month ending at resetsAt (Copilot/cmd period-end semantics). */
|
|
74
|
+
export function monthlyWindowMs(resetsAtMs) {
|
|
75
|
+
if (!Number.isFinite(resetsAtMs)) return NaN;
|
|
76
|
+
const reset = new Date(resetsAtMs);
|
|
77
|
+
const start = new Date(reset);
|
|
78
|
+
start.setUTCMonth(start.getUTCMonth() - 1);
|
|
79
|
+
return Math.max(3600_000, reset.getTime() - start.getTime());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- snapshot cache ---------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
85
|
+
import { join } from 'node:path';
|
|
86
|
+
|
|
87
|
+
/** How old a cached reading may be before we re-poll (fleetlens cadence). */
|
|
88
|
+
export const FRESH_MS = 5 * 60_000;
|
|
89
|
+
/** Beyond this age the reading is labeled stale in output. */
|
|
90
|
+
export const STALE_MS = 60 * 60_000;
|
|
91
|
+
|
|
92
|
+
export class MeterCache {
|
|
93
|
+
constructor(dir) {
|
|
94
|
+
this.dir = dir;
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#path(pool) {
|
|
99
|
+
return join(this.dir, `${pool}.json`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get(pool) {
|
|
103
|
+
const p = this.#path(pool);
|
|
104
|
+
if (!existsSync(p)) return null;
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
put(pool, snapshot) {
|
|
113
|
+
writeFileSync(this.#path(pool), `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fresh reading or null. A reading is fresh if its captured_at is within
|
|
118
|
+
* FRESH_MS of now — otherwise callers should re-poll (and fall back to
|
|
119
|
+
* showing the stale value with its age on failure).
|
|
120
|
+
*/
|
|
121
|
+
fresh(pool, nowMs = Date.now()) {
|
|
122
|
+
const s = this.get(pool);
|
|
123
|
+
if (!s?.captured_at) return null;
|
|
124
|
+
const ms = Date.parse(s.captured_at);
|
|
125
|
+
if (!Number.isFinite(ms)) return null;
|
|
126
|
+
return nowMs - ms <= FRESH_MS ? s : null;
|
|
127
|
+
}
|
|
128
|
+
}
|