@tokentop/ttop 0.6.1 → 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/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/demo/simulator.test.ts +92 -0
- package/src/demo/simulator.ts +579 -211
- 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 +4 -4
- package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
- package/src/tui/components/SessionsTable.tsx +25 -7
- package/src/tui/contexts/AgentSessionContext.tsx +59 -21
- package/src/tui/contexts/PluginContext.tsx +31 -10
- 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/version.ts +1 -1
|
@@ -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
|
+
});
|
|
@@ -8,6 +8,12 @@ interface ModelsDevCost {
|
|
|
8
8
|
output: number;
|
|
9
9
|
cache_read?: number;
|
|
10
10
|
cache_write?: number;
|
|
11
|
+
context_over_200k?: {
|
|
12
|
+
input?: number;
|
|
13
|
+
output?: number;
|
|
14
|
+
cache_read?: number;
|
|
15
|
+
cache_write?: number;
|
|
16
|
+
};
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
interface ModelsDevModel {
|
|
@@ -88,6 +94,14 @@ export async function getModelPricing(
|
|
|
88
94
|
pricing.cacheWrite = model.cost.cache_write;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
const lc = model.cost.context_over_200k;
|
|
98
|
+
if (lc) {
|
|
99
|
+
if (lc.input !== undefined) pricing.longContextInput = lc.input;
|
|
100
|
+
if (lc.output !== undefined) pricing.longContextOutput = lc.output;
|
|
101
|
+
if (lc.cache_read !== undefined) pricing.longContextCacheRead = lc.cache_read;
|
|
102
|
+
if (lc.cache_write !== undefined) pricing.longContextCacheWrite = lc.cache_write;
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
return pricing;
|
|
92
106
|
}
|
|
93
107
|
|
|
@@ -119,6 +133,14 @@ export async function getProviderModels(
|
|
|
119
133
|
pricing.cacheWrite = model.cost.cache_write;
|
|
120
134
|
}
|
|
121
135
|
|
|
136
|
+
const lc = model.cost.context_over_200k;
|
|
137
|
+
if (lc) {
|
|
138
|
+
if (lc.input !== undefined) pricing.longContextInput = lc.input;
|
|
139
|
+
if (lc.output !== undefined) pricing.longContextOutput = lc.output;
|
|
140
|
+
if (lc.cache_read !== undefined) pricing.longContextCacheRead = lc.cache_read;
|
|
141
|
+
if (lc.cache_write !== undefined) pricing.longContextCacheWrite = lc.cache_write;
|
|
142
|
+
}
|
|
143
|
+
|
|
122
144
|
result[modelId] = pricing;
|
|
123
145
|
}
|
|
124
146
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
AgentSessionSnapshotInsert,
|
|
4
|
+
AgentSessionStreamSnapshotRow,
|
|
5
|
+
AgentSessionUpsert,
|
|
6
|
+
UsageEventInsert,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
mock.module("./db.ts", () => ({
|
|
10
|
+
isDatabaseInitialized: () => true,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
let upsertCalls: AgentSessionUpsert[] = [];
|
|
14
|
+
let snapshotCalls: Array<{
|
|
15
|
+
snapshot: AgentSessionSnapshotInsert;
|
|
16
|
+
streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[];
|
|
17
|
+
}> = [];
|
|
18
|
+
let usageEventCalls: UsageEventInsert[][] = [];
|
|
19
|
+
|
|
20
|
+
let seededSnapshots: Array<{
|
|
21
|
+
agentId: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
totalCostUsd: number;
|
|
25
|
+
requestCount: number;
|
|
26
|
+
totalInputTokens: number;
|
|
27
|
+
totalOutputTokens: number;
|
|
28
|
+
}> = [];
|
|
29
|
+
|
|
30
|
+
mock.module("./repos/agentSessions.ts", () => ({
|
|
31
|
+
getLatestSnapshotsForAllSessions: () => seededSnapshots,
|
|
32
|
+
getLatestStreamTotalsForAllSessions: () => [],
|
|
33
|
+
upsertAgentSession: (session: AgentSessionUpsert) => {
|
|
34
|
+
upsertCalls.push(session);
|
|
35
|
+
return upsertCalls.length;
|
|
36
|
+
},
|
|
37
|
+
insertAgentSessionSnapshot: (
|
|
38
|
+
snapshot: AgentSessionSnapshotInsert,
|
|
39
|
+
streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[],
|
|
40
|
+
) => {
|
|
41
|
+
snapshotCalls.push({ snapshot, streams });
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module("./repos/usageEvents.ts", () => ({
|
|
46
|
+
insertUsageEventBatch: (events: UsageEventInsert[]) => {
|
|
47
|
+
usageEventCalls.push(events);
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const { persistSessions, seedPreviousTotals } = await import("./persistence-service.ts");
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
upsertCalls = [];
|
|
55
|
+
snapshotCalls = [];
|
|
56
|
+
usageEventCalls = [];
|
|
57
|
+
seededSnapshots = [];
|
|
58
|
+
(globalThis as { __tokentopPersistence?: unknown }).__tokentopPersistence = undefined;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function makePersistData(overrides: {
|
|
62
|
+
agentId?: string;
|
|
63
|
+
sessionId?: string;
|
|
64
|
+
totalCostUsd?: number;
|
|
65
|
+
requestCount?: number;
|
|
66
|
+
totalInputTokens?: number;
|
|
67
|
+
totalOutputTokens?: number;
|
|
68
|
+
timestamp?: number;
|
|
69
|
+
}) {
|
|
70
|
+
const now = overrides.timestamp ?? Date.now();
|
|
71
|
+
return {
|
|
72
|
+
session: {
|
|
73
|
+
agentId: overrides.agentId ?? "claude-code",
|
|
74
|
+
sessionId: overrides.sessionId ?? "s1",
|
|
75
|
+
projectPath: null,
|
|
76
|
+
startedAt: null,
|
|
77
|
+
lastSeenAt: now,
|
|
78
|
+
},
|
|
79
|
+
snapshot: {
|
|
80
|
+
timestamp: now,
|
|
81
|
+
lastActivityAt: now,
|
|
82
|
+
status: null,
|
|
83
|
+
totalInputTokens: overrides.totalInputTokens ?? 1000,
|
|
84
|
+
totalOutputTokens: overrides.totalOutputTokens ?? 500,
|
|
85
|
+
totalCacheReadTokens: 0,
|
|
86
|
+
totalCacheWriteTokens: 0,
|
|
87
|
+
totalCostUsd: overrides.totalCostUsd ?? 1.5,
|
|
88
|
+
requestCount: overrides.requestCount ?? 10,
|
|
89
|
+
},
|
|
90
|
+
streams: [],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("seedPreviousTotals", () => {
|
|
95
|
+
test("skips persist for session whose fingerprint matches seeded DB snapshot", () => {
|
|
96
|
+
seededSnapshots = [
|
|
97
|
+
{
|
|
98
|
+
agentId: "claude-code",
|
|
99
|
+
sessionId: "s1",
|
|
100
|
+
timestamp: Date.now() - 60_000,
|
|
101
|
+
totalCostUsd: 1.5,
|
|
102
|
+
requestCount: 10,
|
|
103
|
+
totalInputTokens: 1000,
|
|
104
|
+
totalOutputTokens: 500,
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
seedPreviousTotals();
|
|
109
|
+
|
|
110
|
+
const persistedCount = persistSessions([
|
|
111
|
+
makePersistData({
|
|
112
|
+
agentId: "claude-code",
|
|
113
|
+
sessionId: "s1",
|
|
114
|
+
totalCostUsd: 1.5,
|
|
115
|
+
requestCount: 10,
|
|
116
|
+
totalInputTokens: 1000,
|
|
117
|
+
totalOutputTokens: 500,
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
expect(persistedCount).toBe(0);
|
|
122
|
+
expect(upsertCalls).toHaveLength(0);
|
|
123
|
+
expect(snapshotCalls).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("persists session whose usage changed since seeded snapshot", () => {
|
|
127
|
+
seededSnapshots = [
|
|
128
|
+
{
|
|
129
|
+
agentId: "claude-code",
|
|
130
|
+
sessionId: "s1",
|
|
131
|
+
timestamp: Date.now() - 10 * 60 * 1000,
|
|
132
|
+
totalCostUsd: 1.5,
|
|
133
|
+
requestCount: 10,
|
|
134
|
+
totalInputTokens: 1000,
|
|
135
|
+
totalOutputTokens: 500,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
seedPreviousTotals();
|
|
140
|
+
|
|
141
|
+
const persistedCount = persistSessions([
|
|
142
|
+
makePersistData({
|
|
143
|
+
agentId: "claude-code",
|
|
144
|
+
sessionId: "s1",
|
|
145
|
+
totalCostUsd: 2.0,
|
|
146
|
+
requestCount: 12,
|
|
147
|
+
totalInputTokens: 1200,
|
|
148
|
+
totalOutputTokens: 600,
|
|
149
|
+
}),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
expect(persistedCount).toBe(1);
|
|
153
|
+
expect(upsertCalls).toHaveLength(1);
|
|
154
|
+
expect(snapshotCalls).toHaveLength(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("persists brand-new session not present in seed", () => {
|
|
158
|
+
seededSnapshots = [];
|
|
159
|
+
|
|
160
|
+
seedPreviousTotals();
|
|
161
|
+
|
|
162
|
+
const persistedCount = persistSessions([
|
|
163
|
+
makePersistData({ agentId: "claude-code", sessionId: "new-session" }),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
expect(persistedCount).toBe(1);
|
|
167
|
+
expect(upsertCalls).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("skips thousands of unchanged sessions after seed (regression for startup freeze)", () => {
|
|
171
|
+
const baseTs = Date.now() - 60_000;
|
|
172
|
+
seededSnapshots = Array.from({ length: 5000 }, (_, i) => ({
|
|
173
|
+
agentId: "claude-code",
|
|
174
|
+
sessionId: `s${i}`,
|
|
175
|
+
timestamp: baseTs + i,
|
|
176
|
+
totalCostUsd: i * 0.01,
|
|
177
|
+
requestCount: i,
|
|
178
|
+
totalInputTokens: i * 100,
|
|
179
|
+
totalOutputTokens: i * 50,
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
seedPreviousTotals();
|
|
183
|
+
|
|
184
|
+
const persistData = seededSnapshots.map((s) =>
|
|
185
|
+
makePersistData({
|
|
186
|
+
agentId: s.agentId,
|
|
187
|
+
sessionId: s.sessionId,
|
|
188
|
+
totalCostUsd: s.totalCostUsd,
|
|
189
|
+
requestCount: s.requestCount,
|
|
190
|
+
totalInputTokens: s.totalInputTokens,
|
|
191
|
+
totalOutputTokens: s.totalOutputTokens,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const persistedCount = persistSessions(persistData);
|
|
196
|
+
|
|
197
|
+
expect(persistedCount).toBe(0);
|
|
198
|
+
expect(upsertCalls).toHaveLength(0);
|
|
199
|
+
expect(snapshotCalls).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
});
|