@askalf/dario 3.4.5 → 3.5.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/README.md +256 -464
- package/dist/accounts.d.ts +23 -0
- package/dist/accounts.js +253 -0
- package/dist/analytics.d.ts +99 -0
- package/dist/analytics.js +198 -0
- package/dist/cli.js +113 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/pool.d.ts +68 -0
- package/dist/pool.js +212 -0
- package/dist/proxy.js +142 -10
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface AccountCredentials {
|
|
2
|
+
alias: string;
|
|
3
|
+
accessToken: string;
|
|
4
|
+
refreshToken: string;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
scopes: string[];
|
|
7
|
+
deviceId: string;
|
|
8
|
+
accountUuid: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function listAccountAliases(): Promise<string[]>;
|
|
11
|
+
export declare function loadAccount(alias: string): Promise<AccountCredentials | null>;
|
|
12
|
+
export declare function loadAllAccounts(): Promise<AccountCredentials[]>;
|
|
13
|
+
export declare function saveAccount(creds: AccountCredentials): Promise<void>;
|
|
14
|
+
export declare function removeAccount(alias: string): Promise<boolean>;
|
|
15
|
+
/** Refresh an account's OAuth token using dario's auto-detected CC OAuth config. */
|
|
16
|
+
export declare function refreshAccountToken(creds: AccountCredentials): Promise<AccountCredentials>;
|
|
17
|
+
/**
|
|
18
|
+
* Interactive OAuth flow that adds a new account to the pool. Uses dario's
|
|
19
|
+
* auto-detected CC OAuth config (same scanner the single-account path uses).
|
|
20
|
+
* Saves to `~/.dario/accounts/<alias>.json` on success.
|
|
21
|
+
*/
|
|
22
|
+
export declare function addAccountViaOAuth(alias: string): Promise<AccountCredentials>;
|
|
23
|
+
export declare function getAccountsDir(): string;
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-account credential storage.
|
|
3
|
+
*
|
|
4
|
+
* Accounts live at `~/.dario/accounts/<alias>.json`. Single-account dario
|
|
5
|
+
* still uses `~/.dario/credentials.json` and does not touch this module.
|
|
6
|
+
* When `~/.dario/accounts/` contains 2+ files the proxy activates pool mode
|
|
7
|
+
* (see pool.ts). Each account has its own independent OAuth lifecycle and
|
|
8
|
+
* can refresh without affecting the others.
|
|
9
|
+
*
|
|
10
|
+
* OAuth config (client_id, scopes, authorize URL, token URL) comes from
|
|
11
|
+
* dario's cc-oauth-detect scanner — the same source the single-account
|
|
12
|
+
* path already uses. No hardcoded client IDs here.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, writeFile, mkdir, readdir, unlink, rename } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|
18
|
+
import { createServer } from 'node:http';
|
|
19
|
+
import { detectCCOAuthConfig } from './cc-oauth-detect.js';
|
|
20
|
+
const DARIO_DIR = join(homedir(), '.dario');
|
|
21
|
+
const ACCOUNTS_DIR = join(DARIO_DIR, 'accounts');
|
|
22
|
+
async function ensureDir() {
|
|
23
|
+
await mkdir(ACCOUNTS_DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
}
|
|
25
|
+
export async function listAccountAliases() {
|
|
26
|
+
try {
|
|
27
|
+
await ensureDir();
|
|
28
|
+
const entries = await readdir(ACCOUNTS_DIR);
|
|
29
|
+
return entries.filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function loadAccount(alias) {
|
|
36
|
+
const path = join(ACCOUNTS_DIR, `${alias}.json`);
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(path, 'utf-8');
|
|
39
|
+
return JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function loadAllAccounts() {
|
|
46
|
+
const aliases = await listAccountAliases();
|
|
47
|
+
const loaded = await Promise.all(aliases.map(a => loadAccount(a)));
|
|
48
|
+
return loaded.filter((a) => a !== null);
|
|
49
|
+
}
|
|
50
|
+
export async function saveAccount(creds) {
|
|
51
|
+
await ensureDir();
|
|
52
|
+
const path = join(ACCOUNTS_DIR, `${creds.alias}.json`);
|
|
53
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
|
|
54
|
+
await writeFile(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
55
|
+
try {
|
|
56
|
+
await rename(tmp, path);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Windows can fail renames on busy files — fall back to direct write
|
|
60
|
+
await writeFile(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
61
|
+
try {
|
|
62
|
+
await unlink(tmp);
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function removeAccount(alias) {
|
|
68
|
+
const path = join(ACCOUNTS_DIR, `${alias}.json`);
|
|
69
|
+
try {
|
|
70
|
+
await unlink(path);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Detect deviceId + accountUuid from an installed Claude Code. */
|
|
78
|
+
async function detectClaudeIdentity() {
|
|
79
|
+
const paths = [
|
|
80
|
+
join(homedir(), '.claude', '.claude.json'),
|
|
81
|
+
join(homedir(), '.claude.json'),
|
|
82
|
+
];
|
|
83
|
+
for (const p of paths) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = await readFile(p, 'utf-8');
|
|
86
|
+
const data = JSON.parse(raw);
|
|
87
|
+
const deviceId = data.userID || data.installId || data.deviceId || '';
|
|
88
|
+
const accountUuid = data.oauthAccount?.accountUuid || data.accountUuid || '';
|
|
89
|
+
if (deviceId || accountUuid) {
|
|
90
|
+
return { deviceId, accountUuid };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* try next */ }
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
/** Refresh an account's OAuth token using dario's auto-detected CC OAuth config. */
|
|
98
|
+
export async function refreshAccountToken(creds) {
|
|
99
|
+
const cfg = await detectCCOAuthConfig();
|
|
100
|
+
const res = await fetch(cfg.tokenUrl, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
103
|
+
body: new URLSearchParams({
|
|
104
|
+
grant_type: 'refresh_token',
|
|
105
|
+
refresh_token: creds.refreshToken,
|
|
106
|
+
client_id: cfg.clientId,
|
|
107
|
+
}).toString(),
|
|
108
|
+
signal: AbortSignal.timeout(15_000),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const errBody = await res.text().catch(() => '');
|
|
112
|
+
throw new Error(`Refresh failed for ${creds.alias} (${res.status}): ${errBody.slice(0, 200)}`);
|
|
113
|
+
}
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
const updated = {
|
|
116
|
+
...creds,
|
|
117
|
+
accessToken: data.access_token,
|
|
118
|
+
refreshToken: data.refresh_token,
|
|
119
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
120
|
+
};
|
|
121
|
+
await saveAccount(updated);
|
|
122
|
+
return updated;
|
|
123
|
+
}
|
|
124
|
+
// ── PKCE OAuth flow for adding a new account ────────────────────────────
|
|
125
|
+
function base64url(buf) {
|
|
126
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
127
|
+
}
|
|
128
|
+
function generatePKCE() {
|
|
129
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
130
|
+
const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest());
|
|
131
|
+
return { codeVerifier, codeChallenge };
|
|
132
|
+
}
|
|
133
|
+
function openBrowser(url) {
|
|
134
|
+
const { exec } = require('node:child_process');
|
|
135
|
+
const cmd = process.platform === 'win32' ? `start "" "${url}"`
|
|
136
|
+
: process.platform === 'darwin' ? `open "${url}"`
|
|
137
|
+
: `xdg-open "${url}"`;
|
|
138
|
+
exec(cmd, () => { });
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Interactive OAuth flow that adds a new account to the pool. Uses dario's
|
|
142
|
+
* auto-detected CC OAuth config (same scanner the single-account path uses).
|
|
143
|
+
* Saves to `~/.dario/accounts/<alias>.json` on success.
|
|
144
|
+
*/
|
|
145
|
+
export async function addAccountViaOAuth(alias) {
|
|
146
|
+
const cfg = await detectCCOAuthConfig();
|
|
147
|
+
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
148
|
+
const state = base64url(randomBytes(16));
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
let port = 0;
|
|
151
|
+
const server = createServer(async (req, res) => {
|
|
152
|
+
try {
|
|
153
|
+
const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
|
154
|
+
if (url.pathname !== '/callback') {
|
|
155
|
+
res.writeHead(404);
|
|
156
|
+
res.end();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const code = url.searchParams.get('code');
|
|
160
|
+
const returnedState = url.searchParams.get('state');
|
|
161
|
+
if (!code) {
|
|
162
|
+
res.writeHead(400);
|
|
163
|
+
res.end('No authorization code received');
|
|
164
|
+
server.close();
|
|
165
|
+
reject(new Error('No authorization code received'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (returnedState !== state) {
|
|
169
|
+
res.writeHead(400);
|
|
170
|
+
res.end('Invalid state parameter');
|
|
171
|
+
server.close();
|
|
172
|
+
reject(new Error('OAuth state mismatch — possible CSRF'));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
res.writeHead(302, {
|
|
176
|
+
Location: 'https://platform.claude.com/oauth/code/success?app=claude-code',
|
|
177
|
+
});
|
|
178
|
+
res.end();
|
|
179
|
+
server.close();
|
|
180
|
+
// Exchange code for tokens
|
|
181
|
+
const tokenRes = await fetch(cfg.tokenUrl, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
grant_type: 'authorization_code',
|
|
186
|
+
client_id: cfg.clientId,
|
|
187
|
+
code,
|
|
188
|
+
redirect_uri: `http://localhost:${port}/callback`,
|
|
189
|
+
code_verifier: codeVerifier,
|
|
190
|
+
state,
|
|
191
|
+
}),
|
|
192
|
+
signal: AbortSignal.timeout(30_000),
|
|
193
|
+
});
|
|
194
|
+
if (!tokenRes.ok) {
|
|
195
|
+
const body = await tokenRes.text().catch(() => '');
|
|
196
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${body.slice(0, 200)}`);
|
|
197
|
+
}
|
|
198
|
+
const tokens = await tokenRes.json();
|
|
199
|
+
// Prefer CC identity if installed; otherwise generate fresh IDs.
|
|
200
|
+
const identity = (await detectClaudeIdentity()) ?? {
|
|
201
|
+
deviceId: randomUUID(),
|
|
202
|
+
accountUuid: randomUUID(),
|
|
203
|
+
};
|
|
204
|
+
const creds = {
|
|
205
|
+
alias,
|
|
206
|
+
accessToken: tokens.access_token,
|
|
207
|
+
refreshToken: tokens.refresh_token,
|
|
208
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
209
|
+
scopes: tokens.scope?.split(' ') ?? cfg.scopes.split(' '),
|
|
210
|
+
deviceId: identity.deviceId,
|
|
211
|
+
accountUuid: identity.accountUuid,
|
|
212
|
+
};
|
|
213
|
+
await saveAccount(creds);
|
|
214
|
+
resolve(creds);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
server.close();
|
|
218
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
server.listen(0, 'localhost', () => {
|
|
222
|
+
const addr = server.address();
|
|
223
|
+
port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
224
|
+
const params = new URLSearchParams({
|
|
225
|
+
code: 'true',
|
|
226
|
+
client_id: cfg.clientId,
|
|
227
|
+
response_type: 'code',
|
|
228
|
+
redirect_uri: `http://localhost:${port}/callback`,
|
|
229
|
+
scope: cfg.scopes,
|
|
230
|
+
code_challenge: codeChallenge,
|
|
231
|
+
code_challenge_method: 'S256',
|
|
232
|
+
state,
|
|
233
|
+
});
|
|
234
|
+
const authUrl = `${cfg.authorizeUrl}?${params.toString()}`;
|
|
235
|
+
console.log(` Opening browser to add account "${alias}"...`);
|
|
236
|
+
console.log(` If the browser didn't open, visit:`);
|
|
237
|
+
console.log(` ${authUrl}`);
|
|
238
|
+
console.log();
|
|
239
|
+
openBrowser(authUrl);
|
|
240
|
+
});
|
|
241
|
+
server.on('error', (err) => {
|
|
242
|
+
reject(new Error(`Failed to start OAuth callback server: ${err.message}`));
|
|
243
|
+
});
|
|
244
|
+
const timeout = setTimeout(() => {
|
|
245
|
+
server.close();
|
|
246
|
+
reject(new Error('OAuth flow timed out after 5 minutes. Try `dario accounts add` again.'));
|
|
247
|
+
}, 300_000);
|
|
248
|
+
timeout.unref();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
export function getAccountsDir() {
|
|
252
|
+
return ACCOUNTS_DIR;
|
|
253
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token analytics — per-request billing tracking, utilization trends,
|
|
3
|
+
* window exhaustion predictions, cost estimation.
|
|
4
|
+
*
|
|
5
|
+
* In-memory rolling window; exposed via the /analytics endpoint when
|
|
6
|
+
* pool mode is active.
|
|
7
|
+
*/
|
|
8
|
+
export interface RequestRecord {
|
|
9
|
+
timestamp: number;
|
|
10
|
+
account: string;
|
|
11
|
+
model: string;
|
|
12
|
+
inputTokens: number;
|
|
13
|
+
outputTokens: number;
|
|
14
|
+
cacheReadTokens: number;
|
|
15
|
+
cacheCreateTokens: number;
|
|
16
|
+
thinkingTokens: number;
|
|
17
|
+
claim: string;
|
|
18
|
+
util5h: number;
|
|
19
|
+
util7d: number;
|
|
20
|
+
overageUtil: number;
|
|
21
|
+
latencyMs: number;
|
|
22
|
+
status: number;
|
|
23
|
+
isStream: boolean;
|
|
24
|
+
isOpenAI: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare class Analytics {
|
|
27
|
+
private records;
|
|
28
|
+
private maxRecords;
|
|
29
|
+
constructor(maxRecords?: number);
|
|
30
|
+
record(r: RequestRecord): void;
|
|
31
|
+
/** Parse usage from a non-streaming Anthropic response body. */
|
|
32
|
+
static parseUsage(body: Record<string, unknown>): {
|
|
33
|
+
inputTokens: number;
|
|
34
|
+
outputTokens: number;
|
|
35
|
+
cacheReadTokens: number;
|
|
36
|
+
cacheCreateTokens: number;
|
|
37
|
+
thinkingTokens: number;
|
|
38
|
+
model: string;
|
|
39
|
+
};
|
|
40
|
+
summary(windowMinutes?: number): AnalyticsSummary;
|
|
41
|
+
private computeStats;
|
|
42
|
+
private perAccountStats;
|
|
43
|
+
private perModelStats;
|
|
44
|
+
private utilizationTrend;
|
|
45
|
+
private predict;
|
|
46
|
+
}
|
|
47
|
+
interface PerAccountStat {
|
|
48
|
+
requests: number;
|
|
49
|
+
inputTokens: number;
|
|
50
|
+
outputTokens: number;
|
|
51
|
+
estimatedCost: number;
|
|
52
|
+
currentUtil5h: number;
|
|
53
|
+
currentUtil7d: number;
|
|
54
|
+
lastClaim: string;
|
|
55
|
+
}
|
|
56
|
+
interface PerModelStat {
|
|
57
|
+
requests: number;
|
|
58
|
+
avgInputTokens: number;
|
|
59
|
+
avgOutputTokens: number;
|
|
60
|
+
avgThinkingTokens: number;
|
|
61
|
+
estimatedCost: number;
|
|
62
|
+
}
|
|
63
|
+
export interface AnalyticsSummary {
|
|
64
|
+
window: {
|
|
65
|
+
minutes: number;
|
|
66
|
+
requests: number;
|
|
67
|
+
totalInputTokens: number;
|
|
68
|
+
totalOutputTokens: number;
|
|
69
|
+
totalThinkingTokens: number;
|
|
70
|
+
estimatedCost: number;
|
|
71
|
+
avgLatencyMs: number;
|
|
72
|
+
errorRate: number;
|
|
73
|
+
claimBreakdown: Record<string, number>;
|
|
74
|
+
};
|
|
75
|
+
allTime: {
|
|
76
|
+
requests: number;
|
|
77
|
+
totalInputTokens: number;
|
|
78
|
+
totalOutputTokens: number;
|
|
79
|
+
totalThinkingTokens: number;
|
|
80
|
+
estimatedCost: number;
|
|
81
|
+
avgLatencyMs: number;
|
|
82
|
+
errorRate: number;
|
|
83
|
+
claimBreakdown: Record<string, number>;
|
|
84
|
+
};
|
|
85
|
+
perAccount: Record<string, PerAccountStat>;
|
|
86
|
+
perModel: Record<string, PerModelStat>;
|
|
87
|
+
utilization: Array<{
|
|
88
|
+
timestamp: number;
|
|
89
|
+
avgUtil5h: number;
|
|
90
|
+
avgUtil7d: number;
|
|
91
|
+
requests: number;
|
|
92
|
+
}>;
|
|
93
|
+
predictions: {
|
|
94
|
+
estimatedExhaustionMinutes: number | null;
|
|
95
|
+
tokenBurnRate: number;
|
|
96
|
+
costBurnRate: number;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token analytics — per-request billing tracking, utilization trends,
|
|
3
|
+
* window exhaustion predictions, cost estimation.
|
|
4
|
+
*
|
|
5
|
+
* In-memory rolling window; exposed via the /analytics endpoint when
|
|
6
|
+
* pool mode is active.
|
|
7
|
+
*/
|
|
8
|
+
// Anthropic pricing (per 1M tokens, USD). Not authoritative — used for
|
|
9
|
+
// rough burn-rate display in the /analytics summary.
|
|
10
|
+
const PRICING = {
|
|
11
|
+
'claude-opus-4-6': { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
|
|
12
|
+
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
|
|
13
|
+
'claude-haiku-4-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheCreate: 1 },
|
|
14
|
+
};
|
|
15
|
+
function estimateCost(record) {
|
|
16
|
+
const p = PRICING[record.model] ?? PRICING['claude-sonnet-4-6'];
|
|
17
|
+
return ((record.inputTokens * p.input) +
|
|
18
|
+
(record.outputTokens * p.output) +
|
|
19
|
+
(record.cacheReadTokens * p.cacheRead) +
|
|
20
|
+
(record.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
|
|
21
|
+
}
|
|
22
|
+
export class Analytics {
|
|
23
|
+
records = [];
|
|
24
|
+
maxRecords;
|
|
25
|
+
constructor(maxRecords = 10_000) {
|
|
26
|
+
this.maxRecords = maxRecords;
|
|
27
|
+
}
|
|
28
|
+
record(r) {
|
|
29
|
+
this.records.push(r);
|
|
30
|
+
if (this.records.length > this.maxRecords) {
|
|
31
|
+
this.records = this.records.slice(-this.maxRecords);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Parse usage from a non-streaming Anthropic response body. */
|
|
35
|
+
static parseUsage(body) {
|
|
36
|
+
const u = body.usage;
|
|
37
|
+
const content = body.content;
|
|
38
|
+
const thinkingChars = content
|
|
39
|
+
?.filter(b => b.type === 'thinking')
|
|
40
|
+
.reduce((s, b) => s + (b.thinking?.length ?? 0), 0) ?? 0;
|
|
41
|
+
const thinkingTokens = Math.round(thinkingChars / 4);
|
|
42
|
+
return {
|
|
43
|
+
inputTokens: u?.input_tokens ?? 0,
|
|
44
|
+
outputTokens: u?.output_tokens ?? 0,
|
|
45
|
+
cacheReadTokens: u?.cache_read_input_tokens ?? 0,
|
|
46
|
+
cacheCreateTokens: u?.cache_creation_input_tokens ?? 0,
|
|
47
|
+
thinkingTokens,
|
|
48
|
+
model: body.model ?? 'unknown',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
summary(windowMinutes = 60) {
|
|
52
|
+
const cutoff = Date.now() - windowMinutes * 60_000;
|
|
53
|
+
const recent = this.records.filter(r => r.timestamp >= cutoff);
|
|
54
|
+
const allTime = this.records;
|
|
55
|
+
return {
|
|
56
|
+
window: {
|
|
57
|
+
minutes: windowMinutes,
|
|
58
|
+
requests: recent.length,
|
|
59
|
+
...this.computeStats(recent),
|
|
60
|
+
},
|
|
61
|
+
allTime: {
|
|
62
|
+
requests: allTime.length,
|
|
63
|
+
...this.computeStats(allTime),
|
|
64
|
+
},
|
|
65
|
+
perAccount: this.perAccountStats(recent),
|
|
66
|
+
perModel: this.perModelStats(recent),
|
|
67
|
+
utilization: this.utilizationTrend(recent),
|
|
68
|
+
predictions: this.predict(recent),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
computeStats(records) {
|
|
72
|
+
if (records.length === 0) {
|
|
73
|
+
return {
|
|
74
|
+
totalInputTokens: 0, totalOutputTokens: 0, totalThinkingTokens: 0,
|
|
75
|
+
estimatedCost: 0, avgLatencyMs: 0, errorRate: 0,
|
|
76
|
+
claimBreakdown: {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const totalInput = records.reduce((s, r) => s + r.inputTokens, 0);
|
|
80
|
+
const totalOutput = records.reduce((s, r) => s + r.outputTokens, 0);
|
|
81
|
+
const totalThinking = records.reduce((s, r) => s + r.thinkingTokens, 0);
|
|
82
|
+
const cost = records.reduce((s, r) => s + estimateCost(r), 0);
|
|
83
|
+
const avgLatency = records.reduce((s, r) => s + r.latencyMs, 0) / records.length;
|
|
84
|
+
const errors = records.filter(r => r.status >= 400).length;
|
|
85
|
+
const claims = {};
|
|
86
|
+
for (const r of records) {
|
|
87
|
+
claims[r.claim] = (claims[r.claim] ?? 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
totalInputTokens: totalInput,
|
|
91
|
+
totalOutputTokens: totalOutput,
|
|
92
|
+
totalThinkingTokens: totalThinking,
|
|
93
|
+
estimatedCost: Math.round(cost * 10000) / 10000,
|
|
94
|
+
avgLatencyMs: Math.round(avgLatency),
|
|
95
|
+
errorRate: Math.round((errors / records.length) * 10000) / 10000,
|
|
96
|
+
claimBreakdown: claims,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
perAccountStats(records) {
|
|
100
|
+
const grouped = {};
|
|
101
|
+
for (const r of records) {
|
|
102
|
+
(grouped[r.account] ??= []).push(r);
|
|
103
|
+
}
|
|
104
|
+
const result = {};
|
|
105
|
+
for (const [account, recs] of Object.entries(grouped)) {
|
|
106
|
+
const last = recs[recs.length - 1];
|
|
107
|
+
result[account] = {
|
|
108
|
+
requests: recs.length,
|
|
109
|
+
inputTokens: recs.reduce((s, r) => s + r.inputTokens, 0),
|
|
110
|
+
outputTokens: recs.reduce((s, r) => s + r.outputTokens, 0),
|
|
111
|
+
estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
|
|
112
|
+
currentUtil5h: last.util5h,
|
|
113
|
+
currentUtil7d: last.util7d,
|
|
114
|
+
lastClaim: last.claim,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
perModelStats(records) {
|
|
120
|
+
const grouped = {};
|
|
121
|
+
for (const r of records) {
|
|
122
|
+
(grouped[r.model] ??= []).push(r);
|
|
123
|
+
}
|
|
124
|
+
const result = {};
|
|
125
|
+
for (const [model, recs] of Object.entries(grouped)) {
|
|
126
|
+
result[model] = {
|
|
127
|
+
requests: recs.length,
|
|
128
|
+
avgInputTokens: Math.round(recs.reduce((s, r) => s + r.inputTokens, 0) / recs.length),
|
|
129
|
+
avgOutputTokens: Math.round(recs.reduce((s, r) => s + r.outputTokens, 0) / recs.length),
|
|
130
|
+
avgThinkingTokens: Math.round(recs.reduce((s, r) => s + r.thinkingTokens, 0) / recs.length),
|
|
131
|
+
estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
utilizationTrend(records) {
|
|
137
|
+
if (records.length === 0)
|
|
138
|
+
return [];
|
|
139
|
+
const bucketMs = 5 * 60_000;
|
|
140
|
+
const buckets = new Map();
|
|
141
|
+
for (const r of records) {
|
|
142
|
+
const key = Math.floor(r.timestamp / bucketMs) * bucketMs;
|
|
143
|
+
const existing = buckets.get(key);
|
|
144
|
+
if (existing) {
|
|
145
|
+
existing.push(r);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
buckets.set(key, [r]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...buckets.entries()]
|
|
152
|
+
.sort(([a], [b]) => a - b)
|
|
153
|
+
.map(([ts, recs]) => ({
|
|
154
|
+
timestamp: ts,
|
|
155
|
+
avgUtil5h: Math.round(recs.reduce((s, r) => s + r.util5h, 0) / recs.length * 100) / 100,
|
|
156
|
+
avgUtil7d: Math.round(recs.reduce((s, r) => s + r.util7d, 0) / recs.length * 100) / 100,
|
|
157
|
+
requests: recs.length,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
predict(records) {
|
|
161
|
+
if (records.length < 3) {
|
|
162
|
+
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
|
|
163
|
+
}
|
|
164
|
+
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
|
|
165
|
+
const first = sorted[0];
|
|
166
|
+
const last = sorted[sorted.length - 1];
|
|
167
|
+
const durationMin = (last.timestamp - first.timestamp) / 60_000;
|
|
168
|
+
if (durationMin < 1) {
|
|
169
|
+
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
|
|
170
|
+
}
|
|
171
|
+
const totalTokens = sorted.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
|
172
|
+
const totalCost = sorted.reduce((s, r) => s + estimateCost(r), 0);
|
|
173
|
+
const tokenBurnRate = totalTokens / durationMin;
|
|
174
|
+
const costBurnRate = (totalCost / durationMin) * 60;
|
|
175
|
+
const currentUtil = last.util5h;
|
|
176
|
+
if (currentUtil >= 0.95) {
|
|
177
|
+
return {
|
|
178
|
+
estimatedExhaustionMinutes: 0,
|
|
179
|
+
tokenBurnRate: Math.round(tokenBurnRate),
|
|
180
|
+
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const utilGrowthRate = (last.util5h - first.util5h) / durationMin;
|
|
184
|
+
if (utilGrowthRate <= 0) {
|
|
185
|
+
return {
|
|
186
|
+
estimatedExhaustionMinutes: null,
|
|
187
|
+
tokenBurnRate: Math.round(tokenBurnRate),
|
|
188
|
+
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const minutesToExhaustion = (1.0 - currentUtil) / utilGrowthRate;
|
|
192
|
+
return {
|
|
193
|
+
estimatedExhaustionMinutes: Math.round(minutesToExhaustion),
|
|
194
|
+
tokenBurnRate: Math.round(tokenBurnRate),
|
|
195
|
+
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|