@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
@@ -0,0 +1,584 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type { ModelPricing } from "@tokentop/plugin-sdk";
3
+ import { estimateCost } from "@/pricing/estimator.ts";
4
+ import type { AgentSessionAggregate, AgentSessionStream } from "./types.ts";
5
+
6
+ const getPricing = mock<(providerId: string, modelId: string) => Promise<ModelPricing | null>>(
7
+ async () => null,
8
+ );
9
+
10
+ mock.module("@/pricing/index.ts", () => ({
11
+ estimateCost,
12
+ getPricing,
13
+ }));
14
+
15
+ const { priceSession, priceSessions, priceStream } = await import("./costing.ts");
16
+
17
+ function makeStream(overrides: Partial<AgentSessionStream> = {}): AgentSessionStream {
18
+ return {
19
+ providerId: "anthropic",
20
+ modelId: "claude-sonnet-4",
21
+ tokens: { input: 0, output: 0 },
22
+ requestCount: 1,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function makeSession(
28
+ overrides: Partial<AgentSessionAggregate> & Pick<AgentSessionAggregate, "sessionId">,
29
+ ): AgentSessionAggregate {
30
+ return {
31
+ agentId: "opencode",
32
+ agentName: "OpenCode",
33
+ startedAt: 0,
34
+ lastActivityAt: 0,
35
+ status: "idle",
36
+ totals: { input: 0, output: 0 },
37
+ requestCount: 0,
38
+ streams: [],
39
+ costInDay: 0,
40
+ costInWeek: 0,
41
+ costInMonth: 0,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ beforeEach(() => {
47
+ getPricing.mockReset();
48
+ getPricing.mockResolvedValue(null);
49
+ });
50
+
51
+ afterEach(() => {
52
+ getPricing.mockClear();
53
+ });
54
+
55
+ describe("priceStream()", () => {
56
+ test("prices a single stream with fallback pricing and cache breakdown", async () => {
57
+ getPricing.mockResolvedValue({
58
+ input: 2,
59
+ output: 4,
60
+ cacheRead: 1,
61
+ cacheWrite: 3,
62
+ source: "fallback",
63
+ });
64
+
65
+ const priced = await priceStream(
66
+ makeStream({
67
+ tokens: { input: 500_000, output: 250_000, cacheRead: 100_000, cacheWrite: 50_000 },
68
+ }),
69
+ );
70
+
71
+ expect(getPricing).toHaveBeenCalledWith("anthropic", "claude-sonnet-4");
72
+ expect(priced).toMatchObject({
73
+ costUsd: 2.25,
74
+ costBreakdown: {
75
+ total: 2.25,
76
+ input: 1,
77
+ output: 1,
78
+ cacheRead: 0.1,
79
+ cacheWrite: 0.15,
80
+ },
81
+ pricingSource: "fallback",
82
+ });
83
+ });
84
+
85
+ test("marks priced streams as models.dev when pricing comes from models.dev", async () => {
86
+ getPricing.mockResolvedValue({
87
+ input: 1.5,
88
+ output: 6,
89
+ source: "models.dev",
90
+ });
91
+
92
+ const priced = await priceStream(
93
+ makeStream({
94
+ providerId: "openai",
95
+ modelId: "gpt-4.1",
96
+ tokens: { input: 200_000, output: 300_000 },
97
+ }),
98
+ );
99
+
100
+ expect(priced.costUsd).toBe(2.1);
101
+ expect(priced.costBreakdown).toEqual({
102
+ total: 2.1,
103
+ input: 0.3,
104
+ output: 1.8,
105
+ });
106
+ expect(priced.pricingSource).toBe("models.dev");
107
+ });
108
+
109
+ test("returns unknown pricing source when no pricing is available", () => {
110
+ const stream = makeStream({ tokens: { input: 123, output: 456 } });
111
+
112
+ return expect(priceStream(stream)).resolves.toEqual({
113
+ ...stream,
114
+ pricingSource: "unknown",
115
+ });
116
+ });
117
+
118
+ test("keeps base-only pricing identical when tiered pricing metadata exists", async () => {
119
+ getPricing.mockResolvedValue({
120
+ input: 2,
121
+ output: 4,
122
+ cacheRead: 1,
123
+ cacheWrite: 3,
124
+ longContextInput: 20,
125
+ longContextOutput: 40,
126
+ longContextCacheRead: 10,
127
+ longContextCacheWrite: 30,
128
+ source: "fallback",
129
+ });
130
+
131
+ const priced = await priceStream(
132
+ makeStream({
133
+ tokens: { input: 500_000, output: 250_000, cacheRead: 100_000, cacheWrite: 50_000 },
134
+ }),
135
+ );
136
+
137
+ expect(priced.costUsd).toBe(2.25);
138
+ expect(priced.costBreakdown).toEqual({
139
+ total: 2.25,
140
+ input: 1,
141
+ output: 1,
142
+ cacheRead: 0.1,
143
+ cacheWrite: 0.15,
144
+ });
145
+ expect(priced.hasLongContext).toBeUndefined();
146
+ });
147
+
148
+ test("applies long-context tiered pricing to a long-context-only stream", async () => {
149
+ getPricing.mockResolvedValue({
150
+ input: 2,
151
+ output: 4,
152
+ cacheRead: 1,
153
+ cacheWrite: 3,
154
+ longContextInput: 4,
155
+ longContextOutput: 8,
156
+ longContextCacheRead: 2,
157
+ longContextCacheWrite: 6,
158
+ source: "models.dev",
159
+ });
160
+
161
+ const priced = await priceStream(
162
+ makeStream({
163
+ tokens: { input: 0, output: 0 },
164
+ longContextTokens: {
165
+ input: 200_000,
166
+ output: 100_000,
167
+ cacheRead: 50_000,
168
+ cacheWrite: 25_000,
169
+ },
170
+ longContextRequestCount: 1,
171
+ }),
172
+ );
173
+
174
+ expect(priced.costUsd).toBe(1.85);
175
+ expect(priced.costBreakdown).toEqual({
176
+ total: 1.85,
177
+ input: 0.8,
178
+ output: 0.8,
179
+ cacheRead: 0.1,
180
+ cacheWrite: 0.15,
181
+ });
182
+ expect(priced.hasLongContext).toBe(true);
183
+ expect(priced.pricingSource).toBe("models.dev");
184
+ });
185
+
186
+ test("sums base and long-context buckets for mixed streams", async () => {
187
+ getPricing.mockResolvedValue({
188
+ input: 3,
189
+ output: 9,
190
+ cacheRead: 1,
191
+ cacheWrite: 4,
192
+ longContextInput: 6,
193
+ longContextOutput: 18,
194
+ longContextCacheRead: 2,
195
+ longContextCacheWrite: 8,
196
+ source: "fallback",
197
+ });
198
+
199
+ const priced = await priceStream(
200
+ makeStream({
201
+ tokens: { input: 100_000, output: 50_000, cacheRead: 10_000, cacheWrite: 5_000 },
202
+ longContextTokens: {
203
+ input: 250_000,
204
+ output: 100_000,
205
+ cacheRead: 50_000,
206
+ cacheWrite: 25_000,
207
+ },
208
+ longContextRequestCount: 2,
209
+ }),
210
+ );
211
+
212
+ expect(priced.costUsd).toBe(4.38);
213
+ expect(priced.costBreakdown).toEqual({
214
+ total: 4.38,
215
+ input: 1.8,
216
+ output: 2.25,
217
+ cacheRead: 0.11,
218
+ cacheWrite: 0.22,
219
+ });
220
+ expect(priced.hasLongContext).toBe(true);
221
+ });
222
+
223
+ test("uses base pricing conservatively when long-context pricing is unavailable", async () => {
224
+ getPricing.mockResolvedValue({
225
+ input: 2,
226
+ output: 6,
227
+ cacheRead: 1,
228
+ cacheWrite: 3,
229
+ source: "fallback",
230
+ });
231
+
232
+ const priced = await priceStream(
233
+ makeStream({
234
+ tokens: { input: 100_000, output: 50_000, cacheRead: 20_000, cacheWrite: 10_000 },
235
+ longContextTokens: {
236
+ input: 250_000,
237
+ output: 100_000,
238
+ cacheRead: 80_000,
239
+ cacheWrite: 40_000,
240
+ },
241
+ longContextRequestCount: 1,
242
+ }),
243
+ );
244
+
245
+ expect(priced.costUsd).toBe(1.85);
246
+ expect(priced.costBreakdown).toEqual({
247
+ total: 1.85,
248
+ input: 0.7,
249
+ output: 0.9,
250
+ cacheRead: 0.1,
251
+ cacheWrite: 0.15,
252
+ });
253
+ expect(priced.hasLongContext).toBe(true);
254
+ });
255
+
256
+ test("handles empty long-context buckets without NaN or errors", async () => {
257
+ getPricing.mockResolvedValue({
258
+ input: 2,
259
+ output: 4,
260
+ longContextInput: 6,
261
+ longContextOutput: 12,
262
+ source: "fallback",
263
+ });
264
+
265
+ const priced = await priceStream(
266
+ makeStream({
267
+ tokens: { input: 300_000, output: 100_000 },
268
+ longContextTokens: { input: 0, output: 0 },
269
+ longContextRequestCount: 1,
270
+ }),
271
+ );
272
+
273
+ expect(priced.costUsd).toBe(1);
274
+ expect(priced.costBreakdown).toEqual({
275
+ total: 1,
276
+ input: 0.6,
277
+ output: 0.4,
278
+ });
279
+ expect(Number.isNaN(priced.costUsd ?? 0)).toBe(false);
280
+ expect(priced.hasLongContext).toBe(true);
281
+ });
282
+ });
283
+
284
+ describe("priceSession()", () => {
285
+ test("prices a multi-stream session and removes internal window metadata", async () => {
286
+ getPricing.mockImplementation(async (providerId, modelId) => {
287
+ if (`${providerId}::${modelId}` === "anthropic::claude-sonnet-4") {
288
+ return { input: 2, output: 4, cacheRead: 1, cacheWrite: 3, source: "fallback" };
289
+ }
290
+
291
+ if (`${providerId}::${modelId}` === "openai::gpt-4.1") {
292
+ return { input: 1.5, output: 6, source: "models.dev" };
293
+ }
294
+
295
+ return null;
296
+ });
297
+
298
+ const priced = await priceSession(
299
+ makeSession({
300
+ sessionId: "multi-stream",
301
+ requestCount: 3,
302
+ streams: [
303
+ makeStream({
304
+ tokens: { input: 500_000, output: 250_000, cacheRead: 100_000, cacheWrite: 50_000 },
305
+ requestCount: 2,
306
+ }),
307
+ makeStream({
308
+ providerId: "openai",
309
+ modelId: "gpt-4.1",
310
+ tokens: { input: 200_000, output: 300_000 },
311
+ requestCount: 1,
312
+ }),
313
+ ],
314
+ _streamWindowedTokens: new Map([
315
+ [
316
+ "anthropic::claude-sonnet-4",
317
+ { dayTokens: 400, weekTokens: 500, monthTokens: 750, totalTokens: 1000 },
318
+ ],
319
+ [
320
+ "openai::gpt-4.1",
321
+ { dayTokens: 1000, weekTokens: 1200, monthTokens: 1500, totalTokens: 2000 },
322
+ ],
323
+ ]),
324
+ }),
325
+ );
326
+
327
+ expect(priced.totalCostUsd).toBe(4.35);
328
+ expect(priced.costInDay).toBeCloseTo(1.95, 10);
329
+ expect(priced.costInWeek).toBeCloseTo(2.385, 10);
330
+ expect(priced.costInMonth).toBeCloseTo(3.2625, 10);
331
+ expect(priced.streams.map((stream) => stream.pricingSource)).toEqual([
332
+ "fallback",
333
+ "models.dev",
334
+ ]);
335
+ expect(priced).not.toHaveProperty("_streamWindowedTokens");
336
+ });
337
+
338
+ test("uses the current windowed cost ratio formula for day, week, and month costs", async () => {
339
+ getPricing.mockResolvedValue({ input: 10, output: 0, source: "fallback" });
340
+
341
+ const priced = await priceSession(
342
+ makeSession({
343
+ sessionId: "ratio",
344
+ streams: [makeStream({ tokens: { input: 1_000_000, output: 0 } })],
345
+ _streamWindowedTokens: new Map([
346
+ [
347
+ "anthropic::claude-sonnet-4",
348
+ { dayTokens: 500, weekTokens: 750, monthTokens: 1000, totalTokens: 1000 },
349
+ ],
350
+ ]),
351
+ }),
352
+ );
353
+
354
+ expect(priced.totalCostUsd).toBe(10);
355
+ expect(priced.costInDay).toBe(5);
356
+ expect(priced.costInWeek).toBe(7.5);
357
+ expect(priced.costInMonth).toBe(10);
358
+ });
359
+
360
+ test("returns zero costs for zero-token streams without dividing by zero", async () => {
361
+ getPricing.mockResolvedValue({ input: 3, output: 15, source: "fallback" });
362
+
363
+ const priced = await priceSession(
364
+ makeSession({
365
+ sessionId: "zero-token",
366
+ streams: [makeStream({ tokens: { input: 0, output: 0 }, requestCount: 0 })],
367
+ _streamWindowedTokens: new Map([
368
+ [
369
+ "anthropic::claude-sonnet-4",
370
+ { dayTokens: 0, weekTokens: 0, monthTokens: 0, totalTokens: 0 },
371
+ ],
372
+ ]),
373
+ }),
374
+ );
375
+
376
+ expect(priced.totalCostUsd).toBe(0);
377
+ expect(priced.costInDay).toBe(0);
378
+ expect(priced.costInWeek).toBe(0);
379
+ expect(priced.costInMonth).toBe(0);
380
+ expect(priced.streams[0]).toMatchObject({
381
+ costUsd: 0,
382
+ costBreakdown: { total: 0, input: 0, output: 0 },
383
+ pricingSource: "fallback",
384
+ });
385
+ });
386
+
387
+ test("returns an empty session unchanged when there are no streams", async () => {
388
+ const priced = await priceSession(makeSession({ sessionId: "empty" }));
389
+
390
+ expect(priced.streams).toEqual([]);
391
+ expect(priced.totalCostUsd).toBeUndefined();
392
+ expect(priced.costInDay).toBe(0);
393
+ expect(priced.costInWeek).toBe(0);
394
+ expect(priced.costInMonth).toBe(0);
395
+ });
396
+
397
+ test("ignores windowed allocations for streams without pricing", async () => {
398
+ getPricing.mockResolvedValue(null);
399
+
400
+ const priced = await priceSession(
401
+ makeSession({
402
+ sessionId: "unknown-pricing",
403
+ streams: [makeStream({ tokens: { input: 1_000_000, output: 500_000 } })],
404
+ _streamWindowedTokens: new Map([
405
+ [
406
+ "anthropic::claude-sonnet-4",
407
+ { dayTokens: 500, weekTokens: 800, monthTokens: 1000, totalTokens: 1000 },
408
+ ],
409
+ ]),
410
+ }),
411
+ );
412
+
413
+ expect(priced.totalCostUsd).toBeUndefined();
414
+ expect(priced.costInDay).toBe(0);
415
+ expect(priced.costInWeek).toBe(0);
416
+ expect(priced.costInMonth).toBe(0);
417
+ expect(priced.streams[0]?.pricingSource).toBe("unknown");
418
+ });
419
+
420
+ test("prices windowed costs with per-bucket ratios instead of a blended ratio", async () => {
421
+ getPricing.mockResolvedValue({
422
+ input: 2,
423
+ output: 0,
424
+ longContextInput: 6,
425
+ longContextOutput: 0,
426
+ source: "fallback",
427
+ });
428
+
429
+ const priced = await priceSession(
430
+ makeSession({
431
+ sessionId: "windowed-buckets",
432
+ streams: [
433
+ makeStream({
434
+ tokens: { input: 1_000_000, output: 0 },
435
+ longContextTokens: { input: 1_000_000, output: 0 },
436
+ longContextRequestCount: 1,
437
+ }),
438
+ ],
439
+ _streamWindowedTokens: new Map([
440
+ [
441
+ "anthropic::claude-sonnet-4",
442
+ {
443
+ dayTokens: 1_000_000,
444
+ weekTokens: 500_000,
445
+ monthTokens: 1_000_000,
446
+ totalTokens: 1_000_000,
447
+ longContextDayTokens: 0,
448
+ longContextWeekTokens: 250_000,
449
+ longContextMonthTokens: 1_000_000,
450
+ longContextTotalTokens: 1_000_000,
451
+ },
452
+ ],
453
+ ]),
454
+ }),
455
+ );
456
+
457
+ expect(priced.totalCostUsd).toBe(8);
458
+ expect(priced.costInDay).toBe(2);
459
+ expect(priced.costInWeek).toBe(2.5);
460
+ expect(priced.costInMonth).toBe(8);
461
+ });
462
+
463
+ test("prices multi-stream sessions with mixed tiered and non-tiered long-context streams", async () => {
464
+ getPricing.mockImplementation(async (providerId, modelId) => {
465
+ if (`${providerId}::${modelId}` === "anthropic::claude-sonnet-4") {
466
+ return {
467
+ input: 2,
468
+ output: 0,
469
+ longContextInput: 6,
470
+ longContextOutput: 0,
471
+ source: "fallback",
472
+ };
473
+ }
474
+
475
+ if (`${providerId}::${modelId}` === "openai::gpt-4.1") {
476
+ return {
477
+ input: 4,
478
+ output: 0,
479
+ source: "fallback",
480
+ };
481
+ }
482
+
483
+ return null;
484
+ });
485
+
486
+ const priced = await priceSession(
487
+ makeSession({
488
+ sessionId: "mixed-tiered-session",
489
+ streams: [
490
+ makeStream({
491
+ tokens: { input: 500_000, output: 0 },
492
+ longContextTokens: { input: 250_000, output: 0 },
493
+ longContextRequestCount: 1,
494
+ }),
495
+ makeStream({
496
+ providerId: "openai",
497
+ modelId: "gpt-4.1",
498
+ tokens: { input: 500_000, output: 0 },
499
+ longContextTokens: { input: 250_000, output: 0 },
500
+ longContextRequestCount: 1,
501
+ }),
502
+ ],
503
+ _streamWindowedTokens: new Map([
504
+ [
505
+ "anthropic::claude-sonnet-4",
506
+ {
507
+ dayTokens: 250_000,
508
+ weekTokens: 500_000,
509
+ monthTokens: 500_000,
510
+ totalTokens: 500_000,
511
+ longContextDayTokens: 50_000,
512
+ longContextWeekTokens: 100_000,
513
+ longContextMonthTokens: 250_000,
514
+ longContextTotalTokens: 250_000,
515
+ },
516
+ ],
517
+ [
518
+ "openai::gpt-4.1",
519
+ {
520
+ dayTokens: 100_000,
521
+ weekTokens: 250_000,
522
+ monthTokens: 500_000,
523
+ totalTokens: 500_000,
524
+ longContextDayTokens: 125_000,
525
+ longContextWeekTokens: 125_000,
526
+ longContextMonthTokens: 250_000,
527
+ longContextTotalTokens: 250_000,
528
+ },
529
+ ],
530
+ ]),
531
+ }),
532
+ );
533
+
534
+ expect(priced.totalCostUsd).toBe(5.5);
535
+ expect(priced.costInDay).toBe(1.7);
536
+ expect(priced.costInWeek).toBe(3.1);
537
+ expect(priced.costInMonth).toBe(5.5);
538
+ expect(priced.streams.map((stream) => stream.hasLongContext)).toEqual([true, true]);
539
+ });
540
+ });
541
+
542
+ describe("priceSessions()", () => {
543
+ test("prices sessions in a batch", async () => {
544
+ getPricing.mockImplementation(async (providerId, modelId) => {
545
+ if (`${providerId}::${modelId}` === "anthropic::claude-sonnet-4") {
546
+ return { input: 2, output: 4, source: "fallback" };
547
+ }
548
+
549
+ if (`${providerId}::${modelId}` === "openai::gpt-4.1") {
550
+ return { input: 1, output: 2, source: "fallback" };
551
+ }
552
+
553
+ return null;
554
+ });
555
+
556
+ const priced = await priceSessions([
557
+ makeSession({
558
+ sessionId: "batch-a",
559
+ streams: [makeStream({ tokens: { input: 500_000, output: 250_000 } })],
560
+ }),
561
+ makeSession({
562
+ sessionId: "batch-b",
563
+ streams: [
564
+ makeStream({
565
+ providerId: "openai",
566
+ modelId: "gpt-4.1",
567
+ tokens: { input: 1_000_000, output: 500_000 },
568
+ }),
569
+ ],
570
+ }),
571
+ ]);
572
+
573
+ expect(priced).toHaveLength(2);
574
+ expect(
575
+ priced.map((session) => ({
576
+ sessionId: session.sessionId,
577
+ totalCostUsd: session.totalCostUsd,
578
+ })),
579
+ ).toEqual([
580
+ { sessionId: "batch-a", totalCostUsd: 2 },
581
+ { sessionId: "batch-b", totalCostUsd: 2 },
582
+ ]);
583
+ });
584
+ });