@zhangferry-dev/tokendash 1.6.0 → 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/client/popover.html +4 -3
- package/dist/daemon.cjs +3306 -0
- package/dist/daemon.cjs.map +7 -0
- package/dist/electron-server.cjs +1043 -27
- package/dist/electron-server.cjs.map +4 -4
- package/dist/server/daemon.d.ts +12 -0
- package/dist/server/daemon.js +176 -0
- package/dist/server/index.js +39 -13
- 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.d.ts +6 -1
- package/dist/server/routes/api.js +26 -1
- package/dist/server/routes/insights.d.ts +2 -0
- package/dist/server/routes/insights.js +155 -0
- package/package.json +6 -10
- 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 -490
- package/electron/main.js +0 -291
- package/electron/preload.cjs +0 -36
- package/electron/trayBadge.cjs +0 -27
- package/electron/trayBadge.js +0 -30
- package/electron/trayHelper +0 -0
- package/electron/trayHelper.swift +0 -152
- package/electron/updateService.cjs +0 -148
- package/electron-builder.yml +0 -20
- /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
|
@@ -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',
|
|
@@ -12,11 +11,22 @@ const CLI_USAGE = [
|
|
|
12
11
|
' tokendash --port <number> [--no-open]',
|
|
13
12
|
' tokendash --tray [--port <number>]',
|
|
14
13
|
].join('\n');
|
|
14
|
+
const PACKAGE_NAME = '@zhangferry-dev/tokendash';
|
|
15
15
|
function getPackageVersion() {
|
|
16
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
17
|
const __dirname = dirname(__filename);
|
|
18
|
-
const
|
|
19
|
-
|
|
18
|
+
const packageJsonPaths = [
|
|
19
|
+
join(__dirname, '..', '..', 'package.json'), // dist/server/index.js
|
|
20
|
+
join(__dirname, '..', 'package.json'), // bundled server entrypoint
|
|
21
|
+
];
|
|
22
|
+
for (const packageJsonPath of packageJsonPaths) {
|
|
23
|
+
if (!existsSync(packageJsonPath))
|
|
24
|
+
continue;
|
|
25
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
26
|
+
if (packageJson.version)
|
|
27
|
+
return packageJson.version;
|
|
28
|
+
}
|
|
29
|
+
return 'unknown';
|
|
20
30
|
}
|
|
21
31
|
function exitWithCliError(message) {
|
|
22
32
|
console.error(message);
|
|
@@ -127,7 +137,7 @@ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir)
|
|
|
127
137
|
// The CLI entrypoint runs from dist/server/index.js while the Vite assets are
|
|
128
138
|
// emitted to dist/client. Resolve the production asset base to dist instead
|
|
129
139
|
// of dist/server so / resolves to dist/client/index.html in installed npm
|
|
130
|
-
// packages.
|
|
140
|
+
// packages. The native app passes dist explicitly and is unaffected by this branch.
|
|
131
141
|
if (basename(moduleDir) === 'server') {
|
|
132
142
|
return { baseDir: resolve(dirname(moduleDir)), isProduction: true };
|
|
133
143
|
}
|
|
@@ -137,7 +147,11 @@ export function createApp(_port, baseDir) {
|
|
|
137
147
|
const app = express();
|
|
138
148
|
const router = express.Router();
|
|
139
149
|
// Register API routes
|
|
140
|
-
registerApiRoutes(router
|
|
150
|
+
registerApiRoutes(router, {
|
|
151
|
+
packageName: PACKAGE_NAME,
|
|
152
|
+
version: getPackageVersion(),
|
|
153
|
+
dashboardUrl: `http://localhost:${resolvePort(_port)}`,
|
|
154
|
+
});
|
|
141
155
|
app.use('/api', router);
|
|
142
156
|
const { baseDir: _baseDir, isProduction } = resolveStaticAssetBaseDir(import.meta.url, baseDir);
|
|
143
157
|
const popoverPath = isProduction
|
|
@@ -171,21 +185,29 @@ async function main() {
|
|
|
171
185
|
}
|
|
172
186
|
const version = getPackageVersion();
|
|
173
187
|
const preferredPort = resolvePort(args.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined));
|
|
174
|
-
// --tray mode: launch
|
|
188
|
+
// --tray mode: launch native Swift menu bar app
|
|
175
189
|
if (args.tray) {
|
|
176
190
|
if (process.platform !== 'darwin') {
|
|
177
191
|
console.error('Error: --tray is only supported on macOS.');
|
|
178
192
|
process.exit(1);
|
|
179
193
|
}
|
|
180
194
|
console.log(`Starting tokendash v${version} in tray mode...`);
|
|
181
|
-
// @ts-ignore -- electron is installed separately for tray mode
|
|
182
|
-
const { default: electronPath } = await import('electron');
|
|
183
195
|
const { spawn } = await import('node:child_process');
|
|
184
|
-
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, [], {
|
|
185
208
|
env: {
|
|
186
209
|
...process.env,
|
|
187
210
|
TOKENDASH_PORT: String(preferredPort),
|
|
188
|
-
TOKENDASH_TRAY: '1',
|
|
189
211
|
},
|
|
190
212
|
stdio: 'inherit',
|
|
191
213
|
});
|
|
@@ -217,11 +239,15 @@ async function main() {
|
|
|
217
239
|
// Open browser if requested
|
|
218
240
|
if (shouldOpenBrowser) {
|
|
219
241
|
// Small delay to ensure server is ready
|
|
220
|
-
setTimeout(() => {
|
|
242
|
+
setTimeout(async () => {
|
|
221
243
|
console.log('Opening dashboard in your browser...');
|
|
222
|
-
|
|
244
|
+
try {
|
|
245
|
+
const { default: open } = await import('open');
|
|
246
|
+
await open(`http://localhost:${port}`);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
223
249
|
console.warn('Could not open browser:', err.message);
|
|
224
|
-
}
|
|
250
|
+
}
|
|
225
251
|
}, 100);
|
|
226
252
|
}
|
|
227
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'>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured quota error. Adapters throw this (not generic Error) so the
|
|
3
|
+
* service can classify the status without inspecting message strings.
|
|
4
|
+
* Messages must never contain secrets — they reach the API response.
|
|
5
|
+
*/
|
|
6
|
+
export class QuotaError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(status) {
|
|
9
|
+
const msg = 'message' in status && status.message ? status.message : '';
|
|
10
|
+
super(msg ? `${status.state}: ${msg}` : status.state);
|
|
11
|
+
this.name = 'QuotaError';
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Registry of all known adapters, keyed by provider id.
|
|
17
|
+
* Adding a quota provider = ship one adapter + register it here.
|
|
18
|
+
*/
|
|
19
|
+
export class QuotaAdapterRegistry {
|
|
20
|
+
adapters = new Map();
|
|
21
|
+
register(adapter) {
|
|
22
|
+
this.adapters.set(adapter.provider, adapter);
|
|
23
|
+
}
|
|
24
|
+
get(provider) {
|
|
25
|
+
return this.adapters.get(provider);
|
|
26
|
+
}
|
|
27
|
+
list() {
|
|
28
|
+
return Array.from(this.adapters.values());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Build a baseline snapshot with shared fields filled in. */
|
|
32
|
+
export function baseSnapshot(provider, displayName, opts = {}) {
|
|
33
|
+
return {
|
|
34
|
+
provider,
|
|
35
|
+
displayName,
|
|
36
|
+
planName: opts.planName,
|
|
37
|
+
fetchedAt: new Date().toISOString(),
|
|
38
|
+
freshness: 'live',
|
|
39
|
+
windows: opts.windows ?? [],
|
|
40
|
+
};
|
|
41
|
+
}
|