@zhangferry-dev/tokendash 1.6.1 → 1.6.2
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 +146 -83
- package/dist/client/assets/index-Bw503sNp.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/daemon.cjs +3306 -0
- package/dist/daemon.cjs.map +7 -0
- package/dist/electron-server.cjs +1019 -28
- package/dist/electron-server.cjs.map +4 -4
- package/dist/server/ccusage.d.ts +7 -0
- package/dist/server/ccusage.js +69 -0
- package/dist/server/daemon.d.ts +12 -0
- package/dist/server/daemon.js +176 -0
- package/dist/server/index.js +22 -11
- package/dist/server/insightsCalculator.d.ts +15 -0
- package/dist/server/insightsCalculator.js +276 -0
- package/dist/server/quota/adapter.d.ts +47 -0
- package/dist/server/quota/adapter.js +41 -0
- package/dist/server/quota/adapters/claude.d.ts +2 -0
- package/dist/server/quota/adapters/claude.js +124 -0
- package/dist/server/quota/adapters/codex.d.ts +2 -0
- package/dist/server/quota/adapters/codex.js +188 -0
- package/dist/server/quota/adapters/glm.d.ts +2 -0
- package/dist/server/quota/adapters/glm.js +133 -0
- package/dist/server/quota/adapters/kimi.d.ts +2 -0
- package/dist/server/quota/adapters/kimi.js +184 -0
- package/dist/server/quota/adapters/minimax.d.ts +2 -0
- package/dist/server/quota/adapters/minimax.js +77 -0
- package/dist/server/quota/cache.d.ts +20 -0
- package/dist/server/quota/cache.js +44 -0
- package/dist/server/quota/credentialsFile.d.ts +13 -0
- package/dist/server/quota/credentialsFile.js +23 -0
- package/dist/server/quota/helpers.d.ts +39 -0
- package/dist/server/quota/helpers.js +93 -0
- package/dist/server/quota/index.d.ts +5 -0
- package/dist/server/quota/index.js +23 -0
- package/dist/server/quota/quotaService.d.ts +37 -0
- package/dist/server/quota/quotaService.js +141 -0
- package/dist/server/quota/schemas.d.ts +358 -0
- package/dist/server/quota/schemas.js +53 -0
- package/dist/server/quota/types.d.ts +65 -0
- package/dist/server/quota/types.js +10 -0
- package/dist/server/routes/api.js +15 -0
- package/dist/server/routes/insights.d.ts +2 -0
- package/dist/server/routes/insights.js +155 -0
- package/package.json +6 -10
- package/resources/entitlements.mac.plist +10 -0
- package/resources/icon-1024.png +0 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.png +0 -0
- package/resources/product_menu.png +0 -0
- package/resources/readme-hero.png +0 -0
- package/dist/client/assets/index-_yA9tOzZ.css +0 -1
- package/electron/main.cjs +0 -516
- package/electron/npmSync.cjs +0 -62
- package/electron/preload.cjs +0 -36
- package/electron/serverReuse.cjs +0 -59
- package/electron/trayBadge.cjs +0 -27
- package/electron/trayHelper +0 -0
- package/electron/trayHelper.swift +0 -152
- package/electron/updateService.cjs +0 -220
- package/electron-builder.yml +0 -20
- /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function runCcusage(args: string[], timeout?: number): Promise<string>;
|
|
2
|
+
export declare function ensureUsageToolsReady(): Promise<void>;
|
|
3
|
+
export declare function isClaudeCodeAvailable(): Promise<boolean>;
|
|
4
|
+
export declare function detectAvailableAgents(): Promise<{
|
|
5
|
+
claude: boolean;
|
|
6
|
+
codex: boolean;
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { isSessionsDirAccessible } from './codexParser.js';
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
function withJsonFlag(args, asJson) {
|
|
6
|
+
if (!asJson || args.includes('--json')) {
|
|
7
|
+
return args;
|
|
8
|
+
}
|
|
9
|
+
return [...args, '--json'];
|
|
10
|
+
}
|
|
11
|
+
async function runCommand(spec, timeout) {
|
|
12
|
+
const { stdout } = await execFileAsync(spec.command, spec.args, {
|
|
13
|
+
timeout,
|
|
14
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
15
|
+
});
|
|
16
|
+
return stdout;
|
|
17
|
+
}
|
|
18
|
+
function isMissingCommand(error) {
|
|
19
|
+
return typeof error === 'object'
|
|
20
|
+
&& error !== null
|
|
21
|
+
&& 'code' in error
|
|
22
|
+
&& error.code === 'ENOENT';
|
|
23
|
+
}
|
|
24
|
+
async function runCcusageCommand(args, timeout, asJson) {
|
|
25
|
+
const primary = {
|
|
26
|
+
command: 'ccusage',
|
|
27
|
+
args: withJsonFlag(args, asJson),
|
|
28
|
+
};
|
|
29
|
+
const fallback = {
|
|
30
|
+
command: 'npx',
|
|
31
|
+
args: ['--yes', 'ccusage@latest', ...withJsonFlag(args, asJson)],
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
return await runCommand(primary, timeout);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (isMissingCommand(error)) {
|
|
38
|
+
return runCommand(fallback, timeout);
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function runCcusage(args, timeout = 30_000) {
|
|
44
|
+
return runCcusageCommand(args, timeout, true);
|
|
45
|
+
}
|
|
46
|
+
export async function ensureUsageToolsReady() {
|
|
47
|
+
// Claude Code: check ccusage CLI
|
|
48
|
+
await runCcusageCommand(['--version'], 120_000, false);
|
|
49
|
+
// Codex: check local sessions directory (instant, no npm subprocess)
|
|
50
|
+
if (!isSessionsDirAccessible()) {
|
|
51
|
+
throw new Error('Codex sessions directory not found at ~/.codex/sessions/');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function isClaudeCodeAvailable() {
|
|
55
|
+
try {
|
|
56
|
+
await runCcusageCommand(['--version'], 120_000, false);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function detectAvailableAgents() {
|
|
64
|
+
const [claude, codex] = await Promise.all([
|
|
65
|
+
isClaudeCodeAvailable(),
|
|
66
|
+
Promise.resolve(isSessionsDirAccessible()),
|
|
67
|
+
]);
|
|
68
|
+
return { claude, codex };
|
|
69
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TokenDash Daemon — headless Node.js server for the Swift menu bar app.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node dist/daemon.cjs [--port <number>]
|
|
5
|
+
*
|
|
6
|
+
* The Swift app spawns this process and manages its lifecycle.
|
|
7
|
+
* Communication happens via:
|
|
8
|
+
* - ~/.tokendash/daemon.pid — PID file (for process management)
|
|
9
|
+
* - ~/.tokendash/daemon.port — actual listening port (for Swift discovery)
|
|
10
|
+
* - localhost HTTP API — all existing /api/* routes
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TokenDash Daemon — headless Node.js server for the Swift menu bar app.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node dist/daemon.cjs [--port <number>]
|
|
5
|
+
*
|
|
6
|
+
* The Swift app spawns this process and manages its lifecycle.
|
|
7
|
+
* Communication happens via:
|
|
8
|
+
* - ~/.tokendash/daemon.pid — PID file (for process management)
|
|
9
|
+
* - ~/.tokendash/daemon.port — actual listening port (for Swift discovery)
|
|
10
|
+
* - localhost HTTP API — all existing /api/* routes
|
|
11
|
+
*/
|
|
12
|
+
import { createApp } from './index.js';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Paths
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const DATA_DIR = join(homedir(), '.tokendash');
|
|
21
|
+
const PID_FILE = join(DATA_DIR, 'daemon.pid');
|
|
22
|
+
const PORT_FILE = join(DATA_DIR, 'daemon.port');
|
|
23
|
+
function ensureDataDir() {
|
|
24
|
+
if (!existsSync(DATA_DIR))
|
|
25
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
function writePidFile() {
|
|
28
|
+
ensureDataDir();
|
|
29
|
+
writeFileSync(PID_FILE, String(process.pid), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
function writePortFile(port) {
|
|
32
|
+
ensureDataDir();
|
|
33
|
+
writeFileSync(PORT_FILE, String(port), 'utf8');
|
|
34
|
+
}
|
|
35
|
+
function cleanupFiles() {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(PID_FILE))
|
|
38
|
+
unlinkSync(PID_FILE);
|
|
39
|
+
}
|
|
40
|
+
catch (_) { }
|
|
41
|
+
try {
|
|
42
|
+
if (existsSync(PORT_FILE))
|
|
43
|
+
unlinkSync(PORT_FILE);
|
|
44
|
+
}
|
|
45
|
+
catch (_) { }
|
|
46
|
+
}
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Port fallback
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
function resolvePort() {
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
for (let i = 0; i < args.length; i++) {
|
|
53
|
+
if (args[i] === '--port' && i + 1 < args.length) {
|
|
54
|
+
const v = parseInt(args[i + 1], 10);
|
|
55
|
+
if (Number.isInteger(v) && v > 0)
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const envPort = process.env.TOKENDASH_PORT ? parseInt(process.env.TOKENDASH_PORT, 10) : 0;
|
|
60
|
+
if (Number.isInteger(envPort) && envPort > 0)
|
|
61
|
+
return envPort;
|
|
62
|
+
return 3456;
|
|
63
|
+
}
|
|
64
|
+
function listen(app, port) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const server = app.listen(port);
|
|
67
|
+
const onListen = () => { cleanup(); resolve(server); };
|
|
68
|
+
const onError = (err) => { cleanup(); reject(err); };
|
|
69
|
+
const cleanup = () => { server.off('listening', onListen); server.off('error', onError); };
|
|
70
|
+
server.once('listening', onListen);
|
|
71
|
+
server.once('error', onError);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async function listenWithFallback(app, preferredPort) {
|
|
75
|
+
let port = preferredPort;
|
|
76
|
+
for (let attempt = 0; attempt < 20; attempt++, port++) {
|
|
77
|
+
try {
|
|
78
|
+
const server = await listen(app, port);
|
|
79
|
+
return { server, port };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const err = error;
|
|
83
|
+
if (err.code !== 'EADDRINUSE')
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`No available port starting from ${preferredPort}`);
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Stale daemon check
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
function killStaleDaemon() {
|
|
93
|
+
if (!existsSync(PID_FILE))
|
|
94
|
+
return false;
|
|
95
|
+
try {
|
|
96
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
97
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
98
|
+
return false;
|
|
99
|
+
// Send SIGTERM to stale process (throws if PID doesn't exist)
|
|
100
|
+
process.kill(pid, 0); // check if alive
|
|
101
|
+
// Process exists — try to kill it
|
|
102
|
+
process.kill(pid, 'SIGTERM');
|
|
103
|
+
// Give it a moment
|
|
104
|
+
const deadline = Date.now() + 2000;
|
|
105
|
+
while (Date.now() < deadline) {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(pid, 0);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Process doesn't exist — stale PID file
|
|
117
|
+
cleanupFiles();
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Main
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
async function main() {
|
|
125
|
+
// Kill any stale daemon
|
|
126
|
+
killStaleDaemon();
|
|
127
|
+
const preferredPort = resolvePort();
|
|
128
|
+
const distDir = join(import.meta.url.replace('file://', ''), '..');
|
|
129
|
+
const app = createApp(preferredPort, distDir);
|
|
130
|
+
const { server, port } = await listenWithFallback(app, preferredPort);
|
|
131
|
+
writePidFile();
|
|
132
|
+
writePortFile(port);
|
|
133
|
+
// Warm up cache: pre-parse JSONL for all agents so first Swift fetch is fast
|
|
134
|
+
try {
|
|
135
|
+
const agentsRes = await new Promise((resolve, reject) => {
|
|
136
|
+
http.get(`http://127.0.0.1:${port}/api/agents`, (res) => {
|
|
137
|
+
let body = '';
|
|
138
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
139
|
+
res.on('end', () => { try {
|
|
140
|
+
resolve(JSON.parse(body));
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
reject(e);
|
|
144
|
+
} });
|
|
145
|
+
}).on('error', reject);
|
|
146
|
+
});
|
|
147
|
+
const agents = agentsRes?.available ?? ['claude'];
|
|
148
|
+
await Promise.all(agents.map((agent) => new Promise((resolve) => {
|
|
149
|
+
http.get(`http://127.0.0.1:${port}/api/daily?agent=${agent}`, (res) => {
|
|
150
|
+
res.resume();
|
|
151
|
+
res.on('end', () => resolve());
|
|
152
|
+
}).on('error', () => resolve());
|
|
153
|
+
})));
|
|
154
|
+
}
|
|
155
|
+
catch (_) {
|
|
156
|
+
// Warm-up is best-effort; don't block startup
|
|
157
|
+
}
|
|
158
|
+
// Graceful shutdown
|
|
159
|
+
const shutdown = () => {
|
|
160
|
+
cleanupFiles();
|
|
161
|
+
server.close(() => process.exit(0));
|
|
162
|
+
// Force exit after 5s if server.close hangs
|
|
163
|
+
setTimeout(() => process.exit(0), 5000);
|
|
164
|
+
};
|
|
165
|
+
process.on('SIGTERM', shutdown);
|
|
166
|
+
process.on('SIGINT', shutdown);
|
|
167
|
+
process.on('uncaughtException', (err) => {
|
|
168
|
+
console.error('[tokendash-daemon] uncaught:', err);
|
|
169
|
+
shutdown();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
main().catch((err) => {
|
|
173
|
+
console.error('[tokendash-daemon] fatal:', err);
|
|
174
|
+
cleanupFiles();
|
|
175
|
+
process.exit(1);
|
|
176
|
+
});
|
package/dist/server/index.js
CHANGED
|
@@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { registerApiRoutes } from './routes/api.js';
|
|
6
6
|
import { detectAvailableAgents } from './agentDetection.js';
|
|
7
|
-
import open from 'open';
|
|
8
7
|
const CLI_USAGE = [
|
|
9
8
|
'Usage:',
|
|
10
9
|
' tokendash',
|
|
@@ -18,7 +17,7 @@ function getPackageVersion() {
|
|
|
18
17
|
const __dirname = dirname(__filename);
|
|
19
18
|
const packageJsonPaths = [
|
|
20
19
|
join(__dirname, '..', '..', 'package.json'), // dist/server/index.js
|
|
21
|
-
join(__dirname, '..', 'package.json'), //
|
|
20
|
+
join(__dirname, '..', 'package.json'), // bundled server entrypoint
|
|
22
21
|
];
|
|
23
22
|
for (const packageJsonPath of packageJsonPaths) {
|
|
24
23
|
if (!existsSync(packageJsonPath))
|
|
@@ -138,7 +137,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
|
|
|
138
137
|
// The CLI entrypoint runs from dist/server/index.js while the Vite assets are
|
|
139
138
|
// emitted to dist/client. Resolve the production asset base to dist instead
|
|
140
139
|
// of dist/server so / resolves to dist/client/index.html in installed npm
|
|
141
|
-
// packages.
|
|
140
|
+
// packages. The native app passes dist explicitly and is unaffected by this branch.
|
|
142
141
|
if (basename(moduleDir) === 'server') {
|
|
143
142
|
return { baseDir: resolve(dirname(moduleDir)), isProduction: true };
|
|
144
143
|
}
|
|
@@ -186,21 +185,29 @@ async function main() {
|
|
|
186
185
|
}
|
|
187
186
|
const version = getPackageVersion();
|
|
188
187
|
const preferredPort = resolvePort(args.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined));
|
|
189
|
-
// --tray mode: launch
|
|
188
|
+
// --tray mode: launch native Swift menu bar app
|
|
190
189
|
if (args.tray) {
|
|
191
190
|
if (process.platform !== 'darwin') {
|
|
192
191
|
console.error('Error: --tray is only supported on macOS.');
|
|
193
192
|
process.exit(1);
|
|
194
193
|
}
|
|
195
194
|
console.log(`Starting tokendash v${version} in tray mode...`);
|
|
196
|
-
// @ts-ignore -- electron is installed separately for tray mode
|
|
197
|
-
const { default: electronPath } = await import('electron');
|
|
198
195
|
const { spawn } = await import('node:child_process');
|
|
199
|
-
const
|
|
196
|
+
const { resolve } = await import('node:path');
|
|
197
|
+
const { existsSync } = await import('node:fs');
|
|
198
|
+
// Find Swift binary: check packaged app first, then dev build
|
|
199
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
200
|
+
const packagedPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
|
|
201
|
+
const devPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
|
|
202
|
+
const swiftBin = existsSync(packagedPath) ? packagedPath : devPath;
|
|
203
|
+
if (!existsSync(swiftBin)) {
|
|
204
|
+
console.error('Error: TokenDash Swift binary not found. Run "npm run build:swift" first.');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const child = spawn(swiftBin, [], {
|
|
200
208
|
env: {
|
|
201
209
|
...process.env,
|
|
202
210
|
TOKENDASH_PORT: String(preferredPort),
|
|
203
|
-
TOKENDASH_TRAY: '1',
|
|
204
211
|
},
|
|
205
212
|
stdio: 'inherit',
|
|
206
213
|
});
|
|
@@ -232,11 +239,15 @@ async function main() {
|
|
|
232
239
|
// Open browser if requested
|
|
233
240
|
if (shouldOpenBrowser) {
|
|
234
241
|
// Small delay to ensure server is ready
|
|
235
|
-
setTimeout(() => {
|
|
242
|
+
setTimeout(async () => {
|
|
236
243
|
console.log('Opening dashboard in your browser...');
|
|
237
|
-
|
|
244
|
+
try {
|
|
245
|
+
const { default: open } = await import('open');
|
|
246
|
+
await open(`http://localhost:${port}`);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
238
249
|
console.warn('Could not open browser:', err.message);
|
|
239
|
-
}
|
|
250
|
+
}
|
|
240
251
|
}, 100);
|
|
241
252
|
}
|
|
242
253
|
else {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DailyEntry, DriverMetric, InsightsResponse, ProjectsResponse } from '../shared/types.js';
|
|
2
|
+
export declare function cacheHitRate(cacheReadTokens: number, inputTokens: number): number;
|
|
3
|
+
export declare function outputInputRatio(outputTokens: number, inputTokens: number): number;
|
|
4
|
+
export interface BuildInsightsOptions {
|
|
5
|
+
agent: string;
|
|
6
|
+
project?: string;
|
|
7
|
+
timezone?: string;
|
|
8
|
+
daily: DailyEntry[];
|
|
9
|
+
projects?: ProjectsResponse;
|
|
10
|
+
topAgent?: DriverMetric;
|
|
11
|
+
partialAgents?: string[];
|
|
12
|
+
now?: Date;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildInsightsResponse(options: BuildInsightsOptions): InsightsResponse;
|
|
15
|
+
export declare function mergeDailyResponsesByDate(responses: DailyEntry[][]): DailyEntry[];
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const MIN_BASELINE_ACTIVE_DAYS = 3;
|
|
2
|
+
const BASELINE_ACTIVE_DAYS = 7;
|
|
3
|
+
const MAX_INSIGHTS = 5;
|
|
4
|
+
export function cacheHitRate(cacheReadTokens, inputTokens) {
|
|
5
|
+
const totalInput = cacheReadTokens + inputTokens;
|
|
6
|
+
if (totalInput === 0)
|
|
7
|
+
return 0;
|
|
8
|
+
return (cacheReadTokens / totalInput) * 100;
|
|
9
|
+
}
|
|
10
|
+
export function outputInputRatio(outputTokens, inputTokens) {
|
|
11
|
+
if (inputTokens === 0)
|
|
12
|
+
return 0;
|
|
13
|
+
return outputTokens / inputTokens;
|
|
14
|
+
}
|
|
15
|
+
function localTodayKey(now = new Date()) {
|
|
16
|
+
return [
|
|
17
|
+
now.getFullYear(),
|
|
18
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
19
|
+
String(now.getDate()).padStart(2, '0'),
|
|
20
|
+
].join('-');
|
|
21
|
+
}
|
|
22
|
+
function emptyDaily(date) {
|
|
23
|
+
return {
|
|
24
|
+
date,
|
|
25
|
+
inputTokens: 0,
|
|
26
|
+
outputTokens: 0,
|
|
27
|
+
cacheCreationTokens: 0,
|
|
28
|
+
cacheReadTokens: 0,
|
|
29
|
+
totalTokens: 0,
|
|
30
|
+
totalCost: 0,
|
|
31
|
+
modelsUsed: [],
|
|
32
|
+
modelBreakdowns: [],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function mergeEntries(date, entries) {
|
|
36
|
+
const merged = emptyDaily(date);
|
|
37
|
+
const models = new Map();
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
merged.inputTokens += entry.inputTokens;
|
|
40
|
+
merged.outputTokens += entry.outputTokens;
|
|
41
|
+
merged.cacheCreationTokens += entry.cacheCreationTokens;
|
|
42
|
+
merged.cacheReadTokens += entry.cacheReadTokens;
|
|
43
|
+
merged.totalTokens += entry.totalTokens;
|
|
44
|
+
merged.totalCost += entry.totalCost;
|
|
45
|
+
for (const model of entry.modelBreakdowns) {
|
|
46
|
+
const existing = models.get(model.modelName);
|
|
47
|
+
if (existing) {
|
|
48
|
+
existing.inputTokens += model.inputTokens;
|
|
49
|
+
existing.outputTokens += model.outputTokens;
|
|
50
|
+
existing.cacheCreationTokens += model.cacheCreationTokens;
|
|
51
|
+
existing.cacheReadTokens += model.cacheReadTokens;
|
|
52
|
+
existing.cost += model.cost;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
models.set(model.modelName, { ...model });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
merged.modelBreakdowns = [...models.values()];
|
|
60
|
+
merged.modelsUsed = [...new Set(merged.modelBreakdowns.map(model => model.modelName))];
|
|
61
|
+
return merged;
|
|
62
|
+
}
|
|
63
|
+
function activeBaselineEntries(daily, today) {
|
|
64
|
+
return daily
|
|
65
|
+
.filter(entry => entry.date < today && entry.totalTokens > 0)
|
|
66
|
+
.sort((a, b) => b.date.localeCompare(a.date))
|
|
67
|
+
.slice(0, BASELINE_ACTIVE_DAYS);
|
|
68
|
+
}
|
|
69
|
+
function average(values) {
|
|
70
|
+
if (values.length === 0)
|
|
71
|
+
return 0;
|
|
72
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
73
|
+
}
|
|
74
|
+
function baselineFrom(entries) {
|
|
75
|
+
return {
|
|
76
|
+
activeDays: entries.length,
|
|
77
|
+
avgTokens: average(entries.map(entry => entry.totalTokens)),
|
|
78
|
+
avgCost: average(entries.map(entry => entry.totalCost)),
|
|
79
|
+
avgCacheHitRate: average(entries.map(entry => cacheHitRate(entry.cacheReadTokens, entry.inputTokens))),
|
|
80
|
+
avgOutputInputRatio: average(entries.map(entry => outputInputRatio(entry.outputTokens, entry.inputTokens))),
|
|
81
|
+
insufficientData: entries.length < MIN_BASELINE_ACTIVE_DAYS,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function driverFromProject(today, projects) {
|
|
85
|
+
if (!projects)
|
|
86
|
+
return undefined;
|
|
87
|
+
const drivers = Object.entries(projects.projects)
|
|
88
|
+
.map(([name, entries]) => {
|
|
89
|
+
const todayEntry = mergeEntries(today, entries.filter(entry => entry.date === today));
|
|
90
|
+
return { name, tokens: todayEntry.totalTokens, cost: todayEntry.totalCost };
|
|
91
|
+
})
|
|
92
|
+
.filter(driver => driver.tokens > 0 || driver.cost > 0)
|
|
93
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
94
|
+
const totalTokens = drivers.reduce((sum, driver) => sum + driver.tokens, 0);
|
|
95
|
+
const top = drivers[0];
|
|
96
|
+
if (!top || totalTokens === 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
return { ...top, share: top.tokens / totalTokens };
|
|
99
|
+
}
|
|
100
|
+
function driverFromModel(todayEntry) {
|
|
101
|
+
const totalTokens = todayEntry.totalTokens;
|
|
102
|
+
if (todayEntry.modelBreakdowns.length === 0 || totalTokens === 0)
|
|
103
|
+
return undefined;
|
|
104
|
+
const drivers = todayEntry.modelBreakdowns
|
|
105
|
+
.map(model => ({
|
|
106
|
+
name: model.modelName,
|
|
107
|
+
tokens: model.inputTokens + model.outputTokens + model.cacheCreationTokens + model.cacheReadTokens,
|
|
108
|
+
cost: model.cost,
|
|
109
|
+
}))
|
|
110
|
+
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
|
|
111
|
+
const top = drivers[0];
|
|
112
|
+
if (!top)
|
|
113
|
+
return undefined;
|
|
114
|
+
const totalCost = todayEntry.totalCost;
|
|
115
|
+
const share = totalCost > 0 ? top.cost / totalCost : top.tokens / totalTokens;
|
|
116
|
+
return { ...top, share };
|
|
117
|
+
}
|
|
118
|
+
function deltaPercent(current, baseline) {
|
|
119
|
+
if (baseline <= 0)
|
|
120
|
+
return undefined;
|
|
121
|
+
return ((current - baseline) / baseline) * 100;
|
|
122
|
+
}
|
|
123
|
+
function addInsight(insights, insight) {
|
|
124
|
+
if (insight)
|
|
125
|
+
insights.push(insight);
|
|
126
|
+
}
|
|
127
|
+
function severityRank(severity) {
|
|
128
|
+
if (severity === 'critical')
|
|
129
|
+
return 0;
|
|
130
|
+
if (severity === 'warning')
|
|
131
|
+
return 1;
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
function sortInsights(insights) {
|
|
135
|
+
return insights
|
|
136
|
+
.sort((a, b) => {
|
|
137
|
+
const severity = severityRank(a.severity) - severityRank(b.severity);
|
|
138
|
+
if (severity !== 0)
|
|
139
|
+
return severity;
|
|
140
|
+
return Math.abs(b.deltaPercent ?? 0) - Math.abs(a.deltaPercent ?? 0);
|
|
141
|
+
})
|
|
142
|
+
.slice(0, MAX_INSIGHTS);
|
|
143
|
+
}
|
|
144
|
+
export function buildInsightsResponse(options) {
|
|
145
|
+
const today = localTodayKey(options.now);
|
|
146
|
+
const todayEntry = mergeEntries(today, options.daily.filter(entry => entry.date === today));
|
|
147
|
+
const baselineEntries = activeBaselineEntries(options.daily, today);
|
|
148
|
+
const baseline = baselineFrom(baselineEntries);
|
|
149
|
+
const todayCacheHitRate = cacheHitRate(todayEntry.cacheReadTokens, todayEntry.inputTokens);
|
|
150
|
+
const todayOutputInputRatio = outputInputRatio(todayEntry.outputTokens, todayEntry.inputTokens);
|
|
151
|
+
const topProject = options.project ? undefined : driverFromProject(today, options.projects);
|
|
152
|
+
const topModel = driverFromModel(todayEntry);
|
|
153
|
+
const topDrivers = {
|
|
154
|
+
...(topProject ? { project: topProject } : {}),
|
|
155
|
+
...(topModel ? { model: topModel } : {}),
|
|
156
|
+
...(options.topAgent ? { agent: options.topAgent } : {}),
|
|
157
|
+
};
|
|
158
|
+
const insights = [];
|
|
159
|
+
const tokenDelta = deltaPercent(todayEntry.totalTokens, baseline.avgTokens);
|
|
160
|
+
const costDelta = deltaPercent(todayEntry.totalCost, baseline.avgCost);
|
|
161
|
+
const ratioDelta = deltaPercent(todayOutputInputRatio, baseline.avgOutputInputRatio);
|
|
162
|
+
const isNewHigh = todayEntry.totalTokens > 0 && options.daily
|
|
163
|
+
.filter(entry => entry.date <= today)
|
|
164
|
+
.sort((a, b) => b.totalTokens - a.totalTokens)[0]?.date === today;
|
|
165
|
+
if (!baseline.insufficientData) {
|
|
166
|
+
addInsight(insights, todayEntry.totalTokens >= baseline.avgTokens * 2 && todayEntry.totalTokens - baseline.avgTokens >= 100_000 && {
|
|
167
|
+
id: 'token-spike',
|
|
168
|
+
severity: 'warning',
|
|
169
|
+
title: `Today is ${Math.round(tokenDelta ?? 0)}% above your 7-day average`,
|
|
170
|
+
detail: 'Token usage is materially above your recent active-day baseline.',
|
|
171
|
+
metric: 'tokens',
|
|
172
|
+
currentValue: todayEntry.totalTokens,
|
|
173
|
+
baselineValue: baseline.avgTokens,
|
|
174
|
+
deltaPercent: tokenDelta,
|
|
175
|
+
});
|
|
176
|
+
addInsight(insights, todayEntry.totalCost >= baseline.avgCost * 2 && todayEntry.totalCost - baseline.avgCost >= 1 && {
|
|
177
|
+
id: 'cost-spike',
|
|
178
|
+
severity: 'warning',
|
|
179
|
+
title: `Today cost is ${Math.round(costDelta ?? 0)}% above baseline`,
|
|
180
|
+
detail: 'Estimated spend is materially above your recent active-day baseline.',
|
|
181
|
+
metric: 'cost',
|
|
182
|
+
currentValue: todayEntry.totalCost,
|
|
183
|
+
baselineValue: baseline.avgCost,
|
|
184
|
+
deltaPercent: costDelta,
|
|
185
|
+
});
|
|
186
|
+
addInsight(insights, baseline.avgCacheHitRate - todayCacheHitRate >= 20 && {
|
|
187
|
+
id: 'cache-drop',
|
|
188
|
+
severity: 'warning',
|
|
189
|
+
title: `Cache hit dropped to ${todayCacheHitRate.toFixed(1)}%`,
|
|
190
|
+
detail: `Recent baseline is ${baseline.avgCacheHitRate.toFixed(1)}%. Context reuse may be lower than usual.`,
|
|
191
|
+
metric: 'cache',
|
|
192
|
+
currentValue: todayCacheHitRate,
|
|
193
|
+
baselineValue: baseline.avgCacheHitRate,
|
|
194
|
+
deltaPercent: todayCacheHitRate - baseline.avgCacheHitRate,
|
|
195
|
+
});
|
|
196
|
+
addInsight(insights, todayOutputInputRatio >= baseline.avgOutputInputRatio * 2 && todayEntry.outputTokens >= 25_000 && {
|
|
197
|
+
id: 'output-heavy',
|
|
198
|
+
severity: 'info',
|
|
199
|
+
title: `Output/input ratio is ${todayOutputInputRatio.toFixed(1)}x`,
|
|
200
|
+
detail: 'Today is more output-heavy than your recent baseline.',
|
|
201
|
+
metric: 'ratio',
|
|
202
|
+
currentValue: todayOutputInputRatio,
|
|
203
|
+
baselineValue: baseline.avgOutputInputRatio,
|
|
204
|
+
deltaPercent: ratioDelta,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
addInsight(insights, todayCacheHitRate < 50 && todayEntry.inputTokens >= 50_000 && {
|
|
208
|
+
id: 'low-cache',
|
|
209
|
+
severity: 'info',
|
|
210
|
+
title: `Cache hit is low at ${todayCacheHitRate.toFixed(1)}%`,
|
|
211
|
+
detail: 'Low cache reuse can raise cost when input volume is high.',
|
|
212
|
+
metric: 'cache',
|
|
213
|
+
currentValue: todayCacheHitRate,
|
|
214
|
+
baselineValue: baseline.insufficientData ? undefined : baseline.avgCacheHitRate,
|
|
215
|
+
});
|
|
216
|
+
addInsight(insights, topProject && topProject.share >= 0.5 && {
|
|
217
|
+
id: 'large-project-driver',
|
|
218
|
+
severity: 'info',
|
|
219
|
+
title: `${topProject.name} drives ${Math.round(topProject.share * 100)}% of today's tokens`,
|
|
220
|
+
detail: 'One project explains most of today’s scoped usage.',
|
|
221
|
+
metric: 'project',
|
|
222
|
+
currentValue: topProject.share * 100,
|
|
223
|
+
driver: topProject,
|
|
224
|
+
});
|
|
225
|
+
addInsight(insights, topModel && topModel.share >= 0.5 && {
|
|
226
|
+
id: 'large-model-driver',
|
|
227
|
+
severity: 'info',
|
|
228
|
+
title: `${topModel.name} drives ${Math.round(topModel.share * 100)}% of today’s ${todayEntry.totalCost > 0 ? 'cost' : 'tokens'}`,
|
|
229
|
+
detail: 'One model explains most of today’s scoped usage.',
|
|
230
|
+
metric: 'model',
|
|
231
|
+
currentValue: topModel.share * 100,
|
|
232
|
+
driver: topModel,
|
|
233
|
+
});
|
|
234
|
+
addInsight(insights, isNewHigh && {
|
|
235
|
+
id: 'new-high',
|
|
236
|
+
severity: !baseline.insufficientData && todayEntry.totalTokens >= baseline.avgTokens * 2 ? 'critical' : 'info',
|
|
237
|
+
title: 'Today is the highest day in the last 30 days',
|
|
238
|
+
detail: baseline.insufficientData ? 'There is not enough baseline data yet, but today is currently the highest observed day.' : 'Today is the highest observed day in the current local history window.',
|
|
239
|
+
metric: 'tokens',
|
|
240
|
+
currentValue: todayEntry.totalTokens,
|
|
241
|
+
baselineValue: baseline.insufficientData ? undefined : baseline.avgTokens,
|
|
242
|
+
deltaPercent: tokenDelta,
|
|
243
|
+
});
|
|
244
|
+
return {
|
|
245
|
+
generatedAt: new Date().toISOString(),
|
|
246
|
+
scope: {
|
|
247
|
+
agent: options.agent,
|
|
248
|
+
...(options.project ? { project: options.project } : {}),
|
|
249
|
+
timezone: options.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
250
|
+
partialAgents: options.partialAgents ?? [],
|
|
251
|
+
},
|
|
252
|
+
today: {
|
|
253
|
+
date: today,
|
|
254
|
+
totalTokens: todayEntry.totalTokens,
|
|
255
|
+
totalCost: todayEntry.totalCost,
|
|
256
|
+
cacheHitRate: todayCacheHitRate,
|
|
257
|
+
outputInputRatio: todayOutputInputRatio,
|
|
258
|
+
},
|
|
259
|
+
baseline,
|
|
260
|
+
topDrivers,
|
|
261
|
+
insights: sortInsights(insights),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
export function mergeDailyResponsesByDate(responses) {
|
|
265
|
+
const byDate = new Map();
|
|
266
|
+
for (const daily of responses) {
|
|
267
|
+
for (const entry of daily) {
|
|
268
|
+
const entries = byDate.get(entry.date) ?? [];
|
|
269
|
+
entries.push(entry);
|
|
270
|
+
byDate.set(entry.date, entries);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return [...byDate.entries()]
|
|
274
|
+
.map(([date, entries]) => mergeEntries(date, entries))
|
|
275
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
276
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { QuotaProviderId, QuotaSnapshot, QuotaProviderStatus } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Structured quota error. Adapters throw this (not generic Error) so the
|
|
4
|
+
* service can classify the status without inspecting message strings.
|
|
5
|
+
* Messages must never contain secrets — they reach the API response.
|
|
6
|
+
*/
|
|
7
|
+
export declare class QuotaError extends Error {
|
|
8
|
+
readonly status: QuotaProviderStatus;
|
|
9
|
+
constructor(status: QuotaProviderStatus);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One provider adapter. Responsible only for:
|
|
13
|
+
* 1. detecting whether the provider is configured (credentials present)
|
|
14
|
+
* 2. invoking the upstream interface
|
|
15
|
+
* 3. validating the response
|
|
16
|
+
* 4. converting to a normalized snapshot
|
|
17
|
+
*
|
|
18
|
+
* It does NOT cache, deduplicate, or time out — the service owns those.
|
|
19
|
+
* Detecting a provider as configured means it appears in the dashboard;
|
|
20
|
+
* fetch() may still fail with an auth/error status.
|
|
21
|
+
*/
|
|
22
|
+
export interface QuotaAdapter {
|
|
23
|
+
readonly provider: QuotaProviderId;
|
|
24
|
+
readonly displayName: string;
|
|
25
|
+
/** True when credentials/config exist for this provider locally. Cheap, no network. */
|
|
26
|
+
isConfigured(): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Fetch a fresh normalized snapshot. Throws QuotaError on any failure.
|
|
29
|
+
* Must NOT include secrets in any field of the returned snapshot.
|
|
30
|
+
*/
|
|
31
|
+
fetch(): Promise<QuotaSnapshot>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Registry of all known adapters, keyed by provider id.
|
|
35
|
+
* Adding a quota provider = ship one adapter + register it here.
|
|
36
|
+
*/
|
|
37
|
+
export declare class QuotaAdapterRegistry {
|
|
38
|
+
private readonly adapters;
|
|
39
|
+
register(adapter: QuotaAdapter): void;
|
|
40
|
+
get(provider: QuotaProviderId): QuotaAdapter | undefined;
|
|
41
|
+
list(): QuotaAdapter[];
|
|
42
|
+
}
|
|
43
|
+
/** Build a baseline snapshot with shared fields filled in. */
|
|
44
|
+
export declare function baseSnapshot(provider: QuotaProviderId, displayName: string, opts?: {
|
|
45
|
+
planName?: string;
|
|
46
|
+
windows?: QuotaSnapshot['windows'];
|
|
47
|
+
}): Pick<QuotaSnapshot, 'provider' | 'displayName' | 'planName' | 'fetchedAt' | 'freshness' | 'windows'>;
|