@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.
Files changed (43) hide show
  1. package/README.md +33 -11
  2. package/package.json +4 -4
  3. package/src/agents/aggregator.test.ts +269 -1
  4. package/src/agents/aggregator.ts +196 -8
  5. package/src/agents/costing.test.ts +584 -0
  6. package/src/agents/costing.ts +144 -30
  7. package/src/agents/types.ts +7 -0
  8. package/src/config/schema.ts +1 -1
  9. package/src/demo/simulator.test.ts +92 -0
  10. package/src/demo/simulator.ts +579 -211
  11. package/src/plugins/notification-bus.ts +27 -0
  12. package/src/plugins/notifications/terminal-bell.ts +80 -16
  13. package/src/plugins/notifications/visual-flash.ts +5 -18
  14. package/src/plugins/plugin-context-factory.ts +9 -1
  15. package/src/plugins/providers/anthropic.test.ts +145 -0
  16. package/src/plugins/providers/anthropic.ts +18 -12
  17. package/src/plugins/providers/chutes.ts +2 -0
  18. package/src/plugins/providers/github-copilot.ts +3 -0
  19. package/src/plugins/providers/minimax.ts +2 -0
  20. package/src/plugins/providers/openai-api.ts +4 -0
  21. package/src/plugins/providers/zai.ts +2 -0
  22. package/src/pricing/estimator.ts +7 -0
  23. package/src/pricing/index.ts +5 -0
  24. package/src/pricing/models-dev.test.ts +117 -0
  25. package/src/pricing/models-dev.ts +22 -0
  26. package/src/storage/persistence-service.test.ts +201 -0
  27. package/src/storage/persistence-service.ts +54 -5
  28. package/src/storage/repos/agentSessions.ts +49 -0
  29. package/src/tui/App.tsx +10 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
  32. package/src/tui/components/SessionsTable.tsx +25 -7
  33. package/src/tui/components/SettingsModal.tsx +20 -4
  34. package/src/tui/components/Toast.tsx +1 -1
  35. package/src/tui/contexts/AgentSessionContext.tsx +59 -21
  36. package/src/tui/contexts/PluginContext.tsx +39 -10
  37. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  38. package/src/tui/utils/scrollFollow.test.ts +71 -0
  39. package/src/tui/utils/scrollFollow.ts +12 -0
  40. package/src/tui/views/Dashboard.tsx +0 -6
  41. package/src/tui/views/RealTimeDashboard.tsx +105 -38
  42. package/src/tui/views/SettingsView.tsx +26 -8
  43. package/src/version.ts +1 -1
@@ -1,34 +1,21 @@
1
+ import type { CostBreakdown, ModelPricing } from "@tokentop/plugin-sdk";
1
2
  import { estimateCost, getPricing } from "@/pricing/index.ts";
2
3
  import type { AgentSessionAggregate, AgentSessionStream, StreamCostBreakdown } from "./types.ts";
3
4
 
4
5
  export async function priceStream(stream: AgentSessionStream): Promise<AgentSessionStream> {
5
6
  const pricing = await getPricing(stream.providerId, stream.modelId);
6
7
 
7
- if (!pricing) {
8
- return { ...stream, pricingSource: "unknown" };
9
- }
10
-
11
- const breakdown = estimateCost(stream.tokens, pricing);
12
- const source = pricing.source === "models.dev" ? "models.dev" : "fallback";
13
-
14
- const costBreakdown: StreamCostBreakdown = {
15
- total: breakdown.total,
16
- input: breakdown.input ?? 0,
17
- output: breakdown.output ?? 0,
18
- };
19
- if (breakdown.cacheRead) costBreakdown.cacheRead = breakdown.cacheRead;
20
- if (breakdown.cacheWrite) costBreakdown.cacheWrite = breakdown.cacheWrite;
21
-
22
- return {
23
- ...stream,
24
- costUsd: breakdown.total,
25
- costBreakdown,
26
- pricingSource: source,
27
- };
8
+ return priceStreamWithPricing(stream, pricing).stream;
28
9
  }
29
10
 
30
11
  export async function priceSession(session: AgentSessionAggregate): Promise<AgentSessionAggregate> {
31
- const pricedStreams = await Promise.all(session.streams.map(priceStream));
12
+ const pricedResults = await Promise.all(
13
+ session.streams.map(async (stream) => {
14
+ const pricing = await getPricing(stream.providerId, stream.modelId);
15
+ return priceStreamWithPricing(stream, pricing);
16
+ }),
17
+ );
18
+ const pricedStreams = pricedResults.map((result) => result.stream);
32
19
 
33
20
  const totalCostUsd = pricedStreams.reduce((sum, s) => {
34
21
  return sum + (s.costUsd ?? 0);
@@ -41,18 +28,37 @@ export async function priceSession(session: AgentSessionAggregate): Promise<Agen
41
28
  let costInMonth = 0;
42
29
 
43
30
  if (hasAnyCost && session._streamWindowedTokens) {
44
- for (const stream of pricedStreams) {
45
- const streamCost = stream.costUsd ?? 0;
31
+ for (const result of pricedResults) {
32
+ const streamCost = result.stream.costUsd ?? 0;
46
33
  if (streamCost === 0) continue;
47
34
 
48
- const keyStr = `${stream.providerId}::${stream.modelId}`;
35
+ const keyStr = `${result.stream.providerId}::${result.stream.modelId}`;
49
36
  const windowed = session._streamWindowedTokens.get(keyStr);
50
- if (!windowed || windowed.totalTokens === 0) continue;
37
+ if (!windowed) continue;
51
38
 
52
- const ratio = 1 / windowed.totalTokens;
53
- costInDay += streamCost * windowed.dayTokens * ratio;
54
- costInWeek += streamCost * windowed.weekTokens * ratio;
55
- costInMonth += streamCost * windowed.monthTokens * ratio;
39
+ if (result.baseBreakdown.total > 0 && windowed.totalTokens > 0) {
40
+ const baseRatio = 1 / windowed.totalTokens;
41
+ costInDay += result.baseBreakdown.total * windowed.dayTokens * baseRatio;
42
+ costInWeek += result.baseBreakdown.total * windowed.weekTokens * baseRatio;
43
+ costInMonth += result.baseBreakdown.total * windowed.monthTokens * baseRatio;
44
+ }
45
+
46
+ const longContextTotalTokens = windowed.longContextTotalTokens ?? 0;
47
+ if (result.longContextBreakdown.total > 0 && longContextTotalTokens > 0) {
48
+ const longContextRatio = 1 / longContextTotalTokens;
49
+ costInDay +=
50
+ result.longContextBreakdown.total *
51
+ (windowed.longContextDayTokens ?? 0) *
52
+ longContextRatio;
53
+ costInWeek +=
54
+ result.longContextBreakdown.total *
55
+ (windowed.longContextWeekTokens ?? 0) *
56
+ longContextRatio;
57
+ costInMonth +=
58
+ result.longContextBreakdown.total *
59
+ (windowed.longContextMonthTokens ?? 0) *
60
+ longContextRatio;
61
+ }
56
62
  }
57
63
  }
58
64
 
@@ -69,8 +75,116 @@ export async function priceSession(session: AgentSessionAggregate): Promise<Agen
69
75
  return result;
70
76
  }
71
77
 
78
+ type PricedStreamResult = {
79
+ stream: AgentSessionStream;
80
+ baseBreakdown: CostBreakdown;
81
+ longContextBreakdown: CostBreakdown;
82
+ };
83
+
84
+ function priceStreamWithPricing(
85
+ stream: AgentSessionStream,
86
+ pricing: ModelPricing | null,
87
+ ): PricedStreamResult {
88
+ const hasLongContext = (stream.longContextRequestCount ?? 0) > 0;
89
+
90
+ if (!pricing) {
91
+ return {
92
+ stream: {
93
+ ...stream,
94
+ ...(hasLongContext ? { hasLongContext: true } : {}),
95
+ pricingSource: "unknown",
96
+ },
97
+ baseBreakdown: zeroCostBreakdown(),
98
+ longContextBreakdown: zeroCostBreakdown(),
99
+ };
100
+ }
101
+
102
+ const baseBreakdown = estimateCost(stream.tokens, pricing);
103
+ const longContextBreakdown = stream.longContextTokens
104
+ ? estimateCost(
105
+ stream.longContextTokens,
106
+ hasLongContextPricing(pricing) ? buildLongContextPricing(pricing) : pricing,
107
+ )
108
+ : zeroCostBreakdown();
109
+ const breakdown = sumCostBreakdowns(baseBreakdown, longContextBreakdown);
110
+ const source = pricing.source === "models.dev" ? "models.dev" : "fallback";
111
+
112
+ return {
113
+ stream: {
114
+ ...stream,
115
+ ...(hasLongContext ? { hasLongContext: true } : {}),
116
+ costUsd: breakdown.total,
117
+ costBreakdown: toStreamCostBreakdown(breakdown),
118
+ pricingSource: source,
119
+ },
120
+ baseBreakdown,
121
+ longContextBreakdown,
122
+ };
123
+ }
124
+
72
125
  export async function priceSessions(
73
126
  sessions: AgentSessionAggregate[],
74
127
  ): Promise<AgentSessionAggregate[]> {
75
128
  return Promise.all(sessions.map(priceSession));
76
129
  }
130
+
131
+ function hasLongContextPricing(pricing: ModelPricing): boolean {
132
+ return (
133
+ pricing.longContextInput !== undefined ||
134
+ pricing.longContextOutput !== undefined ||
135
+ pricing.longContextCacheRead !== undefined ||
136
+ pricing.longContextCacheWrite !== undefined
137
+ );
138
+ }
139
+
140
+ function buildLongContextPricing(pricing: ModelPricing): ModelPricing {
141
+ return {
142
+ ...pricing,
143
+ input: pricing.longContextInput ?? pricing.input,
144
+ output: pricing.longContextOutput ?? pricing.output,
145
+ ...(pricing.longContextCacheRead !== undefined || pricing.cacheRead !== undefined
146
+ ? { cacheRead: pricing.longContextCacheRead ?? pricing.cacheRead }
147
+ : {}),
148
+ ...(pricing.longContextCacheWrite !== undefined || pricing.cacheWrite !== undefined
149
+ ? { cacheWrite: pricing.longContextCacheWrite ?? pricing.cacheWrite }
150
+ : {}),
151
+ };
152
+ }
153
+
154
+ function toStreamCostBreakdown(breakdown: CostBreakdown): StreamCostBreakdown {
155
+ const costBreakdown: StreamCostBreakdown = {
156
+ total: breakdown.total,
157
+ input: breakdown.input ?? 0,
158
+ output: breakdown.output ?? 0,
159
+ };
160
+ if (breakdown.cacheRead) costBreakdown.cacheRead = breakdown.cacheRead;
161
+ if (breakdown.cacheWrite) costBreakdown.cacheWrite = breakdown.cacheWrite;
162
+ return costBreakdown;
163
+ }
164
+
165
+ function sumCostBreakdowns(a: CostBreakdown, b: CostBreakdown): CostBreakdown {
166
+ const cacheRead = (a.cacheRead ?? 0) + (b.cacheRead ?? 0);
167
+ const cacheWrite = (a.cacheWrite ?? 0) + (b.cacheWrite ?? 0);
168
+ const breakdown: CostBreakdown = {
169
+ total: roundCost(a.total + b.total),
170
+ input: roundCost((a.input ?? 0) + (b.input ?? 0)),
171
+ output: roundCost((a.output ?? 0) + (b.output ?? 0)),
172
+ currency: a.currency,
173
+ };
174
+ if (cacheRead > 0) breakdown.cacheRead = roundCost(cacheRead);
175
+ if (cacheWrite > 0) breakdown.cacheWrite = roundCost(cacheWrite);
176
+ return breakdown;
177
+ }
178
+
179
+ function zeroCostBreakdown(): CostBreakdown {
180
+ return {
181
+ total: 0,
182
+ input: 0,
183
+ output: 0,
184
+ currency: "USD",
185
+ };
186
+ }
187
+
188
+ function roundCost(value: number): number {
189
+ return Math.round(value * 1_000_000) / 1_000_000;
190
+ }
@@ -27,6 +27,9 @@ export interface AgentSessionStream {
27
27
  modelId: string;
28
28
  tokens: TokenCounts;
29
29
  requestCount: number;
30
+ longContextTokens?: TokenCounts;
31
+ longContextRequestCount?: number;
32
+ hasLongContext?: boolean;
30
33
  costUsd?: number;
31
34
  costBreakdown?: StreamCostBreakdown;
32
35
  pricingSource?: "models.dev" | "fallback" | "unknown";
@@ -37,6 +40,10 @@ export interface StreamWindowedTokens {
37
40
  weekTokens: number;
38
41
  monthTokens: number;
39
42
  totalTokens: number;
43
+ longContextDayTokens?: number;
44
+ longContextWeekTokens?: number;
45
+ longContextMonthTokens?: number;
46
+ longContextTotalTokens?: number;
40
47
  }
41
48
 
42
49
  export interface AgentSessionAggregate {
@@ -82,7 +82,7 @@ export const DEFAULT_CONFIG: AppConfig = {
82
82
  },
83
83
  notifications: {
84
84
  toastsEnabled: true,
85
- soundEnabled: false,
85
+ soundEnabled: true,
86
86
  },
87
87
  budgets: {
88
88
  daily: null,
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DEMO_PRESETS, type DemoPreset, DemoSimulator } from "./simulator.ts";
3
+
4
+ function summarize(sim: DemoSimulator) {
5
+ const { sessions } = sim.tick();
6
+ return {
7
+ count: sessions.length,
8
+ ids: sessions.map((s) => s.sessionId),
9
+ names: sessions.map((s) => s.sessionName),
10
+ totalCost: sessions.reduce((acc, s) => acc + (s.totalCostUsd ?? 0), 0),
11
+ totalTokens: sessions.reduce((acc, s) => acc + s.totals.input + s.totals.output, 0),
12
+ uniqueModels: new Set(sessions.flatMap((s) => s.streams.map((st) => st.modelId))).size,
13
+ uniqueProviders: new Set(sessions.flatMap((s) => s.streams.map((st) => st.providerId))).size,
14
+ uniqueProjects: new Set(sessions.map((s) => s.projectPath).filter(Boolean)).size,
15
+ uniqueAgents: new Set(sessions.map((s) => s.agentId)).size,
16
+ };
17
+ }
18
+
19
+ describe("DemoSimulator determinism", () => {
20
+ for (const preset of Object.keys(DEMO_PRESETS) as DemoPreset[]) {
21
+ test(`same seed + preset=${preset} produces identical sessions`, () => {
22
+ const a = summarize(new DemoSimulator({ seed: 42, preset }));
23
+ const b = summarize(new DemoSimulator({ seed: 42, preset }));
24
+
25
+ expect(b.count).toBe(a.count);
26
+ expect(b.ids).toEqual(a.ids);
27
+ expect(b.names).toEqual(a.names);
28
+ expect(b.totalCost).toBeCloseTo(a.totalCost, 6);
29
+ expect(b.totalTokens).toBe(a.totalTokens);
30
+ });
31
+ }
32
+
33
+ test("different seeds produce different sessions", () => {
34
+ const a = summarize(new DemoSimulator({ seed: 1, preset: "heavy" }));
35
+ const b = summarize(new DemoSimulator({ seed: 2, preset: "heavy" }));
36
+
37
+ expect(b.ids).not.toEqual(a.ids);
38
+ expect(b.totalCost).not.toBeCloseTo(a.totalCost, 2);
39
+ });
40
+
41
+ test("preset controls session count", () => {
42
+ expect(summarize(new DemoSimulator({ seed: 7, preset: "light" })).count).toBe(50);
43
+ expect(summarize(new DemoSimulator({ seed: 7, preset: "normal" })).count).toBe(500);
44
+ expect(summarize(new DemoSimulator({ seed: 7, preset: "heavy" })).count).toBe(4000);
45
+ });
46
+
47
+ test("repeated ticks keep historical sessions stable", () => {
48
+ const sim = new DemoSimulator({ seed: 99, preset: "normal" });
49
+ const first = sim.tick().sessions.filter((s) => s.endedAt !== undefined);
50
+ const second = sim.tick().sessions.filter((s) => s.endedAt !== undefined);
51
+
52
+ expect(second.length).toBe(first.length);
53
+ for (let i = 0; i < first.length; i++) {
54
+ expect(second[i]!.sessionId).toBe(first[i]!.sessionId);
55
+ expect(second[i]!.totalCostUsd).toBe(first[i]!.totalCostUsd);
56
+ expect(second[i]!.totals.input).toBe(first[i]!.totals.input);
57
+ expect(second[i]!.totals.output).toBe(first[i]!.totals.output);
58
+ }
59
+ });
60
+ });
61
+
62
+ describe("DemoSimulator variety", () => {
63
+ test("heavy preset produces enough models to stress the dashboard list", () => {
64
+ const s = summarize(new DemoSimulator({ seed: 42, preset: "heavy" }));
65
+ expect(s.uniqueModels).toBeGreaterThanOrEqual(12);
66
+ expect(s.uniqueProviders).toBeGreaterThanOrEqual(5);
67
+ expect(s.uniqueProjects).toBeGreaterThanOrEqual(10);
68
+ expect(s.uniqueAgents).toBeGreaterThanOrEqual(4);
69
+ });
70
+
71
+ test("normal preset still covers multiple models and agents", () => {
72
+ const s = summarize(new DemoSimulator({ seed: 42, preset: "normal" }));
73
+ expect(s.uniqueModels).toBeGreaterThanOrEqual(8);
74
+ expect(s.uniqueAgents).toBeGreaterThanOrEqual(3);
75
+ });
76
+
77
+ test("sessions are split between active and historical", () => {
78
+ const { sessions } = new DemoSimulator({ seed: 42, preset: "heavy" }).tick();
79
+ const active = sessions.filter((s) => s.endedAt === undefined);
80
+ const historical = sessions.filter((s) => s.endedAt !== undefined);
81
+ expect(active.length).toBeGreaterThan(0);
82
+ expect(active.length).toBeLessThanOrEqual(8);
83
+ expect(historical.length).toBeGreaterThan(active.length * 10);
84
+ });
85
+
86
+ test("heavy preset has enough recent sessions to trigger scrollbar", () => {
87
+ const { sessions } = new DemoSimulator({ seed: 42, preset: "heavy" }).tick();
88
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
89
+ const recent = sessions.filter((s) => s.lastActivityAt >= cutoff);
90
+ expect(recent.length).toBeGreaterThanOrEqual(50);
91
+ });
92
+ });