@zhangferry-dev/tokendash 1.6.1 → 1.7.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 +148 -84
- package/dist/client/assets/index-Bw503sNp.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/daemon.cjs +3411 -0
- package/dist/daemon.cjs.map +7 -0
- package/dist/electron-server.cjs +1124 -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 +23 -11
- package/dist/server/insightsCalculator.d.ts +15 -0
- package/dist/server/insightsCalculator.js +276 -0
- package/dist/server/quota/adapter.d.ts +49 -0
- package/dist/server/quota/adapter.js +41 -0
- package/dist/server/quota/adapters/claude.d.ts +4 -0
- package/dist/server/quota/adapters/claude.js +152 -0
- package/dist/server/quota/adapters/codex.d.ts +16 -0
- package/dist/server/quota/adapters/codex.js +226 -0
- package/dist/server/quota/adapters/glm.d.ts +2 -0
- package/dist/server/quota/adapters/glm.js +139 -0
- package/dist/server/quota/adapters/kimi.d.ts +2 -0
- package/dist/server/quota/adapters/kimi.js +186 -0
- package/dist/server/quota/adapters/minimax.d.ts +2 -0
- package/dist/server/quota/adapters/minimax.js +82 -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 +43 -0
- package/dist/server/quota/quotaService.js +163 -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 +76 -0
- package/dist/server/quota/types.js +10 -0
- package/dist/server/routes/api.js +34 -0
- package/dist/server/routes/insights.d.ts +2 -0
- package/dist/server/routes/insights.js +155 -0
- package/package.json +9 -11
- 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
|
}
|
|
@@ -147,6 +146,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
|
|
|
147
146
|
export function createApp(_port, baseDir) {
|
|
148
147
|
const app = express();
|
|
149
148
|
const router = express.Router();
|
|
149
|
+
app.use(express.json({ limit: '16kb' }));
|
|
150
150
|
// Register API routes
|
|
151
151
|
registerApiRoutes(router, {
|
|
152
152
|
packageName: PACKAGE_NAME,
|
|
@@ -186,21 +186,29 @@ async function main() {
|
|
|
186
186
|
}
|
|
187
187
|
const version = getPackageVersion();
|
|
188
188
|
const preferredPort = resolvePort(args.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined));
|
|
189
|
-
// --tray mode: launch
|
|
189
|
+
// --tray mode: launch native Swift menu bar app
|
|
190
190
|
if (args.tray) {
|
|
191
191
|
if (process.platform !== 'darwin') {
|
|
192
192
|
console.error('Error: --tray is only supported on macOS.');
|
|
193
193
|
process.exit(1);
|
|
194
194
|
}
|
|
195
195
|
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
196
|
const { spawn } = await import('node:child_process');
|
|
199
|
-
const
|
|
197
|
+
const { resolve } = await import('node:path');
|
|
198
|
+
const { existsSync } = await import('node:fs');
|
|
199
|
+
// Find Swift binary: check packaged app first, then dev build
|
|
200
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
201
|
+
const packagedPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
|
|
202
|
+
const devPath = resolve(moduleDir, '..', '..', 'TokenDashSwift', '.build', 'debug', 'TokenDash');
|
|
203
|
+
const swiftBin = existsSync(packagedPath) ? packagedPath : devPath;
|
|
204
|
+
if (!existsSync(swiftBin)) {
|
|
205
|
+
console.error('Error: TokenDash Swift binary not found. Run "npm run build:swift" first.');
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
const child = spawn(swiftBin, [], {
|
|
200
209
|
env: {
|
|
201
210
|
...process.env,
|
|
202
211
|
TOKENDASH_PORT: String(preferredPort),
|
|
203
|
-
TOKENDASH_TRAY: '1',
|
|
204
212
|
},
|
|
205
213
|
stdio: 'inherit',
|
|
206
214
|
});
|
|
@@ -232,11 +240,15 @@ async function main() {
|
|
|
232
240
|
// Open browser if requested
|
|
233
241
|
if (shouldOpenBrowser) {
|
|
234
242
|
// Small delay to ensure server is ready
|
|
235
|
-
setTimeout(() => {
|
|
243
|
+
setTimeout(async () => {
|
|
236
244
|
console.log('Opening dashboard in your browser...');
|
|
237
|
-
|
|
245
|
+
try {
|
|
246
|
+
const { default: open } = await import('open');
|
|
247
|
+
await open(`http://localhost:${port}`);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
238
250
|
console.warn('Could not open browser:', err.message);
|
|
239
|
-
}
|
|
251
|
+
}
|
|
240
252
|
}, 100);
|
|
241
253
|
}
|
|
242
254
|
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
|
+
}
|