@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
|
@@ -18,6 +18,16 @@ class NotificationBus {
|
|
|
18
18
|
private plugins: NotificationPlugin[] = [];
|
|
19
19
|
private recentEvents = new Map<string, DedupEntry>();
|
|
20
20
|
private pluginConfigs = new Map<string, Record<string, unknown>>();
|
|
21
|
+
private toastHandler: ((event: NotificationEvent) => void) | null = null;
|
|
22
|
+
private flashHandler: ((severity: NotificationEvent["severity"]) => void) | null = null;
|
|
23
|
+
|
|
24
|
+
setToastHandler(handler: ((event: NotificationEvent) => void) | null): void {
|
|
25
|
+
this.toastHandler = handler;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setFlashHandler(handler: ((severity: NotificationEvent["severity"]) => void) | null): void {
|
|
29
|
+
this.flashHandler = handler;
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
registerPlugins(plugins: NotificationPlugin[]): void {
|
|
23
33
|
this.plugins = plugins;
|
|
@@ -124,6 +134,23 @@ class NotificationBus {
|
|
|
124
134
|
);
|
|
125
135
|
}),
|
|
126
136
|
);
|
|
137
|
+
|
|
138
|
+
this.toastHandler?.(event);
|
|
139
|
+
this.flashHandler?.(event.severity);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async testNotification(): Promise<void> {
|
|
143
|
+
const event: NotificationEvent = {
|
|
144
|
+
type: "budget.thresholdCrossed",
|
|
145
|
+
severity: "warning",
|
|
146
|
+
title: "Test Notification",
|
|
147
|
+
message: "This is a test notification from tokentop.",
|
|
148
|
+
timestamp: Date.now(),
|
|
149
|
+
data: {},
|
|
150
|
+
};
|
|
151
|
+
// Bypass dedup for test
|
|
152
|
+
await this.emit("__test__", event);
|
|
153
|
+
this.recentEvents.delete("__test__");
|
|
127
154
|
}
|
|
128
155
|
|
|
129
156
|
private isDuplicate(key: string): boolean {
|
|
@@ -4,6 +4,80 @@ import type {
|
|
|
4
4
|
NotificationPlugin,
|
|
5
5
|
} from "@tokentop/plugin-sdk";
|
|
6
6
|
|
|
7
|
+
const SEVERITY_ORDER = ["info", "warning", "critical"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Write directly to stderr so the escape sequences bypass OpenTUI's
|
|
11
|
+
* stdout render pipeline. Most terminals still interpret BEL and OSC
|
|
12
|
+
* sequences arriving on fd 2.
|
|
13
|
+
*/
|
|
14
|
+
function writeEsc(data: string): void {
|
|
15
|
+
process.stderr.write(data);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* OSC 777 (notify;title;body) — supported by iTerm2, Kitty, WezTerm, rxvt-unicode.
|
|
20
|
+
* OSC 9 (notify;body) — supported by Windows Terminal, ConEmu.
|
|
21
|
+
* BEL (\x07) — universal fallback (if terminal has bell enabled).
|
|
22
|
+
*
|
|
23
|
+
* We send all three; unsupported sequences are silently ignored.
|
|
24
|
+
*/
|
|
25
|
+
function sendDesktopNotification(title: string, body: string): void {
|
|
26
|
+
writeEsc(`\x1b]777;notify;${title};${body}\x07`);
|
|
27
|
+
writeEsc(`\x1b]9;${title}: ${body}\x07`);
|
|
28
|
+
writeEsc("\x07");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* macOS: afplay with system sounds (Ping, Funk, Glass, etc.)
|
|
33
|
+
* Linux: paplay → aplay → canberra-gtk-play (first available)
|
|
34
|
+
*
|
|
35
|
+
* Fire-and-forget — errors are silently ignored so a missing
|
|
36
|
+
* sound binary never breaks notifications.
|
|
37
|
+
*/
|
|
38
|
+
const SEVERITY_SOUNDS: Record<string, string> = {
|
|
39
|
+
critical: "Funk.aiff",
|
|
40
|
+
warning: "Ping.aiff",
|
|
41
|
+
info: "Pop.aiff",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function playSystemSound(severity: string): void {
|
|
45
|
+
const platform = process.platform;
|
|
46
|
+
|
|
47
|
+
if (platform === "darwin") {
|
|
48
|
+
const sound = SEVERITY_SOUNDS[severity] ?? "Ping.aiff";
|
|
49
|
+
Bun.spawn(["afplay", `/System/Library/Sounds/${sound}`], {
|
|
50
|
+
stdout: "ignore",
|
|
51
|
+
stderr: "ignore",
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (platform === "linux") {
|
|
57
|
+
const xdgSound =
|
|
58
|
+
severity === "critical"
|
|
59
|
+
? "dialog-error"
|
|
60
|
+
: severity === "warning"
|
|
61
|
+
? "dialog-warning"
|
|
62
|
+
: "dialog-information";
|
|
63
|
+
|
|
64
|
+
Bun.spawn(["canberra-gtk-play", "-i", xdgSound], {
|
|
65
|
+
stdout: "ignore",
|
|
66
|
+
stderr: "ignore",
|
|
67
|
+
}).exited.then((code) => {
|
|
68
|
+
if (code !== 0) {
|
|
69
|
+
Bun.spawn(["paplay", "/usr/share/sounds/freedesktop/stereo/bell.oga"], {
|
|
70
|
+
stdout: "ignore",
|
|
71
|
+
stderr: "ignore",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Windows / other — BEL only (already sent via sendDesktopNotification)
|
|
79
|
+
}
|
|
80
|
+
|
|
7
81
|
export const terminalBellPlugin: NotificationPlugin = {
|
|
8
82
|
apiVersion: 2,
|
|
9
83
|
id: "terminal-bell",
|
|
@@ -12,7 +86,7 @@ export const terminalBellPlugin: NotificationPlugin = {
|
|
|
12
86
|
version: "1.0.0",
|
|
13
87
|
|
|
14
88
|
meta: {
|
|
15
|
-
description: "
|
|
89
|
+
description: "Desktop notification + system sound alerts",
|
|
16
90
|
},
|
|
17
91
|
|
|
18
92
|
permissions: {},
|
|
@@ -50,30 +124,20 @@ export const terminalBellPlugin: NotificationPlugin = {
|
|
|
50
124
|
},
|
|
51
125
|
|
|
52
126
|
async notify(ctx: NotificationContext, event: NotificationEvent): Promise<void> {
|
|
53
|
-
const severityOrder = ["info", "warning", "critical"];
|
|
54
127
|
const minSeverity = (ctx.config.minSeverity as string) ?? "warning";
|
|
55
128
|
|
|
56
|
-
if (
|
|
129
|
+
if (SEVERITY_ORDER.indexOf(event.severity) < SEVERITY_ORDER.indexOf(minSeverity)) {
|
|
57
130
|
return;
|
|
58
131
|
}
|
|
59
132
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
for (let i = 0; i < bellCount; i++) {
|
|
63
|
-
process.stdout.write("\x07");
|
|
64
|
-
if (i < bellCount - 1) {
|
|
65
|
-
await sleep(200);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
133
|
+
sendDesktopNotification(event.title, event.message);
|
|
134
|
+
playSystemSound(event.severity);
|
|
68
135
|
},
|
|
69
136
|
|
|
70
137
|
async test(ctx: NotificationContext): Promise<boolean> {
|
|
71
138
|
ctx.logger.info("Testing terminal bell...");
|
|
72
|
-
|
|
139
|
+
sendDesktopNotification("tokentop", "Test notification");
|
|
140
|
+
playSystemSound("warning");
|
|
73
141
|
return true;
|
|
74
142
|
},
|
|
75
143
|
};
|
|
76
|
-
|
|
77
|
-
function sleep(ms: number): Promise<void> {
|
|
78
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
|
-
}
|
|
@@ -12,7 +12,7 @@ export const visualFlashPlugin: NotificationPlugin = {
|
|
|
12
12
|
version: "1.0.0",
|
|
13
13
|
|
|
14
14
|
meta: {
|
|
15
|
-
description: "Visual screen flash
|
|
15
|
+
description: "Visual screen flash for alerts (rendered via TUI overlay)",
|
|
16
16
|
},
|
|
17
17
|
|
|
18
18
|
permissions: {},
|
|
@@ -27,7 +27,7 @@ export const visualFlashPlugin: NotificationPlugin = {
|
|
|
27
27
|
duration: {
|
|
28
28
|
type: "number",
|
|
29
29
|
label: "Flash duration (ms)",
|
|
30
|
-
default:
|
|
30
|
+
default: 150,
|
|
31
31
|
description: "Flash duration in milliseconds",
|
|
32
32
|
},
|
|
33
33
|
},
|
|
@@ -44,26 +44,13 @@ export const visualFlashPlugin: NotificationPlugin = {
|
|
|
44
44
|
ctx.logger.debug("Visual flash notification plugin initialized");
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
-
async notify(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const colorCode =
|
|
51
|
-
event.severity === "critical" ? "41" : event.severity === "warning" ? "43" : "42";
|
|
52
|
-
|
|
53
|
-
process.stdout.write(`\x1b[${colorCode}m`);
|
|
54
|
-
await sleep(duration);
|
|
55
|
-
process.stdout.write("\x1b[0m");
|
|
47
|
+
async notify(_ctx: NotificationContext, _event: NotificationEvent): Promise<void> {
|
|
48
|
+
// Flash is handled by the NotificationFlash React component via
|
|
49
|
+
// notificationBus.flashHandler — raw ANSI escapes conflict with OpenTUI.
|
|
56
50
|
},
|
|
57
51
|
|
|
58
52
|
async test(ctx: NotificationContext): Promise<boolean> {
|
|
59
53
|
ctx.logger.info("Testing visual flash...");
|
|
60
|
-
process.stdout.write("\x1b[44m");
|
|
61
|
-
await sleep(100);
|
|
62
|
-
process.stdout.write("\x1b[0m");
|
|
63
54
|
return true;
|
|
64
55
|
},
|
|
65
56
|
};
|
|
66
|
-
|
|
67
|
-
function sleep(ms: number): Promise<void> {
|
|
68
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
-
}
|
|
@@ -39,13 +39,21 @@ export function createPluginContext(
|
|
|
39
39
|
permissions: PluginPermissions,
|
|
40
40
|
signal?: AbortSignal,
|
|
41
41
|
): PluginContext {
|
|
42
|
+
// Lazily create the AbortSignal only when accessed to avoid accumulating
|
|
43
|
+
// dangling 30s timers from AbortSignal.timeout() on every context creation.
|
|
44
|
+
let _signal = signal;
|
|
42
45
|
const ctx: PluginContext = {
|
|
43
46
|
config: {},
|
|
44
47
|
logger: createPluginLogger(pluginId),
|
|
45
48
|
http: createSandboxedHttpClient(pluginId, permissions),
|
|
46
49
|
authSources: createAuthSources(pluginId, permissions),
|
|
47
50
|
storage: createPluginStorage(pluginId),
|
|
48
|
-
|
|
51
|
+
get signal() {
|
|
52
|
+
if (!_signal) {
|
|
53
|
+
_signal = AbortSignal.timeout(30_000);
|
|
54
|
+
}
|
|
55
|
+
return _signal;
|
|
56
|
+
},
|
|
49
57
|
};
|
|
50
58
|
deepFreeze(ctx.config);
|
|
51
59
|
return ctx;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type {
|
|
3
3
|
Credentials,
|
|
4
|
+
OpenCodeAuthEntry,
|
|
5
|
+
PluginContext,
|
|
4
6
|
PluginHttpClient,
|
|
5
7
|
PluginLogger,
|
|
6
8
|
ProviderFetchContext,
|
|
@@ -200,3 +202,146 @@ describe("anthropic fetchUsage — success", () => {
|
|
|
200
202
|
expect(result.error).toBeUndefined();
|
|
201
203
|
});
|
|
202
204
|
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// auth.discover — credential source fallthrough
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
function createDiscoverContext(options: {
|
|
211
|
+
opencodeEntry?: OpenCodeAuthEntry | null;
|
|
212
|
+
files?: Record<string, string>;
|
|
213
|
+
env?: Record<string, string>;
|
|
214
|
+
platformOs?: "darwin" | "linux" | "win32";
|
|
215
|
+
homedir?: string;
|
|
216
|
+
}): PluginContext {
|
|
217
|
+
const files = options.files ?? {};
|
|
218
|
+
const env = options.env ?? {};
|
|
219
|
+
return {
|
|
220
|
+
config: {},
|
|
221
|
+
logger: createMockLogger(),
|
|
222
|
+
http: { fetch: async () => new Response(null, { status: 404 }) },
|
|
223
|
+
storage: {
|
|
224
|
+
get: async () => null,
|
|
225
|
+
set: async () => {},
|
|
226
|
+
delete: async () => {},
|
|
227
|
+
has: async () => false,
|
|
228
|
+
},
|
|
229
|
+
signal: new AbortController().signal,
|
|
230
|
+
authSources: {
|
|
231
|
+
env: { get: (name) => env[name] },
|
|
232
|
+
files: {
|
|
233
|
+
readText: async (path) => files[path] ?? null,
|
|
234
|
+
readJson: async <T = unknown>(path: string): Promise<T | null> => {
|
|
235
|
+
const raw = files[path];
|
|
236
|
+
if (!raw) return null;
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(raw) as T;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
exists: async (path) => path in files,
|
|
244
|
+
},
|
|
245
|
+
opencode: {
|
|
246
|
+
getProviderEntry: async () => options.opencodeEntry ?? null,
|
|
247
|
+
},
|
|
248
|
+
platform: {
|
|
249
|
+
os: options.platformOs ?? "linux",
|
|
250
|
+
homedir: options.homedir ?? "/home/test",
|
|
251
|
+
arch: "x64",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
describe("anthropic auth.discover — OpenCode OAuth expiry", () => {
|
|
258
|
+
// Regression: an expired OpenCode OAuth entry used to be returned as-is,
|
|
259
|
+
// blocking fallthrough to fresher Claude Code credentials and causing the
|
|
260
|
+
// TUI to show "needs auth" even when Claude Code had a valid token.
|
|
261
|
+
test("falls through to Claude Code credentials when OpenCode OAuth is expired", async () => {
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
const ctx = createDiscoverContext({
|
|
264
|
+
opencodeEntry: {
|
|
265
|
+
type: "oauth",
|
|
266
|
+
access: "stale-opencode-token",
|
|
267
|
+
refresh: "stale-refresh",
|
|
268
|
+
expires: now - 60_000,
|
|
269
|
+
},
|
|
270
|
+
files: {
|
|
271
|
+
"/home/test/.claude/.credentials.json": JSON.stringify({
|
|
272
|
+
claudeAiOauth: {
|
|
273
|
+
accessToken: "fresh-claude-code-token",
|
|
274
|
+
refreshToken: "fresh-refresh",
|
|
275
|
+
expiresAt: now + 3_600_000,
|
|
276
|
+
subscriptionType: "max",
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const result = await anthropicPlugin.auth.discover(ctx);
|
|
283
|
+
|
|
284
|
+
expect(result.ok).toBe(true);
|
|
285
|
+
expect(result.credentials?.oauth?.accessToken).toBe("fresh-claude-code-token");
|
|
286
|
+
expect(result.credentials?.source).toBe("external");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("treats near-expiry OpenCode token (within 5-minute buffer) as expired", async () => {
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
const ctx = createDiscoverContext({
|
|
292
|
+
opencodeEntry: {
|
|
293
|
+
type: "oauth",
|
|
294
|
+
access: "near-expiry-opencode-token",
|
|
295
|
+
expires: now + 60_000,
|
|
296
|
+
},
|
|
297
|
+
env: { ANTHROPIC_API_KEY: "sk-ant-api-key" },
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const result = await anthropicPlugin.auth.discover(ctx);
|
|
301
|
+
|
|
302
|
+
expect(result.ok).toBe(true);
|
|
303
|
+
expect(result.credentials?.apiKey).toBe("sk-ant-api-key");
|
|
304
|
+
expect(result.credentials?.source).toBe("env");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("uses OpenCode entry when token is comfortably in the future", async () => {
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
const ctx = createDiscoverContext({
|
|
310
|
+
opencodeEntry: {
|
|
311
|
+
type: "oauth",
|
|
312
|
+
access: "valid-opencode-token",
|
|
313
|
+
refresh: "valid-refresh",
|
|
314
|
+
expires: now + 3_600_000,
|
|
315
|
+
},
|
|
316
|
+
files: {
|
|
317
|
+
"/home/test/.claude/.credentials.json": JSON.stringify({
|
|
318
|
+
claudeAiOauth: {
|
|
319
|
+
accessToken: "claude-code-token",
|
|
320
|
+
expiresAt: now + 3_600_000,
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const result = await anthropicPlugin.auth.discover(ctx);
|
|
327
|
+
|
|
328
|
+
expect(result.ok).toBe(true);
|
|
329
|
+
expect(result.credentials?.oauth?.accessToken).toBe("valid-opencode-token");
|
|
330
|
+
expect(result.credentials?.source).toBe("opencode");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("uses OpenCode entry with no expires field (treated as non-expiring)", async () => {
|
|
334
|
+
const ctx = createDiscoverContext({
|
|
335
|
+
opencodeEntry: {
|
|
336
|
+
type: "oauth",
|
|
337
|
+
access: "no-expiry-token",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const result = await anthropicPlugin.auth.discover(ctx);
|
|
342
|
+
|
|
343
|
+
expect(result.ok).toBe(true);
|
|
344
|
+
expect(result.credentials?.oauth?.accessToken).toBe("no-expiry-token");
|
|
345
|
+
expect(result.credentials?.source).toBe("opencode");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -155,18 +155,22 @@ export const anthropicPlugin: ProviderPlugin = {
|
|
|
155
155
|
async discover(ctx: PluginContext): Promise<CredentialResult> {
|
|
156
156
|
const entry = await ctx.authSources.opencode.getProviderEntry("anthropic");
|
|
157
157
|
if (entry?.type === "oauth" && entry.access) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
158
|
+
const expired =
|
|
159
|
+
entry.expires != null && entry.expires <= Date.now() + TOKEN_EXPIRY_BUFFER_MS;
|
|
160
|
+
if (!expired) {
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
credentials: {
|
|
164
|
+
oauth: buildOAuthCredentials(
|
|
165
|
+
entry.access,
|
|
166
|
+
entry.refresh,
|
|
167
|
+
entry.expires,
|
|
168
|
+
entry.accountId,
|
|
169
|
+
),
|
|
170
|
+
source: "opencode",
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
170
174
|
}
|
|
171
175
|
|
|
172
176
|
const claudeCodeResult = await discoverClaudeCode(ctx);
|
|
@@ -233,6 +237,8 @@ export const anthropicPlugin: ProviderPlugin = {
|
|
|
233
237
|
|
|
234
238
|
if (!response.ok) {
|
|
235
239
|
log.warn("Failed to fetch Anthropic usage", { status: response.status });
|
|
240
|
+
// Drain the response body to prevent memory leaks in Bun's fetch implementation
|
|
241
|
+
response.body?.cancel();
|
|
236
242
|
|
|
237
243
|
if (response.status === 429) {
|
|
238
244
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
@@ -108,6 +108,8 @@ export const chutesPlugin: ProviderPlugin = {
|
|
|
108
108
|
|
|
109
109
|
if (!response.ok) {
|
|
110
110
|
log.warn("Failed to fetch Chutes usage", { status: response.status });
|
|
111
|
+
// Drain the response body to prevent memory leaks in Bun's fetch implementation
|
|
112
|
+
response.body?.cancel();
|
|
111
113
|
|
|
112
114
|
if (response.status === 429) {
|
|
113
115
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
@@ -134,6 +134,9 @@ export const githubCopilotPlugin: ProviderPlugin = {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
if (!response.ok) {
|
|
137
|
+
// Drain the response body to prevent memory leaks in Bun's fetch implementation
|
|
138
|
+
response.body?.cancel();
|
|
139
|
+
|
|
137
140
|
if (response.status === 429) {
|
|
138
141
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
139
142
|
const parsedRetryAfter = retryAfterHeader
|
|
@@ -150,6 +150,8 @@ function createMinimaxPlugin(id: string, name: string): ProviderPlugin {
|
|
|
150
150
|
|
|
151
151
|
if (!response.ok) {
|
|
152
152
|
log.warn("Failed to fetch MiniMax usage", { status: response.status });
|
|
153
|
+
// Drain the response body to prevent memory leaks in Bun's fetch implementation
|
|
154
|
+
response.body?.cancel();
|
|
153
155
|
|
|
154
156
|
if (response.status === 429) {
|
|
155
157
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
@@ -270,6 +270,8 @@ async function fetchCompletionsUsage(
|
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
if (!response.ok) {
|
|
273
|
+
// Drain the response body to prevent memory leaks in Bun's fetch implementation
|
|
274
|
+
response.body?.cancel();
|
|
273
275
|
if (response.status === 429) {
|
|
274
276
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
275
277
|
const parsedRetryAfter = retryAfterHeader
|
|
@@ -303,6 +305,8 @@ async function fetchCosts(
|
|
|
303
305
|
});
|
|
304
306
|
|
|
305
307
|
if (!response.ok) {
|
|
308
|
+
// Drain the response body to prevent memory leaks in Bun's fetch implementation
|
|
309
|
+
response.body?.cancel();
|
|
306
310
|
if (response.status === 429) {
|
|
307
311
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
308
312
|
const parsedRetryAfter = retryAfterHeader
|
|
@@ -123,6 +123,8 @@ export const zaiCodingPlanPlugin: ProviderPlugin = {
|
|
|
123
123
|
|
|
124
124
|
if (!response.ok) {
|
|
125
125
|
log.warn("Failed to fetch Z.ai usage", { status: response.status });
|
|
126
|
+
// Drain the response body to prevent memory leaks in Bun's fetch implementation
|
|
127
|
+
response.body?.cancel();
|
|
126
128
|
|
|
127
129
|
if (response.status === 429) {
|
|
128
130
|
const retryAfterHeader = response.headers.get("retry-after");
|
package/src/pricing/estimator.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { CostBreakdown, ModelPricing } from "@tokentop/plugin-sdk";
|
|
2
2
|
|
|
3
|
+
export const LONG_CONTEXT_THRESHOLD = 200_000;
|
|
4
|
+
|
|
3
5
|
export interface TokenUsage {
|
|
4
6
|
input: number;
|
|
5
7
|
output: number;
|
|
@@ -36,6 +38,11 @@ export function estimateCost(usage: TokenUsage, pricing: ModelPricing): CostBrea
|
|
|
36
38
|
return breakdown;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* @deprecated This function sums tokens before pricing, which is incompatible with
|
|
43
|
+
* tiered context-based pricing. Use per-request costing via priceStream() instead.
|
|
44
|
+
* Kept for backward compatibility — not called at runtime.
|
|
45
|
+
*/
|
|
39
46
|
export function estimateSessionCost(sessions: TokenUsage[], pricing: ModelPricing): CostBreakdown {
|
|
40
47
|
const totals = sessions.reduce<TokenUsage>(
|
|
41
48
|
(acc, session) => ({
|
package/src/pricing/index.ts
CHANGED
|
@@ -68,5 +68,10 @@ export function clearPricingCache(): void {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export type { TokenUsage } from "./estimator.ts";
|
|
71
|
+
/**
|
|
72
|
+
* @deprecated This function sums tokens before pricing, which is incompatible with
|
|
73
|
+
* tiered context-based pricing. Use per-request costing via priceStream() instead.
|
|
74
|
+
* Kept for backward compatibility — not called at runtime.
|
|
75
|
+
*/
|
|
71
76
|
export { estimateCost, estimateSessionCost, formatCost, formatTokenCount } from "./estimator.ts";
|
|
72
77
|
export { FALLBACK_PRICING } from "./fallback.ts";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { clearCache, getModelPricing, getProviderModels } from "./models-dev.ts";
|
|
3
|
+
|
|
4
|
+
const mockFetch = mock(() => Promise.resolve(new Response("null")));
|
|
5
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
6
|
+
|
|
7
|
+
function makeApiResponse(costOverride?: Record<string, unknown>) {
|
|
8
|
+
return {
|
|
9
|
+
anthropic: {
|
|
10
|
+
id: "anthropic",
|
|
11
|
+
name: "Anthropic",
|
|
12
|
+
models: {
|
|
13
|
+
"claude-sonnet-4-20250514": {
|
|
14
|
+
id: "claude-sonnet-4-20250514",
|
|
15
|
+
name: "Claude Sonnet 4",
|
|
16
|
+
family: "claude",
|
|
17
|
+
cost: {
|
|
18
|
+
input: 3,
|
|
19
|
+
output: 15,
|
|
20
|
+
cache_read: 0.3,
|
|
21
|
+
cache_write: 3.75,
|
|
22
|
+
...costOverride,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mockApiResponse(data: unknown) {
|
|
31
|
+
mockFetch.mockImplementation(() =>
|
|
32
|
+
Promise.resolve(new Response(JSON.stringify(data), { status: 200 })),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
clearCache();
|
|
38
|
+
mockFetch.mockClear();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("getModelPricing() — context_over_200k", () => {
|
|
42
|
+
test("returns all longContext fields when API has full context_over_200k", async () => {
|
|
43
|
+
mockApiResponse(
|
|
44
|
+
makeApiResponse({
|
|
45
|
+
context_over_200k: {
|
|
46
|
+
input: 6,
|
|
47
|
+
output: 22.5,
|
|
48
|
+
cache_read: 0.6,
|
|
49
|
+
cache_write: 7.5,
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const pricing = await getModelPricing("anthropic", "claude-sonnet-4-20250514");
|
|
55
|
+
|
|
56
|
+
expect(pricing).not.toBeNull();
|
|
57
|
+
expect(pricing!.longContextInput).toBe(6);
|
|
58
|
+
expect(pricing!.longContextOutput).toBe(22.5);
|
|
59
|
+
expect(pricing!.longContextCacheRead).toBe(0.6);
|
|
60
|
+
expect(pricing!.longContextCacheWrite).toBe(7.5);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns undefined longContext fields when API lacks context_over_200k", async () => {
|
|
64
|
+
mockApiResponse(makeApiResponse());
|
|
65
|
+
|
|
66
|
+
const pricing = await getModelPricing("anthropic", "claude-sonnet-4-20250514");
|
|
67
|
+
|
|
68
|
+
expect(pricing).not.toBeNull();
|
|
69
|
+
expect(pricing!.longContextInput).toBeUndefined();
|
|
70
|
+
expect(pricing!.longContextOutput).toBeUndefined();
|
|
71
|
+
expect(pricing!.longContextCacheRead).toBeUndefined();
|
|
72
|
+
expect(pricing!.longContextCacheWrite).toBeUndefined();
|
|
73
|
+
expect(pricing!.input).toBe(3);
|
|
74
|
+
expect(pricing!.output).toBe(15);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("handles partial context_over_200k (only input+output, no cache)", async () => {
|
|
78
|
+
mockApiResponse(
|
|
79
|
+
makeApiResponse({
|
|
80
|
+
context_over_200k: {
|
|
81
|
+
input: 6,
|
|
82
|
+
output: 22.5,
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const pricing = await getModelPricing("anthropic", "claude-sonnet-4-20250514");
|
|
88
|
+
|
|
89
|
+
expect(pricing).not.toBeNull();
|
|
90
|
+
expect(pricing!.longContextInput).toBe(6);
|
|
91
|
+
expect(pricing!.longContextOutput).toBe(22.5);
|
|
92
|
+
expect(pricing!.longContextCacheRead).toBeUndefined();
|
|
93
|
+
expect(pricing!.longContextCacheWrite).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("getProviderModels() passes through longContext fields", async () => {
|
|
97
|
+
mockApiResponse(
|
|
98
|
+
makeApiResponse({
|
|
99
|
+
context_over_200k: {
|
|
100
|
+
input: 6,
|
|
101
|
+
output: 22.5,
|
|
102
|
+
cache_read: 0.6,
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const models = await getProviderModels("anthropic");
|
|
108
|
+
|
|
109
|
+
expect(models).not.toBeNull();
|
|
110
|
+
const pricing = models!["claude-sonnet-4-20250514"]!;
|
|
111
|
+
expect(pricing).toBeDefined();
|
|
112
|
+
expect(pricing.longContextInput).toBe(6);
|
|
113
|
+
expect(pricing.longContextOutput).toBe(22.5);
|
|
114
|
+
expect(pricing.longContextCacheRead).toBe(0.6);
|
|
115
|
+
expect(pricing.longContextCacheWrite).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
});
|