@tokentop/ttop 0.6.0 → 0.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 +33 -11
- package/package.json +4 -4
- package/src/agents/aggregator.test.ts +269 -1
- package/src/agents/aggregator.ts +196 -8
- package/src/agents/costing.test.ts +584 -0
- package/src/agents/costing.ts +144 -30
- package/src/agents/types.ts +7 -0
- package/src/config/schema.ts +1 -1
- package/src/demo/simulator.test.ts +92 -0
- package/src/demo/simulator.ts +579 -211
- package/src/plugins/notification-bus.ts +27 -0
- package/src/plugins/notifications/terminal-bell.ts +80 -16
- package/src/plugins/notifications/visual-flash.ts +5 -18
- package/src/plugins/plugin-context-factory.ts +9 -1
- package/src/plugins/providers/anthropic.test.ts +145 -0
- package/src/plugins/providers/anthropic.ts +18 -12
- package/src/plugins/providers/chutes.ts +2 -0
- package/src/plugins/providers/github-copilot.ts +3 -0
- package/src/plugins/providers/minimax.ts +2 -0
- package/src/plugins/providers/openai-api.ts +4 -0
- package/src/plugins/providers/zai.ts +2 -0
- package/src/pricing/estimator.ts +7 -0
- package/src/pricing/index.ts +5 -0
- package/src/pricing/models-dev.test.ts +117 -0
- package/src/pricing/models-dev.ts +22 -0
- package/src/storage/persistence-service.test.ts +201 -0
- package/src/storage/persistence-service.ts +54 -5
- package/src/storage/repos/agentSessions.ts +49 -0
- package/src/tui/App.tsx +10 -4
- package/src/tui/components/NotificationFlash.tsx +141 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
- package/src/tui/components/SessionsTable.tsx +25 -7
- package/src/tui/components/SettingsModal.tsx +20 -4
- package/src/tui/components/Toast.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +59 -21
- package/src/tui/contexts/PluginContext.tsx +39 -10
- package/src/tui/hooks/useNotificationBridge.ts +29 -0
- package/src/tui/utils/scrollFollow.test.ts +71 -0
- package/src/tui/utils/scrollFollow.ts +12 -0
- package/src/tui/views/Dashboard.tsx +0 -6
- package/src/tui/views/RealTimeDashboard.tsx +105 -38
- package/src/tui/views/SettingsView.tsx +26 -8
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
> [!
|
|
2
|
-
> **tokentop is under active development and not yet ready for general use.** APIs and configuration may change without notice. If you're interested, star the repo and check back soon.
|
|
1
|
+
> **Early release** — tokentop is functional and actively developed. Please [report issues](https://github.com/tokentopapp/tokentop/issues)!
|
|
3
2
|
|
|
4
3
|
<div align="center">
|
|
5
4
|
|
|
@@ -18,7 +17,9 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
|
|
|
18
17
|
[](https://bun.sh)
|
|
19
18
|
[](https://visitorbadge.io/status?path=https://github.com/tokentopapp/tokentop)
|
|
20
19
|
|
|
21
|
-
[Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [
|
|
20
|
+
[Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Agents](#agents) · [Plugins](#plugin-system) · [Docs](https://tokentop.app/docs/getting-started/)
|
|
21
|
+
|
|
22
|
+
**[tokentop.app](https://tokentop.app)**
|
|
22
23
|
|
|
23
24
|
</div>
|
|
24
25
|
|
|
@@ -45,7 +46,7 @@ That's tokentop.
|
|
|
45
46
|
|
|
46
47
|
- **Real-time dashboard** — Live token counts, costs, burn rate, and activity sparklines
|
|
47
48
|
- **11 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen, Chutes
|
|
48
|
-
- **
|
|
49
|
+
- **7 coding agents** — Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, Antigravity, and Windsurf. See every session with model, tokens, cost, and duration
|
|
49
50
|
- **Budget guardrails** — Daily, weekly, and monthly limits with visual warnings at limit percentages you set
|
|
50
51
|
- **Smart sidebar** — Adaptive panel that breaks down spending by model, project, or agent
|
|
51
52
|
- **Efficiency insights** — Cache leverage, output verbosity, and cost-per-request analysis to help you spend less
|
|
@@ -53,11 +54,11 @@ That's tokentop.
|
|
|
53
54
|
- **Projects view** — See which codebase is costing you the most
|
|
54
55
|
- **Provider limits** — Visual gauges showing how close you are to rate limits
|
|
55
56
|
- **Live pricing** — Fetches current model pricing from [models.dev](https://models.dev) with local caching
|
|
56
|
-
- **15 built-in themes** —
|
|
57
|
+
- **[15 built-in themes](https://tokentop.app/docs/themes/built-in/)** — Tokyo Night, Dracula, Nord, Catppuccin, Gruvbox, Rosé Pine, Kanagawa, and more. Dark and light variants. Create your own with the [theme plugin API](https://tokentop.app/docs/themes/custom-themes/)
|
|
57
58
|
- **Plugin system** — Extend with custom providers, agents, themes, and notifications
|
|
58
59
|
- **Responsive layout** — Adapts to any terminal size; sidebar, KPI strip, header, and tables all reflow automatically from ultrawide to laptop-width
|
|
59
60
|
- **Demo mode** — Explore the UI with synthetic data, no API keys needed
|
|
60
|
-
- **Zero config** — Auto-discovers credentials from Claude Code, OpenCode, Cursor, Copilot CLI, environment variables, and CLI auth files
|
|
61
|
+
- **Zero config** — Auto-discovers credentials from Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, environment variables, and CLI auth files
|
|
61
62
|
|
|
62
63
|
## Installation
|
|
63
64
|
|
|
@@ -141,9 +142,21 @@ tokentop has 4 main views, switchable with `1`–`4`:
|
|
|
141
142
|
| `3` | **Trends** | ASCII step charts of cost over 7/30/90 days |
|
|
142
143
|
| `4` | **Projects** | Cost and token breakdown by local project/repo |
|
|
143
144
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
## Agents
|
|
146
|
+
|
|
147
|
+
tokentop tracks sessions from 7 coding agents. Each agent is a standalone plugin — built-in agents ship with the app, community agents install from npm.
|
|
148
|
+
|
|
149
|
+
| Agent | What it tracks | Plugin |
|
|
150
|
+
|-------|---------------|--------|
|
|
151
|
+
| [Claude Code](https://tokentop.app/docs/agents/claude-code/) | Sessions, OAuth tokens, multi-provider usage | [`@tokentop/agent-claude-code`](https://github.com/tokentopapp/agent-claude-code) |
|
|
152
|
+
| [OpenCode](https://tokentop.app/docs/agents/opencode/) | Sessions, OAuth credentials, multi-provider support | [`@tokentop/agent-opencode`](https://github.com/tokentopapp/agent-opencode) |
|
|
153
|
+
| [Cursor](https://tokentop.app/docs/agents/cursor/) | Sessions, CSV server log enrichment | [`@tokentop/agent-cursor`](https://github.com/tokentopapp/agent-cursor) |
|
|
154
|
+
| [Copilot CLI](https://tokentop.app/docs/agents/copilot-cli/) | Sessions, GitHub token auth | [`@tokentop/agent-copilot-cli`](https://github.com/tokentopapp/agent-copilot-cli) |
|
|
155
|
+
| [Gemini CLI](https://tokentop.app/docs/agents/gemini-cli/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
|
|
156
|
+
| [Antigravity](https://tokentop.app/docs/agents/antigravity/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
|
|
157
|
+
| [Windsurf](https://tokentop.app/docs/agents/windsurf/) | Sessions, Codeium auth | [`@tokentop/agent-windsurf`](https://github.com/tokentopapp/agent-windsurf) |
|
|
158
|
+
|
|
159
|
+
All agents are auto-discovered — if you have the tool installed, tokentop finds it. See the [agent docs](https://tokentop.app/docs/agents/) for details.
|
|
147
160
|
|
|
148
161
|
## Keyboard Shortcuts
|
|
149
162
|
|
|
@@ -219,7 +232,7 @@ tokentop is built on a plugin architecture with four extension points:
|
|
|
219
232
|
|
|
220
233
|
All plugins run in a **permission sandbox** — they must declare network, filesystem, and environment access upfront. Anyone can publish community plugins to npm — no org membership needed.
|
|
221
234
|
|
|
222
|
-
See the [Plugin Guide](docs/plugins
|
|
235
|
+
See the [Plugin Guide](https://tokentop.app/docs/plugins/overview/) for installation, configuration, and development details.
|
|
223
236
|
|
|
224
237
|
## How It Works
|
|
225
238
|
|
|
@@ -232,6 +245,15 @@ Costs are calculated from token counts and live pricing data from [models.dev](h
|
|
|
232
245
|
|
|
233
246
|
All data is stored in a **local SQLite database**. Nothing is sent anywhere. No telemetry, no analytics, no network calls except to the provider APIs you've already authenticated with.
|
|
234
247
|
|
|
248
|
+
## Alternatives
|
|
249
|
+
|
|
250
|
+
| Tool | Approach | Tokentop Difference |
|
|
251
|
+
|------|----------|---------------------|
|
|
252
|
+
| Provider dashboards | Web, hours behind | Real-time, in terminal |
|
|
253
|
+
| [tokentap](https://github.com/nicholasgasior/tokentap) | MitM proxy (abandoned) | Native API polling, no proxy |
|
|
254
|
+
| [toktop](https://github.com/jnsahaj/toktop) | Rust TUI, 2 providers | 11 providers, 7 agents, plugins |
|
|
255
|
+
| [sniffly](https://github.com/chiphuyen/sniffly) | Python dashboard | Terminal-native, multi-agent |
|
|
256
|
+
|
|
235
257
|
## Development
|
|
236
258
|
|
|
237
259
|
```bash
|
|
@@ -239,7 +261,7 @@ bun install # Install dependencies
|
|
|
239
261
|
bun run dev # Dev mode with hot reload
|
|
240
262
|
bun test # Run tests
|
|
241
263
|
bun run typecheck # TypeScript check
|
|
242
|
-
bun run lint #
|
|
264
|
+
bun run lint # Biome
|
|
243
265
|
```
|
|
244
266
|
|
|
245
267
|
### Demo mode for development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokentop/ttop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Real-time AI token usage monitor - htop for your API costs",
|
|
5
5
|
"author": "Nigel Bazzeghin <nbazzeghin@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
"analytics": "bun .opencode/skills/analytics/scripts/analytics.ts"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@opentui/core": "0.1.
|
|
38
|
-
"@opentui/react": "0.1.
|
|
37
|
+
"@opentui/core": "0.1.98",
|
|
38
|
+
"@opentui/react": "0.1.98",
|
|
39
39
|
"@tokentop/agent-claude-code": "^1.1.0",
|
|
40
40
|
"@tokentop/agent-copilot-cli": "^1.0.0",
|
|
41
41
|
"@tokentop/agent-cursor": "^1.2.0",
|
|
42
42
|
"@tokentop/agent-gemini": "^1.0.0",
|
|
43
43
|
"@tokentop/agent-opencode": "^1.0.0",
|
|
44
|
-
"@tokentop/plugin-sdk": "^1.
|
|
44
|
+
"@tokentop/plugin-sdk": "^1.5.0",
|
|
45
45
|
"react": "^19.0.0",
|
|
46
46
|
"react-devtools-core": "^7.0.1",
|
|
47
47
|
"zod": "^4.0.0"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SessionUsageData } from "@tokentop/plugin-sdk";
|
|
2
3
|
import { aggregateSessionUsage, deduplicateAggregates } from "./aggregator.ts";
|
|
3
4
|
import type { AgentSessionAggregate } from "./types.ts";
|
|
4
5
|
|
|
@@ -201,7 +202,40 @@ describe("deduplicateAggregates", () => {
|
|
|
201
202
|
// aggregateSessionUsage — basic sanity
|
|
202
203
|
// ---------------------------------------------------------------------------
|
|
203
204
|
describe("aggregateSessionUsage", () => {
|
|
204
|
-
const NOW = Date.
|
|
205
|
+
const NOW = new Date(2026, 3, 15, 12, 0, 0, 0).getTime();
|
|
206
|
+
|
|
207
|
+
function makeUsageRow(
|
|
208
|
+
overrides: Partial<SessionUsageData> &
|
|
209
|
+
Pick<SessionUsageData, "sessionId" | "tokens" | "timestamp">,
|
|
210
|
+
): SessionUsageData {
|
|
211
|
+
return {
|
|
212
|
+
providerId: "anthropic",
|
|
213
|
+
modelId: "claude-sonnet-4",
|
|
214
|
+
...overrides,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function aggregateSingleSession(rows: SessionUsageData[]) {
|
|
219
|
+
const result = aggregateSessionUsage({
|
|
220
|
+
agentId: "test",
|
|
221
|
+
agentName: "Test",
|
|
222
|
+
now: NOW,
|
|
223
|
+
rows,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result).toHaveLength(1);
|
|
227
|
+
return result[0]!;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getOnlyStream(rows: SessionUsageData[]) {
|
|
231
|
+
const session = aggregateSingleSession(rows);
|
|
232
|
+
expect(session.streams).toHaveLength(1);
|
|
233
|
+
return {
|
|
234
|
+
session,
|
|
235
|
+
stream: session.streams[0]!,
|
|
236
|
+
windowed: session._streamWindowedTokens?.get("anthropic::claude-sonnet-4"),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
205
239
|
|
|
206
240
|
test("groups rows by sessionId", () => {
|
|
207
241
|
const result = aggregateSessionUsage({
|
|
@@ -303,4 +337,238 @@ describe("aggregateSessionUsage", () => {
|
|
|
303
337
|
expect(active!.status).toBe("active");
|
|
304
338
|
expect(idle!.status).toBe("idle");
|
|
305
339
|
});
|
|
340
|
+
|
|
341
|
+
test("keeps all requests in the base bucket when every request is at or under 200K context", () => {
|
|
342
|
+
const { stream, session, windowed } = getOnlyStream([
|
|
343
|
+
makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 120_000, output: 1_000 } }),
|
|
344
|
+
makeUsageRow({
|
|
345
|
+
sessionId: "s1",
|
|
346
|
+
timestamp: NOW - 1_000,
|
|
347
|
+
tokens: { input: 80_000, output: 2_000 },
|
|
348
|
+
}),
|
|
349
|
+
makeUsageRow({
|
|
350
|
+
sessionId: "s1",
|
|
351
|
+
timestamp: NOW - 2_000,
|
|
352
|
+
tokens: { input: 200_000, output: 3_000 },
|
|
353
|
+
}),
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
expect(stream.tokens).toEqual({ input: 400_000, output: 6_000 });
|
|
357
|
+
expect(stream.longContextTokens).toBeUndefined();
|
|
358
|
+
expect(stream.longContextRequestCount).toBeUndefined();
|
|
359
|
+
expect(stream.hasLongContext).toBeUndefined();
|
|
360
|
+
expect(session.totals).toEqual({ input: 400_000, output: 6_000 });
|
|
361
|
+
expect(windowed).toEqual({
|
|
362
|
+
dayTokens: 406_000,
|
|
363
|
+
weekTokens: 406_000,
|
|
364
|
+
monthTokens: 406_000,
|
|
365
|
+
totalTokens: 406_000,
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("moves all requests into long-context bucket when every request exceeds 200K context", () => {
|
|
370
|
+
const { stream, windowed } = getOnlyStream([
|
|
371
|
+
makeUsageRow({
|
|
372
|
+
sessionId: "s1",
|
|
373
|
+
timestamp: NOW,
|
|
374
|
+
tokens: { input: 210_000, output: 1_000, cacheRead: 5_000, cacheWrite: 2_000 },
|
|
375
|
+
}),
|
|
376
|
+
makeUsageRow({
|
|
377
|
+
sessionId: "s1",
|
|
378
|
+
timestamp: NOW - 1_000,
|
|
379
|
+
tokens: { input: 220_000, output: 2_000, cacheRead: 10_000, cacheWrite: 4_000 },
|
|
380
|
+
}),
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
expect(stream.tokens).toEqual({ input: 0, output: 0 });
|
|
384
|
+
expect(stream.requestCount).toBe(2);
|
|
385
|
+
expect(stream.longContextTokens).toEqual({
|
|
386
|
+
input: 430_000,
|
|
387
|
+
output: 3_000,
|
|
388
|
+
cacheRead: 15_000,
|
|
389
|
+
cacheWrite: 6_000,
|
|
390
|
+
});
|
|
391
|
+
expect(stream.longContextRequestCount).toBe(2);
|
|
392
|
+
expect(stream.hasLongContext).toBe(true);
|
|
393
|
+
expect(windowed).toEqual({
|
|
394
|
+
dayTokens: 454_000,
|
|
395
|
+
weekTokens: 454_000,
|
|
396
|
+
monthTokens: 454_000,
|
|
397
|
+
totalTokens: 454_000,
|
|
398
|
+
longContextDayTokens: 454_000,
|
|
399
|
+
longContextWeekTokens: 454_000,
|
|
400
|
+
longContextMonthTokens: 454_000,
|
|
401
|
+
longContextTotalTokens: 454_000,
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("splits mixed requests into base and long-context buckets", () => {
|
|
406
|
+
const { stream } = getOnlyStream([
|
|
407
|
+
makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 100_000, output: 1_000 } }),
|
|
408
|
+
makeUsageRow({
|
|
409
|
+
sessionId: "s1",
|
|
410
|
+
timestamp: NOW - 1_000,
|
|
411
|
+
tokens: { input: 120_000, output: 2_000 },
|
|
412
|
+
}),
|
|
413
|
+
makeUsageRow({
|
|
414
|
+
sessionId: "s1",
|
|
415
|
+
timestamp: NOW - 2_000,
|
|
416
|
+
tokens: { input: 180_000, output: 3_000 },
|
|
417
|
+
}),
|
|
418
|
+
makeUsageRow({
|
|
419
|
+
sessionId: "s1",
|
|
420
|
+
timestamp: NOW - 3_000,
|
|
421
|
+
tokens: { input: 210_000, output: 4_000 },
|
|
422
|
+
}),
|
|
423
|
+
makeUsageRow({
|
|
424
|
+
sessionId: "s1",
|
|
425
|
+
timestamp: NOW - 4_000,
|
|
426
|
+
tokens: { input: 190_000, output: 5_000, cacheRead: 20_000 },
|
|
427
|
+
}),
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
expect(stream.tokens).toEqual({ input: 400_000, output: 6_000 });
|
|
431
|
+
expect(stream.longContextTokens).toEqual({ input: 400_000, output: 9_000, cacheRead: 20_000 });
|
|
432
|
+
expect(stream.requestCount).toBe(5);
|
|
433
|
+
expect(stream.longContextRequestCount).toBe(2);
|
|
434
|
+
expect(stream.hasLongContext).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("treats exactly 200,000 context tokens as base bucket", () => {
|
|
438
|
+
const { stream, windowed } = getOnlyStream([
|
|
439
|
+
makeUsageRow({
|
|
440
|
+
sessionId: "s1",
|
|
441
|
+
timestamp: NOW,
|
|
442
|
+
tokens: { input: 150_000, output: 7_000, cacheRead: 30_000, cacheWrite: 20_000 },
|
|
443
|
+
}),
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
expect(stream.tokens).toEqual({
|
|
447
|
+
input: 150_000,
|
|
448
|
+
output: 7_000,
|
|
449
|
+
cacheRead: 30_000,
|
|
450
|
+
cacheWrite: 20_000,
|
|
451
|
+
});
|
|
452
|
+
expect(stream.longContextTokens).toBeUndefined();
|
|
453
|
+
expect(stream.longContextRequestCount).toBeUndefined();
|
|
454
|
+
expect(stream.hasLongContext).toBeUndefined();
|
|
455
|
+
expect(windowed?.longContextTotalTokens).toBeUndefined();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("moves 200,001 context tokens into long-context bucket", () => {
|
|
459
|
+
const { stream, windowed } = getOnlyStream([
|
|
460
|
+
makeUsageRow({
|
|
461
|
+
sessionId: "s1",
|
|
462
|
+
timestamp: NOW,
|
|
463
|
+
tokens: { input: 150_000, output: 7_000, cacheRead: 30_001, cacheWrite: 20_000 },
|
|
464
|
+
}),
|
|
465
|
+
]);
|
|
466
|
+
|
|
467
|
+
expect(stream.tokens).toEqual({ input: 0, output: 0 });
|
|
468
|
+
expect(stream.longContextTokens).toEqual({
|
|
469
|
+
input: 150_000,
|
|
470
|
+
output: 7_000,
|
|
471
|
+
cacheRead: 30_001,
|
|
472
|
+
cacheWrite: 20_000,
|
|
473
|
+
});
|
|
474
|
+
expect(stream.longContextRequestCount).toBe(1);
|
|
475
|
+
expect(stream.hasLongContext).toBe(true);
|
|
476
|
+
expect(windowed?.longContextTotalTokens).toBe(207_001);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("uses cache tokens when cache-only growth crosses the threshold", () => {
|
|
480
|
+
const { stream } = getOnlyStream([
|
|
481
|
+
makeUsageRow({
|
|
482
|
+
sessionId: "s1",
|
|
483
|
+
timestamp: NOW,
|
|
484
|
+
tokens: { input: 50_000, output: 1_000, cacheRead: 180_000 },
|
|
485
|
+
}),
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
expect(stream.tokens).toEqual({ input: 0, output: 0 });
|
|
489
|
+
expect(stream.longContextTokens).toEqual({ input: 50_000, output: 1_000, cacheRead: 180_000 });
|
|
490
|
+
expect(stream.longContextRequestCount).toBe(1);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("treats missing cacheRead and cacheWrite as zero when computing context size", () => {
|
|
494
|
+
const { stream } = getOnlyStream([
|
|
495
|
+
makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 199_999, output: 1_000 } }),
|
|
496
|
+
makeUsageRow({
|
|
497
|
+
sessionId: "s1",
|
|
498
|
+
timestamp: NOW - 1_000,
|
|
499
|
+
tokens: { input: 200_001, output: 2_000 },
|
|
500
|
+
}),
|
|
501
|
+
]);
|
|
502
|
+
|
|
503
|
+
expect(stream.tokens).toEqual({ input: 199_999, output: 1_000 });
|
|
504
|
+
expect(stream.longContextTokens).toEqual({ input: 200_001, output: 2_000 });
|
|
505
|
+
expect(stream.longContextRequestCount).toBe(1);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("splits windowed tokens correctly between total and long-context buckets", () => {
|
|
509
|
+
const { windowed } = getOnlyStream([
|
|
510
|
+
makeUsageRow({
|
|
511
|
+
sessionId: "s1",
|
|
512
|
+
timestamp: NOW - 1_000,
|
|
513
|
+
tokens: { input: 210_000, output: 1_000 },
|
|
514
|
+
}),
|
|
515
|
+
makeUsageRow({
|
|
516
|
+
sessionId: "s1",
|
|
517
|
+
timestamp: NOW - 60 * 60 * 1000,
|
|
518
|
+
tokens: { input: 90_000, output: 2_000 },
|
|
519
|
+
}),
|
|
520
|
+
makeUsageRow({
|
|
521
|
+
sessionId: "s1",
|
|
522
|
+
timestamp: NOW - 2 * 24 * 60 * 60 * 1000,
|
|
523
|
+
tokens: { input: 205_000, output: 3_000 },
|
|
524
|
+
}),
|
|
525
|
+
makeUsageRow({
|
|
526
|
+
sessionId: "s1",
|
|
527
|
+
timestamp: NOW - 10 * 24 * 60 * 60 * 1000,
|
|
528
|
+
tokens: { input: 80_000, output: 4_000 },
|
|
529
|
+
}),
|
|
530
|
+
makeUsageRow({
|
|
531
|
+
sessionId: "s1",
|
|
532
|
+
timestamp: new Date(
|
|
533
|
+
new Date(NOW).getFullYear(),
|
|
534
|
+
new Date(NOW).getMonth() - 1,
|
|
535
|
+
15,
|
|
536
|
+
).getTime(),
|
|
537
|
+
tokens: { input: 220_000, output: 5_000 },
|
|
538
|
+
}),
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
expect(windowed).toEqual({
|
|
542
|
+
dayTokens: 303_000,
|
|
543
|
+
weekTokens: 511_000,
|
|
544
|
+
monthTokens: 595_000,
|
|
545
|
+
totalTokens: 820_000,
|
|
546
|
+
longContextDayTokens: 211_000,
|
|
547
|
+
longContextWeekTokens: 419_000,
|
|
548
|
+
longContextMonthTokens: 419_000,
|
|
549
|
+
longContextTotalTokens: 644_000,
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("does not produce division-by-zero or NaN artifacts when every request is long-context", () => {
|
|
554
|
+
const { stream, session, windowed } = getOnlyStream([
|
|
555
|
+
makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 300_000, output: 5_000 } }),
|
|
556
|
+
makeUsageRow({
|
|
557
|
+
sessionId: "s1",
|
|
558
|
+
timestamp: NOW - 1_000,
|
|
559
|
+
tokens: { input: 250_000, output: 6_000 },
|
|
560
|
+
}),
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
expect(stream.tokens).toEqual({ input: 0, output: 0 });
|
|
564
|
+
expect(stream.longContextTokens).toEqual({ input: 550_000, output: 11_000 });
|
|
565
|
+
expect(stream.longContextRequestCount).toBe(2);
|
|
566
|
+
expect(stream.hasLongContext).toBe(true);
|
|
567
|
+
expect(Number.isNaN(stream.tokens.input)).toBe(false);
|
|
568
|
+
expect(Number.isNaN(stream.tokens.output)).toBe(false);
|
|
569
|
+
expect(Number.isNaN(session.totals.input)).toBe(false);
|
|
570
|
+
expect(Number.isNaN(session.totals.output)).toBe(false);
|
|
571
|
+
expect(Number.isNaN(windowed!.totalTokens)).toBe(false);
|
|
572
|
+
expect(Number.isNaN(windowed!.longContextTotalTokens!)).toBe(false);
|
|
573
|
+
});
|
|
306
574
|
});
|
package/src/agents/aggregator.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SessionUsageData } from "@tokentop/plugin-sdk";
|
|
2
|
+
import { LONG_CONTEXT_THRESHOLD } from "../pricing/estimator.ts";
|
|
2
3
|
import {
|
|
3
4
|
type AgentId,
|
|
4
5
|
type AgentName,
|
|
@@ -64,9 +65,35 @@ interface StreamAccumulator {
|
|
|
64
65
|
key: StreamKey;
|
|
65
66
|
tokens: TokenCounts;
|
|
66
67
|
requestCount: number;
|
|
68
|
+
longContextTokens?: TokenCounts;
|
|
69
|
+
longContextRequestCount?: number;
|
|
67
70
|
windowed: StreamWindowedTokens;
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
function zeroTokens(): TokenCounts {
|
|
74
|
+
return { input: 0, output: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeTokens(tokens: TokenCounts): TokenCounts {
|
|
78
|
+
const normalized: TokenCounts = {
|
|
79
|
+
input: tokens.input,
|
|
80
|
+
output: tokens.output,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const cacheRead = tokens.cacheRead ?? 0;
|
|
84
|
+
const cacheWrite = tokens.cacheWrite ?? 0;
|
|
85
|
+
|
|
86
|
+
if (cacheRead > 0) normalized.cacheRead = cacheRead;
|
|
87
|
+
if (cacheWrite > 0) normalized.cacheWrite = cacheWrite;
|
|
88
|
+
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getContextSize(tokens: TokenCounts): number {
|
|
93
|
+
// contextSize = input + cacheRead + cacheWrite — the full prompt context sent to the model
|
|
94
|
+
return tokens.input + (tokens.cacheRead ?? 0) + (tokens.cacheWrite ?? 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAggregate[] {
|
|
71
98
|
const {
|
|
72
99
|
agentId,
|
|
@@ -134,21 +161,52 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
134
161
|
if (!stream) {
|
|
135
162
|
stream = {
|
|
136
163
|
key: streamKey,
|
|
137
|
-
tokens:
|
|
164
|
+
tokens: zeroTokens(),
|
|
138
165
|
requestCount: 0,
|
|
139
166
|
windowed: { dayTokens: 0, weekTokens: 0, monthTokens: 0, totalTokens: 0 },
|
|
140
167
|
};
|
|
141
168
|
session.streamMap.set(streamKeyStr, stream);
|
|
142
169
|
}
|
|
143
170
|
|
|
144
|
-
|
|
171
|
+
const contextSize = getContextSize(row.tokens);
|
|
172
|
+
const bucketTokens = normalizeTokens(row.tokens);
|
|
173
|
+
|
|
174
|
+
if (contextSize > LONG_CONTEXT_THRESHOLD) {
|
|
175
|
+
stream.longContextTokens = sumTokens(stream.longContextTokens ?? zeroTokens(), bucketTokens);
|
|
176
|
+
stream.longContextRequestCount = (stream.longContextRequestCount ?? 0) + 1;
|
|
177
|
+
} else {
|
|
178
|
+
stream.tokens = sumTokens(stream.tokens, bucketTokens);
|
|
179
|
+
}
|
|
180
|
+
|
|
145
181
|
stream.requestCount += 1;
|
|
146
182
|
|
|
147
183
|
const msgTokens = totalTokenCount(row.tokens);
|
|
148
184
|
stream.windowed.totalTokens += msgTokens;
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
185
|
+
if (contextSize > LONG_CONTEXT_THRESHOLD) {
|
|
186
|
+
stream.windowed.longContextTotalTokens =
|
|
187
|
+
(stream.windowed.longContextTotalTokens ?? 0) + msgTokens;
|
|
188
|
+
}
|
|
189
|
+
if (row.timestamp >= startOfDay) {
|
|
190
|
+
stream.windowed.dayTokens += msgTokens;
|
|
191
|
+
if (contextSize > LONG_CONTEXT_THRESHOLD) {
|
|
192
|
+
stream.windowed.longContextDayTokens =
|
|
193
|
+
(stream.windowed.longContextDayTokens ?? 0) + msgTokens;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (row.timestamp >= startOfWeek) {
|
|
197
|
+
stream.windowed.weekTokens += msgTokens;
|
|
198
|
+
if (contextSize > LONG_CONTEXT_THRESHOLD) {
|
|
199
|
+
stream.windowed.longContextWeekTokens =
|
|
200
|
+
(stream.windowed.longContextWeekTokens ?? 0) + msgTokens;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (row.timestamp >= startOfMonth) {
|
|
204
|
+
stream.windowed.monthTokens += msgTokens;
|
|
205
|
+
if (contextSize > LONG_CONTEXT_THRESHOLD) {
|
|
206
|
+
stream.windowed.longContextMonthTokens =
|
|
207
|
+
(stream.windowed.longContextMonthTokens ?? 0) + msgTokens;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
152
210
|
if ((row.metadata?.isEstimated as boolean) === true) {
|
|
153
211
|
session.hasEstimated = true;
|
|
154
212
|
}
|
|
@@ -168,13 +226,24 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
168
226
|
let totalRequestCount = 0;
|
|
169
227
|
|
|
170
228
|
for (const [streamKeyStr, stream] of session.streamMap) {
|
|
171
|
-
|
|
229
|
+
const totalStreamTokens = sumTokens(stream.tokens, stream.longContextTokens ?? zeroTokens());
|
|
230
|
+
const streamAggregate: AgentSessionStream = {
|
|
172
231
|
providerId: stream.key.providerId,
|
|
173
232
|
modelId: stream.key.modelId,
|
|
174
233
|
tokens: stream.tokens,
|
|
175
234
|
requestCount: stream.requestCount,
|
|
176
|
-
}
|
|
177
|
-
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (stream.longContextTokens) {
|
|
238
|
+
streamAggregate.longContextTokens = stream.longContextTokens;
|
|
239
|
+
}
|
|
240
|
+
if (stream.longContextRequestCount) {
|
|
241
|
+
streamAggregate.longContextRequestCount = stream.longContextRequestCount;
|
|
242
|
+
streamAggregate.hasLongContext = true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
streams.push(streamAggregate);
|
|
246
|
+
totals = sumTokens(totals, totalStreamTokens);
|
|
178
247
|
totalRequestCount += stream.requestCount;
|
|
179
248
|
streamWindowedTokens.set(streamKeyStr, stream.windowed);
|
|
180
249
|
}
|
|
@@ -236,3 +305,122 @@ export function deduplicateAggregates(
|
|
|
236
305
|
}
|
|
237
306
|
return Array.from(deduped.values());
|
|
238
307
|
}
|
|
308
|
+
|
|
309
|
+
export interface CrossAgentShadowMetadata {
|
|
310
|
+
kind: "cross-agent-shadow";
|
|
311
|
+
shadowOfAgentId: string;
|
|
312
|
+
shadowOfSessionId: string;
|
|
313
|
+
reason: "proxy-sdk-session-id" | "heuristic";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function isCrossAgentShadow(session: AgentSessionAggregate): boolean {
|
|
317
|
+
const dedup = session.metadata?.dedup as { kind?: string } | undefined;
|
|
318
|
+
return dedup?.kind === "cross-agent-shadow";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function selectEffectiveSessions(
|
|
322
|
+
sessions: AgentSessionAggregate[],
|
|
323
|
+
): AgentSessionAggregate[] {
|
|
324
|
+
return sessions.filter((s) => !isCrossAgentShadow(s));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const CROSS_AGENT_TIMESTAMP_TOLERANCE_MS = 120_000;
|
|
328
|
+
|
|
329
|
+
function normalizeProjectPath(p: string | undefined): string {
|
|
330
|
+
if (!p) return "";
|
|
331
|
+
return p.replace(/\/+$/, "");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function markCrossAgentShadows(
|
|
335
|
+
aggregates: AgentSessionAggregate[],
|
|
336
|
+
): AgentSessionAggregate[] {
|
|
337
|
+
const opencodeSessions: AgentSessionAggregate[] = [];
|
|
338
|
+
const claudeCodeById = new Map<string, AgentSessionAggregate>();
|
|
339
|
+
const claudeCodeByPath = new Map<string, AgentSessionAggregate[]>();
|
|
340
|
+
const ccStreamKeys = new Map<string, string[]>();
|
|
341
|
+
|
|
342
|
+
for (const agg of aggregates) {
|
|
343
|
+
if (agg.agentId === "opencode") {
|
|
344
|
+
opencodeSessions.push(agg);
|
|
345
|
+
} else if (agg.agentId === "claude-code") {
|
|
346
|
+
claudeCodeById.set(agg.sessionId, agg);
|
|
347
|
+
const path = normalizeProjectPath(agg.projectPath);
|
|
348
|
+
if (path) {
|
|
349
|
+
const bucket = claudeCodeByPath.get(path);
|
|
350
|
+
if (bucket) bucket.push(agg);
|
|
351
|
+
else claudeCodeByPath.set(path, [agg]);
|
|
352
|
+
}
|
|
353
|
+
const keys: string[] = new Array(agg.streams.length);
|
|
354
|
+
for (let i = 0; i < agg.streams.length; i++) {
|
|
355
|
+
const s = agg.streams[i]!;
|
|
356
|
+
keys[i] = `${s.providerId}::${s.modelId}`;
|
|
357
|
+
}
|
|
358
|
+
ccStreamKeys.set(agg.sessionId, keys);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (opencodeSessions.length === 0 || claudeCodeById.size === 0) {
|
|
363
|
+
return aggregates;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const claimed = new Set<string>();
|
|
367
|
+
|
|
368
|
+
for (const oc of opencodeSessions) {
|
|
369
|
+
const proxy = oc.metadata?.proxy as { sdkSessionId?: string } | undefined;
|
|
370
|
+
if (!proxy?.sdkSessionId) continue;
|
|
371
|
+
const cc = claudeCodeById.get(proxy.sdkSessionId);
|
|
372
|
+
if (!cc || claimed.has(cc.sessionId)) continue;
|
|
373
|
+
cc.metadata = {
|
|
374
|
+
...cc.metadata,
|
|
375
|
+
dedup: {
|
|
376
|
+
kind: "cross-agent-shadow",
|
|
377
|
+
shadowOfAgentId: "opencode",
|
|
378
|
+
shadowOfSessionId: oc.sessionId,
|
|
379
|
+
reason: "proxy-sdk-session-id",
|
|
380
|
+
} satisfies CrossAgentShadowMetadata,
|
|
381
|
+
};
|
|
382
|
+
claimed.add(cc.sessionId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const oc of opencodeSessions) {
|
|
386
|
+
if (oc.streams.length === 0) continue;
|
|
387
|
+
const ocPath = normalizeProjectPath(oc.projectPath);
|
|
388
|
+
if (!ocPath) continue;
|
|
389
|
+
const bucket = claudeCodeByPath.get(ocPath);
|
|
390
|
+
if (!bucket) continue;
|
|
391
|
+
|
|
392
|
+
const ocKeys = new Set<string>();
|
|
393
|
+
for (const s of oc.streams) ocKeys.add(`${s.providerId}::${s.modelId}`);
|
|
394
|
+
|
|
395
|
+
for (const cc of bucket) {
|
|
396
|
+
if (claimed.has(cc.sessionId)) continue;
|
|
397
|
+
if (Math.abs(oc.lastActivityAt - cc.lastActivityAt) > CROSS_AGENT_TIMESTAMP_TOLERANCE_MS) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const ccKeys = ccStreamKeys.get(cc.sessionId);
|
|
401
|
+
if (!ccKeys || ccKeys.length === 0) continue;
|
|
402
|
+
let overlap = false;
|
|
403
|
+
for (const k of ccKeys) {
|
|
404
|
+
if (ocKeys.has(k)) {
|
|
405
|
+
overlap = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!overlap) continue;
|
|
410
|
+
|
|
411
|
+
cc.metadata = {
|
|
412
|
+
...cc.metadata,
|
|
413
|
+
dedup: {
|
|
414
|
+
kind: "cross-agent-shadow",
|
|
415
|
+
shadowOfAgentId: "opencode",
|
|
416
|
+
shadowOfSessionId: oc.sessionId,
|
|
417
|
+
reason: "heuristic",
|
|
418
|
+
} satisfies CrossAgentShadowMetadata,
|
|
419
|
+
};
|
|
420
|
+
claimed.add(cc.sessionId);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return aggregates;
|
|
426
|
+
}
|