@neuralmindlabs/tokn 0.1.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/.github/workflows/ci.yml +22 -0
- package/.oxlintrc.json +8 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/assets/index-Dq74RfU-.css +1 -0
- package/dist/assets/index-_9e8Z58Z.js +66 -0
- package/dist/favicon.svg +1 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +45 -0
- package/packages/tokn/LICENSE +21 -0
- package/packages/tokn/README.md +85 -0
- package/packages/tokn/package.json +56 -0
- package/packages/tokn/src/aggregate.ts +83 -0
- package/packages/tokn/src/cli.ts +181 -0
- package/packages/tokn/src/cursor.ts +85 -0
- package/packages/tokn/src/index.ts +6 -0
- package/packages/tokn/src/node.ts +50 -0
- package/packages/tokn/src/parsers/claude.ts +53 -0
- package/packages/tokn/src/parsers/codex.ts +74 -0
- package/packages/tokn/src/pricing/index.ts +106 -0
- package/packages/tokn/src/pricing/prices.json +948 -0
- package/packages/tokn/src/pricing/prices.ts +947 -0
- package/packages/tokn/src/types.ts +51 -0
- package/packages/tokn/tests/__snapshots__/eval.test.ts.snap +12 -0
- package/packages/tokn/tests/aggregate.test.ts +73 -0
- package/packages/tokn/tests/claude.test.ts +87 -0
- package/packages/tokn/tests/codex.test.ts +54 -0
- package/packages/tokn/tests/cursor.test.ts +126 -0
- package/packages/tokn/tests/eval.test.ts +69 -0
- package/packages/tokn/tests/fixtures/claude-session.jsonl +3 -0
- package/packages/tokn/tests/fixtures/codex-session.jsonl +5 -0
- package/packages/tokn/tests/pricing.test.ts +137 -0
- package/packages/tokn/tsconfig.json +21 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/scripts/validate.ts +122 -0
- package/src/App.tsx +12 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/vite.svg +1 -0
- package/src/index.css +250 -0
- package/src/lib/refresh.ts +134 -0
- package/src/lib/scanner.ts +108 -0
- package/src/main.tsx +7 -0
- package/src/ui/Dashboard.tsx +203 -0
- package/src/ui/Popover.tsx +91 -0
- package/src/ui/Settings.tsx +114 -0
- package/src/ui/useAppData.ts +71 -0
- package/src-tauri/Cargo.lock +5972 -0
- package/src-tauri/Cargo.toml +35 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +114 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/64x64.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@2x-1.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@2x-1.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@2x-1.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-512@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-60x60@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-60x60@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-76x76@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-76x76@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/src-tauri/icons/source/app-icon-1024.png +0 -0
- package/src-tauri/icons/source/app-icon.svg +74 -0
- package/src-tauri/icons/source/tray-22.png +0 -0
- package/src-tauri/icons/source/tray-44.png +0 -0
- package/src-tauri/icons/source/tray-icon.svg +14 -0
- package/src-tauri/permissions/allow-custom-commands.toml +11 -0
- package/src-tauri/src/lib.rs +238 -0
- package/src-tauri/src/main.rs +6 -0
- package/src-tauri/tauri.conf.json +57 -0
- package/tsconfig.app.json +27 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +23 -0
- package/vite.config.ts +7 -0
- package/vitest.config.ts +28 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type Tool = 'claude' | 'codex' | 'cursor';
|
|
2
|
+
|
|
3
|
+
export interface UsageEvent {
|
|
4
|
+
tool: Tool;
|
|
5
|
+
/** ISO timestamp of the API call */
|
|
6
|
+
timestamp: string;
|
|
7
|
+
model: string;
|
|
8
|
+
/** Absolute path of the project the session ran in, if known */
|
|
9
|
+
project?: string;
|
|
10
|
+
inputTokens: number;
|
|
11
|
+
outputTokens: number;
|
|
12
|
+
/** Anthropic cache_creation_input_tokens (5m + 1h combined) */
|
|
13
|
+
cacheWriteTokens: number;
|
|
14
|
+
/** Subset of cacheWriteTokens written with 1h TTL — billed at 2x input rate instead of 1.25x */
|
|
15
|
+
cacheWrite1hTokens?: number;
|
|
16
|
+
/** Anthropic cache_read_input_tokens / OpenAI cached_input_tokens */
|
|
17
|
+
cacheReadTokens: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CostedEvent extends UsageEvent {
|
|
21
|
+
/** undefined when the model has no pricing entry */
|
|
22
|
+
costUSD?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolDayTotals {
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
cacheWriteTokens: number;
|
|
29
|
+
cacheReadTokens: number;
|
|
30
|
+
costUSD: number;
|
|
31
|
+
/** events whose model had no pricing entry (tokens counted, cost not) */
|
|
32
|
+
unpricedEvents: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DayRollup {
|
|
36
|
+
/** local date YYYY-MM-DD */
|
|
37
|
+
date: string;
|
|
38
|
+
byTool: Partial<Record<Tool, ToolDayTotals>>;
|
|
39
|
+
byModel: Record<string, ToolDayTotals>;
|
|
40
|
+
byProject: Record<string, ToolDayTotals>;
|
|
41
|
+
totalCostUSD: number;
|
|
42
|
+
totalTokens: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FileScanState {
|
|
46
|
+
size: number;
|
|
47
|
+
mtimeMs: number;
|
|
48
|
+
project?: string;
|
|
49
|
+
/** parsed contribution of this file (date -> model -> totals), so unchanged files are never re-read */
|
|
50
|
+
days: Record<string, Record<string, ToolDayTotals>>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`eval: fixtures → rollups > produces a stable per-day rollup (golden cost snapshot) 1`] = `
|
|
4
|
+
{
|
|
5
|
+
"claudeCost": 0.0576,
|
|
6
|
+
"claudeTokens": 27000,
|
|
7
|
+
"codexCost": 0.02905,
|
|
8
|
+
"codexTokens": 46200,
|
|
9
|
+
"date": "2026-07-05",
|
|
10
|
+
"totalCost": 0.08665,
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildRollups, eventsToDays, localDate, totalTokens } from '../src/aggregate.ts';
|
|
3
|
+
import type { UsageEvent } from '../src/types.ts';
|
|
4
|
+
|
|
5
|
+
const ev = (over: Partial<UsageEvent>): UsageEvent => ({
|
|
6
|
+
tool: 'claude',
|
|
7
|
+
timestamp: '2026-07-05T10:00:00.000Z',
|
|
8
|
+
model: 'claude-sonnet-5',
|
|
9
|
+
inputTokens: 100,
|
|
10
|
+
outputTokens: 50,
|
|
11
|
+
cacheWriteTokens: 0,
|
|
12
|
+
cacheReadTokens: 0,
|
|
13
|
+
...over,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('aggregate', () => {
|
|
17
|
+
it('groups events into local dates and counts unpriced models separately', () => {
|
|
18
|
+
const days = eventsToDays([ev({}), ev({ model: 'mystery-model' })]);
|
|
19
|
+
const day = days['2026-07-05'];
|
|
20
|
+
expect(day['claude-sonnet-5'].costUSD).toBeGreaterThan(0);
|
|
21
|
+
expect(day['mystery-model'].costUSD).toBe(0);
|
|
22
|
+
expect(day['mystery-model'].unpricedEvents).toBe(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('merges multiple sources into per-day tool/model/project rollups', () => {
|
|
26
|
+
const claudeDays = eventsToDays([ev({ inputTokens: 1_000_000 })]);
|
|
27
|
+
const codexDays = eventsToDays([ev({ tool: 'codex', model: 'gpt-5.2-codex', inputTokens: 500_000 })]);
|
|
28
|
+
const rollups = buildRollups([
|
|
29
|
+
{ tool: 'claude', project: '/p/a', days: claudeDays },
|
|
30
|
+
{ tool: 'codex', project: '/p/b', days: codexDays },
|
|
31
|
+
]);
|
|
32
|
+
expect(rollups).toHaveLength(1);
|
|
33
|
+
const day = rollups[0];
|
|
34
|
+
expect(day.date).toBe('2026-07-05');
|
|
35
|
+
expect(day.byTool.claude!.inputTokens).toBe(1_000_000);
|
|
36
|
+
expect(day.byTool.codex!.inputTokens).toBe(500_000);
|
|
37
|
+
expect(Object.keys(day.byProject).sort()).toEqual(['/p/a', '/p/b']);
|
|
38
|
+
expect(day.totalCostUSD).toBeCloseTo(day.byTool.claude!.costUSD + day.byTool.codex!.costUSD, 10);
|
|
39
|
+
expect(day.totalTokens).toBe(totalTokens(day.byTool.claude!) + totalTokens(day.byTool.codex!));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('maps ISO timestamps to local calendar dates', () => {
|
|
43
|
+
expect(localDate('2026-07-05T12:00:00.000Z')).toBe('2026-07-05');
|
|
44
|
+
expect(localDate('2026-07-06T23:59:59.000Z')).toBe('2026-07-06');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('sorts rollups by date ascending across multiple days', () => {
|
|
48
|
+
const later = eventsToDays([ev({ timestamp: '2026-07-06T10:00:00.000Z' })]);
|
|
49
|
+
const earlier = eventsToDays([ev({ timestamp: '2026-07-05T10:00:00.000Z' })]);
|
|
50
|
+
const rollups = buildRollups([
|
|
51
|
+
{ tool: 'claude', days: later },
|
|
52
|
+
{ tool: 'codex', days: earlier },
|
|
53
|
+
]);
|
|
54
|
+
expect(rollups.map((r) => r.date)).toEqual(['2026-07-05', '2026-07-06']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('groups sources without a project under (unknown)', () => {
|
|
58
|
+
const days = eventsToDays([ev({ inputTokens: 100 })]);
|
|
59
|
+
const rollups = buildRollups([{ tool: 'claude', days }]);
|
|
60
|
+
expect(rollups[0].byProject['(unknown)']!.inputTokens).toBe(100);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('accumulates multiple models on the same day into byModel totals', () => {
|
|
64
|
+
const days = eventsToDays([
|
|
65
|
+
ev({ model: 'claude-sonnet-5', inputTokens: 100 }),
|
|
66
|
+
ev({ model: 'claude-haiku-4-5-20251001', inputTokens: 200 }),
|
|
67
|
+
]);
|
|
68
|
+
const rollups = buildRollups([{ tool: 'claude', project: '/p/a', days }]);
|
|
69
|
+
expect(Object.keys(rollups[0].byModel)).toHaveLength(2);
|
|
70
|
+
expect(rollups[0].byModel['claude-sonnet-5'].inputTokens).toBe(100);
|
|
71
|
+
expect(rollups[0].byModel['claude-haiku-4-5-20251001'].inputTokens).toBe(200);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseClaudeSession } from '../src/parsers/claude.ts';
|
|
3
|
+
|
|
4
|
+
const assistantLine = (over: Record<string, unknown> = {}, usage: Record<string, number> = {}) =>
|
|
5
|
+
JSON.stringify({
|
|
6
|
+
type: 'assistant',
|
|
7
|
+
timestamp: '2026-07-05T10:00:00.000Z',
|
|
8
|
+
requestId: 'req_1',
|
|
9
|
+
cwd: '/Users/surya/proj/a',
|
|
10
|
+
message: {
|
|
11
|
+
id: 'msg_1',
|
|
12
|
+
model: 'claude-sonnet-5',
|
|
13
|
+
usage: {
|
|
14
|
+
input_tokens: 10,
|
|
15
|
+
output_tokens: 100,
|
|
16
|
+
cache_creation_input_tokens: 500,
|
|
17
|
+
cache_read_input_tokens: 2000,
|
|
18
|
+
...usage,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
...over,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('parseClaudeSession', () => {
|
|
25
|
+
it('extracts usage events with project from cwd', () => {
|
|
26
|
+
const { events, project } = parseClaudeSession(
|
|
27
|
+
[JSON.stringify({ type: 'user', cwd: '/Users/surya/proj/a' }), assistantLine()].join('\n'),
|
|
28
|
+
new Set(),
|
|
29
|
+
);
|
|
30
|
+
expect(project).toBe('/Users/surya/proj/a');
|
|
31
|
+
expect(events).toHaveLength(1);
|
|
32
|
+
expect(events[0]).toMatchObject({
|
|
33
|
+
tool: 'claude',
|
|
34
|
+
model: 'claude-sonnet-5',
|
|
35
|
+
inputTokens: 10,
|
|
36
|
+
outputTokens: 100,
|
|
37
|
+
cacheWriteTokens: 500,
|
|
38
|
+
cacheReadTokens: 2000,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('dedupes identical message.id + requestId across files', () => {
|
|
43
|
+
const dedupe = new Set<string>();
|
|
44
|
+
const a = parseClaudeSession(assistantLine(), dedupe);
|
|
45
|
+
const b = parseClaudeSession(assistantLine(), dedupe);
|
|
46
|
+
expect(a.events).toHaveLength(1);
|
|
47
|
+
expect(b.events).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps events that lack ids instead of deduping them', () => {
|
|
51
|
+
const line = assistantLine({ requestId: undefined });
|
|
52
|
+
const dedupe = new Set<string>();
|
|
53
|
+
expect(parseClaudeSession(`${line}\n${line}`, dedupe).events).toHaveLength(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('extracts ephemeral 1h cache-write tokens from usage.cache_creation', () => {
|
|
57
|
+
const line = assistantLine(
|
|
58
|
+
{},
|
|
59
|
+
{
|
|
60
|
+
cache_creation_input_tokens: 1000,
|
|
61
|
+
cache_creation: { ephemeral_1h_input_tokens: 800 },
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
const { events } = parseClaudeSession(line, new Set());
|
|
65
|
+
expect(events[0].cacheWriteTokens).toBe(1000);
|
|
66
|
+
expect(events[0].cacheWrite1hTokens).toBe(800);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('skips assistant lines missing timestamp, usage, or model', () => {
|
|
70
|
+
const noTs = JSON.stringify({
|
|
71
|
+
type: 'assistant',
|
|
72
|
+
message: { id: 'm', model: 'claude-sonnet-5', usage: { input_tokens: 1, output_tokens: 1 } },
|
|
73
|
+
});
|
|
74
|
+
const noUsage = assistantLine({ timestamp: '2026-07-05T10:00:00.000Z', message: { id: 'm', model: 'claude-sonnet-5' } });
|
|
75
|
+
const noModel = assistantLine({ message: { id: 'm', usage: { input_tokens: 1, output_tokens: 1 } } });
|
|
76
|
+
expect(parseClaudeSession([noTs, noUsage, noModel].join('\n'), new Set()).events).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('skips synthetic models, malformed lines, and non-assistant entries', () => {
|
|
80
|
+
const content = [
|
|
81
|
+
'not json{{{',
|
|
82
|
+
JSON.stringify({ type: 'user', message: { role: 'user' } }),
|
|
83
|
+
assistantLine({ message: { id: 'm2', model: '<synthetic>', usage: { input_tokens: 5 } } }),
|
|
84
|
+
].join('\n');
|
|
85
|
+
expect(parseClaudeSession(content, new Set()).events).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseCodexSession } from '../src/parsers/codex.ts';
|
|
3
|
+
|
|
4
|
+
const meta = JSON.stringify({
|
|
5
|
+
timestamp: '2026-07-05T09:00:00.000Z',
|
|
6
|
+
type: 'session_meta',
|
|
7
|
+
payload: { id: 's1', cwd: '/Users/surya/proj/b', model_provider: 'openai' },
|
|
8
|
+
});
|
|
9
|
+
const turn = JSON.stringify({
|
|
10
|
+
timestamp: '2026-07-05T09:00:01.000Z',
|
|
11
|
+
type: 'turn_context',
|
|
12
|
+
payload: { model: 'gpt-5.2-codex', cwd: '/Users/surya/proj/b', effort: 'medium' },
|
|
13
|
+
});
|
|
14
|
+
const count = (input: number, cached: number, output: number, ts = '2026-07-05T09:01:00.000Z') =>
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
timestamp: ts,
|
|
17
|
+
type: 'event_msg',
|
|
18
|
+
payload: {
|
|
19
|
+
type: 'token_count',
|
|
20
|
+
info: {
|
|
21
|
+
total_token_usage: { input_tokens: input, cached_input_tokens: cached, output_tokens: output, total_tokens: input + output },
|
|
22
|
+
last_token_usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('parseCodexSession', () => {
|
|
28
|
+
it('takes deltas of the cumulative counter and tracks model/project', () => {
|
|
29
|
+
const { events, project } = parseCodexSession([meta, turn, count(1000, 800, 50), count(2500, 2000, 120)].join('\n'));
|
|
30
|
+
expect(project).toBe('/Users/surya/proj/b');
|
|
31
|
+
expect(events).toHaveLength(2);
|
|
32
|
+
expect(events[0]).toMatchObject({ tool: 'codex', model: 'gpt-5.2-codex', inputTokens: 1000, cacheReadTokens: 800, outputTokens: 50 });
|
|
33
|
+
expect(events[1]).toMatchObject({ inputTokens: 1500, cacheReadTokens: 1200, outputTokens: 70 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('treats a shrinking counter as a reset, not a negative delta', () => {
|
|
37
|
+
const { events } = parseCodexSession([turn, count(1000, 800, 50), count(300, 0, 10)].join('\n'));
|
|
38
|
+
expect(events).toHaveLength(2);
|
|
39
|
+
expect(events[1]).toMatchObject({ inputTokens: 300, cacheReadTokens: 0, outputTokens: 10 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('skips malformed JSON lines and entries without payload', () => {
|
|
43
|
+
const noPayload = JSON.stringify({ timestamp: '2026-07-05T09:00:00.000Z', type: 'event_msg' });
|
|
44
|
+
const { events } = parseCodexSession(['not json{{{', noPayload, turn, count(100, 0, 10)].join('\n'));
|
|
45
|
+
expect(events).toHaveLength(1);
|
|
46
|
+
expect(events[0].inputTokens).toBe(100);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('skips zero-delta events and token_count without info', () => {
|
|
50
|
+
const empty = JSON.stringify({ timestamp: '2026-07-05T09:02:00.000Z', type: 'event_msg', payload: { type: 'token_count', info: null } });
|
|
51
|
+
const { events } = parseCodexSession([turn, count(1000, 0, 50), count(1000, 0, 50), empty].join('\n'));
|
|
52
|
+
expect(events).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { fetchCursorUsage } from '../src/cursor.ts';
|
|
3
|
+
|
|
4
|
+
const ts = new Date('2026-07-05T12:00:00.000Z').getTime();
|
|
5
|
+
|
|
6
|
+
function mockResponse(body: unknown, status = 200): Response {
|
|
7
|
+
return {
|
|
8
|
+
ok: status >= 200 && status < 300,
|
|
9
|
+
status,
|
|
10
|
+
json: async () => body,
|
|
11
|
+
} as Response;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('fetchCursorUsage', () => {
|
|
15
|
+
it('aggregates token usage and billed cost from usageEventsDisplay', async () => {
|
|
16
|
+
const result = await fetchCursorUsage(' my-cookie ', {
|
|
17
|
+
fetchImpl: async () =>
|
|
18
|
+
mockResponse({
|
|
19
|
+
usageEventsDisplay: [
|
|
20
|
+
{
|
|
21
|
+
timestamp: ts,
|
|
22
|
+
model: 'claude-sonnet-5',
|
|
23
|
+
tokenUsage: { inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, totalCents: 42 },
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
totalUsageEventsCount: 1,
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
expect(result.status).toBe('ok');
|
|
30
|
+
const day = result.days['2026-07-05']['claude-sonnet-5'];
|
|
31
|
+
expect(day.inputTokens).toBe(1000);
|
|
32
|
+
expect(day.outputTokens).toBe(500);
|
|
33
|
+
expect(day.cacheReadTokens).toBe(200);
|
|
34
|
+
expect(day.costUSD).toBeCloseTo(0.42, 6);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('falls back to usageEvents and defaults the model name to cursor', async () => {
|
|
38
|
+
const result = await fetchCursorUsage('cookie', {
|
|
39
|
+
fetchImpl: async () =>
|
|
40
|
+
mockResponse({
|
|
41
|
+
usageEvents: [{ timestamp: String(ts), tokenUsage: { totalCents: 10 } }],
|
|
42
|
+
totalUsageEventsCount: 1,
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
expect(result.status).toBe('ok');
|
|
46
|
+
expect(result.days['2026-07-05'].cursor.costUSD).toBeCloseTo(0.1, 6);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('paginates until all events are fetched', async () => {
|
|
50
|
+
let calls = 0;
|
|
51
|
+
const result = await fetchCursorUsage('cookie', {
|
|
52
|
+
fetchImpl: async () => {
|
|
53
|
+
calls++;
|
|
54
|
+
if (calls === 1) {
|
|
55
|
+
return mockResponse({
|
|
56
|
+
usageEventsDisplay: Array.from({ length: 300 }, () => ({
|
|
57
|
+
timestamp: ts,
|
|
58
|
+
model: 'cursor',
|
|
59
|
+
tokenUsage: { totalCents: 1 },
|
|
60
|
+
})),
|
|
61
|
+
totalUsageEventsCount: 301,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return mockResponse({
|
|
65
|
+
usageEventsDisplay: [{ timestamp: ts, model: 'cursor', tokenUsage: { totalCents: 1 } }],
|
|
66
|
+
totalUsageEventsCount: 301,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(result.status).toBe('ok');
|
|
71
|
+
expect(calls).toBe(2);
|
|
72
|
+
expect(result.days['2026-07-05'].cursor.costUSD).toBeCloseTo(3.01, 6);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns unauthorized on 401/403 and when the body is not JSON', async () => {
|
|
76
|
+
expect((await fetchCursorUsage('bad', { fetchImpl: async () => mockResponse({}, 401) })).status).toBe('unauthorized');
|
|
77
|
+
expect((await fetchCursorUsage('bad', { fetchImpl: async () => mockResponse({}, 403) })).status).toBe('unauthorized');
|
|
78
|
+
expect(
|
|
79
|
+
(
|
|
80
|
+
await fetchCursorUsage('bad', {
|
|
81
|
+
fetchImpl: async () =>
|
|
82
|
+
({
|
|
83
|
+
ok: true,
|
|
84
|
+
status: 200,
|
|
85
|
+
json: async () => {
|
|
86
|
+
throw new Error('not json');
|
|
87
|
+
},
|
|
88
|
+
}) as Response,
|
|
89
|
+
})
|
|
90
|
+
).status,
|
|
91
|
+
).toBe('unauthorized');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns error on network failure and non-ok HTTP', async () => {
|
|
95
|
+
const net = await fetchCursorUsage('cookie', {
|
|
96
|
+
fetchImpl: async () => {
|
|
97
|
+
throw new Error('offline');
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
expect(net.status).toBe('error');
|
|
101
|
+
expect(net.detail).toContain('offline');
|
|
102
|
+
|
|
103
|
+
const http = await fetchCursorUsage('cookie', {
|
|
104
|
+
fetchImpl: async () => mockResponse({}, 500),
|
|
105
|
+
});
|
|
106
|
+
expect(http.status).toBe('error');
|
|
107
|
+
expect(http.detail).toBe('HTTP 500');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('skips events with missing or invalid timestamps', async () => {
|
|
111
|
+
const result = await fetchCursorUsage('cookie', {
|
|
112
|
+
fetchImpl: async () =>
|
|
113
|
+
mockResponse({
|
|
114
|
+
usageEventsDisplay: [
|
|
115
|
+
{ timestamp: undefined, tokenUsage: { totalCents: 99 } },
|
|
116
|
+
{ timestamp: 'not-a-number', tokenUsage: { totalCents: 99 } },
|
|
117
|
+
{ timestamp: ts, tokenUsage: { totalCents: 5 } },
|
|
118
|
+
],
|
|
119
|
+
totalUsageEventsCount: 3,
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
expect(result.status).toBe('ok');
|
|
123
|
+
expect(Object.keys(result.days)).toEqual(['2026-07-05']);
|
|
124
|
+
expect(result.days['2026-07-05'].cursor.costUSD).toBeCloseTo(0.05, 6);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { parseClaudeSession } from '../src/parsers/claude.ts';
|
|
6
|
+
import { parseCodexSession } from '../src/parsers/codex.ts';
|
|
7
|
+
import { buildRollups, eventsToDays, totalTokens } from '../src/aggregate.ts';
|
|
8
|
+
|
|
9
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
10
|
+
const read = (name: string) => readFileSync(join(fixtures, name), 'utf8');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Golden end-to-end eval: parse committed fixture logs → aggregate → assert
|
|
14
|
+
* exact token totals and a snapshotted cost. Fully offline and deterministic
|
|
15
|
+
* (pricing is vendored), so it runs as a CI gate. This is the CI-safe stand-in
|
|
16
|
+
* for `npm run validate`, which needs the user's real ~/.claude logs + network.
|
|
17
|
+
*/
|
|
18
|
+
describe('eval: fixtures → rollups', () => {
|
|
19
|
+
const claude = parseClaudeSession(read('claude-session.jsonl'), new Set());
|
|
20
|
+
const codex = parseCodexSession(read('codex-session.jsonl'));
|
|
21
|
+
|
|
22
|
+
it('parses Claude usage including the 1h cache-write bucket', () => {
|
|
23
|
+
expect(claude.project).toBe('/Users/dev/proj/alpha');
|
|
24
|
+
expect(claude.events).toHaveLength(2);
|
|
25
|
+
const sum = claude.events.reduce(
|
|
26
|
+
(a, e) => ({
|
|
27
|
+
input: a.input + e.inputTokens,
|
|
28
|
+
output: a.output + e.outputTokens,
|
|
29
|
+
cacheWrite: a.cacheWrite + e.cacheWriteTokens,
|
|
30
|
+
cacheWrite1h: a.cacheWrite1h + (e.cacheWrite1hTokens ?? 0),
|
|
31
|
+
cacheRead: a.cacheRead + e.cacheReadTokens,
|
|
32
|
+
}),
|
|
33
|
+
{ input: 0, output: 0, cacheWrite: 0, cacheWrite1h: 0, cacheRead: 0 },
|
|
34
|
+
);
|
|
35
|
+
expect(sum).toEqual({ input: 1500, output: 3500, cacheWrite: 4000, cacheWrite1h: 4000, cacheRead: 18000 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('takes Codex deltas and ignores the duplicated token_count event', () => {
|
|
39
|
+
// The fixture emits the first token_count twice; the dup is a zero delta.
|
|
40
|
+
expect(codex.events).toHaveLength(2);
|
|
41
|
+
const t = codex.events.reduce(
|
|
42
|
+
(a, e) => ({
|
|
43
|
+
input: a.input + e.inputTokens,
|
|
44
|
+
cacheRead: a.cacheRead + e.cacheReadTokens,
|
|
45
|
+
output: a.output + e.outputTokens,
|
|
46
|
+
}),
|
|
47
|
+
{ input: 0, cacheRead: 0, output: 0 },
|
|
48
|
+
);
|
|
49
|
+
expect(t).toEqual({ input: 25000, cacheRead: 20000, output: 1200 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('produces a stable per-day rollup (golden cost snapshot)', () => {
|
|
53
|
+
const rollups = buildRollups([
|
|
54
|
+
{ tool: 'claude', project: claude.project, days: eventsToDays(claude.events) },
|
|
55
|
+
{ tool: 'codex', project: codex.project, days: eventsToDays(codex.events) },
|
|
56
|
+
]);
|
|
57
|
+
const day = rollups.find((r) => r.date === '2026-07-05');
|
|
58
|
+
expect(day).toBeDefined();
|
|
59
|
+
const summary = {
|
|
60
|
+
date: day!.date,
|
|
61
|
+
claudeTokens: totalTokens(day!.byTool.claude),
|
|
62
|
+
codexTokens: totalTokens(day!.byTool.codex),
|
|
63
|
+
claudeCost: +day!.byTool.claude.costUSD.toFixed(6),
|
|
64
|
+
codexCost: +day!.byTool.codex.costUSD.toFixed(6),
|
|
65
|
+
totalCost: +day!.totalCostUSD.toFixed(6),
|
|
66
|
+
};
|
|
67
|
+
expect(summary).toMatchSnapshot();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
{"type":"user","cwd":"/Users/dev/proj/alpha","timestamp":"2026-07-05T09:59:00.000Z"}
|
|
2
|
+
{"type":"assistant","timestamp":"2026-07-05T10:00:00.000Z","requestId":"req_a1","cwd":"/Users/dev/proj/alpha","message":{"id":"msg_a1","model":"claude-sonnet-5","usage":{"input_tokens":1000,"output_tokens":2000,"cache_creation_input_tokens":4000,"cache_creation":{"ephemeral_1h_input_tokens":4000},"cache_read_input_tokens":8000}}}
|
|
3
|
+
{"type":"assistant","timestamp":"2026-07-05T10:05:00.000Z","requestId":"req_a2","cwd":"/Users/dev/proj/alpha","message":{"id":"msg_a2","model":"claude-sonnet-5","usage":{"input_tokens":500,"output_tokens":1500,"cache_creation_input_tokens":0,"cache_read_input_tokens":10000}}}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"timestamp":"2026-07-05T09:00:00.000Z","type":"session_meta","payload":{"id":"s1","cwd":"/Users/dev/proj/beta","model_provider":"openai"}}
|
|
2
|
+
{"timestamp":"2026-07-05T09:00:01.000Z","type":"turn_context","payload":{"model":"gpt-5.2-codex","cwd":"/Users/dev/proj/beta","effort":"medium"}}
|
|
3
|
+
{"timestamp":"2026-07-05T09:01:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10000,"cached_input_tokens":8000,"output_tokens":500,"total_tokens":10500}}}}
|
|
4
|
+
{"timestamp":"2026-07-05T09:01:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10000,"cached_input_tokens":8000,"output_tokens":500,"total_tokens":10500}}}}
|
|
5
|
+
{"timestamp":"2026-07-05T09:02:00.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":25000,"cached_input_tokens":20000,"output_tokens":1200,"total_tokens":26200}}}}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { eventCostUSD, findPrice, fetchPrices, setPriceTable } from '../src/pricing/index.ts';
|
|
3
|
+
import type { UsageEvent } from '../src/types.ts';
|
|
4
|
+
|
|
5
|
+
const ev = (over: Partial<UsageEvent>): UsageEvent => ({
|
|
6
|
+
tool: 'claude',
|
|
7
|
+
timestamp: '2026-07-05T10:00:00.000Z',
|
|
8
|
+
model: 'claude-sonnet-5',
|
|
9
|
+
inputTokens: 0,
|
|
10
|
+
outputTokens: 0,
|
|
11
|
+
cacheWriteTokens: 0,
|
|
12
|
+
cacheReadTokens: 0,
|
|
13
|
+
...over,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('pricing', () => {
|
|
17
|
+
it('prices anthropic events with disjoint cache buckets', () => {
|
|
18
|
+
const cost = eventCostUSD(
|
|
19
|
+
ev({ inputTokens: 1000, outputTokens: 1000, cacheWriteTokens: 1000, cacheReadTokens: 1000 }),
|
|
20
|
+
);
|
|
21
|
+
expect(cost).toBeCloseTo(0.0147, 6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('prices openai events where input includes cached tokens', () => {
|
|
25
|
+
const cost = eventCostUSD(
|
|
26
|
+
ev({ tool: 'codex', model: 'gpt-5.2-codex', inputTokens: 2000, cacheReadTokens: 1000, outputTokens: 100 }),
|
|
27
|
+
);
|
|
28
|
+
expect(cost).toBeCloseTo(0.003325, 7);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('resolves dated model ids and -latest suffixes', () => {
|
|
32
|
+
expect(findPrice('claude-haiku-4-5-20251001')).toBeDefined();
|
|
33
|
+
setPriceTable({
|
|
34
|
+
'gpt-4o': { provider: 'openai', input_cost_per_token: 1, output_cost_per_token: 1 },
|
|
35
|
+
});
|
|
36
|
+
expect(findPrice('gpt-4o-latest')).toBeDefined();
|
|
37
|
+
expect(eventCostUSD(ev({ model: 'some-future-model' }))).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('prices 1h cache writes at 2x input rate separately from 5m writes', () => {
|
|
41
|
+
setPriceTable({
|
|
42
|
+
'test-anthropic': {
|
|
43
|
+
provider: 'anthropic',
|
|
44
|
+
input_cost_per_token: 0.001,
|
|
45
|
+
output_cost_per_token: 0,
|
|
46
|
+
cache_creation_input_token_cost: 0.00125,
|
|
47
|
+
cache_read_input_token_cost: 0.0001,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const cost = eventCostUSD(
|
|
51
|
+
ev({ model: 'test-anthropic', cacheWriteTokens: 1000, cacheWrite1hTokens: 600 }),
|
|
52
|
+
);
|
|
53
|
+
expect(cost).toBeCloseTo(400 * 0.00125 + 600 * 0.002, 6);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('fetches model pricing and updates the pricing table with throttling', async () => {
|
|
57
|
+
let fetchCount = 0;
|
|
58
|
+
const mockFetch = async () => {
|
|
59
|
+
fetchCount++;
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
json: async () => ({
|
|
63
|
+
data: [
|
|
64
|
+
{
|
|
65
|
+
id: 'custom-provider/custom-test-model',
|
|
66
|
+
provider: 'custom-provider',
|
|
67
|
+
inputPricePer1M: 10,
|
|
68
|
+
outputPricePer1M: 20,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'anthropic/claude-test',
|
|
72
|
+
provider: 'Anthropic',
|
|
73
|
+
inputPricePer1M: 3,
|
|
74
|
+
outputPricePer1M: 15,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'openai/gpt-test',
|
|
78
|
+
provider: 'OpenAI',
|
|
79
|
+
inputPricePer1M: 2,
|
|
80
|
+
outputPricePer1M: 8,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
} as any;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Force fetch should invoke mockFetch
|
|
88
|
+
const ok = await fetchPrices({ fetchImpl: mockFetch, force: true });
|
|
89
|
+
expect(ok).toBe(true);
|
|
90
|
+
expect(fetchCount).toBe(1);
|
|
91
|
+
|
|
92
|
+
// Verify model was added with full ID and bare ID
|
|
93
|
+
const priceFull = findPrice('custom-provider/custom-test-model');
|
|
94
|
+
expect(priceFull).toBeDefined();
|
|
95
|
+
expect(priceFull?.input_cost_per_token).toBe(10 / 1000000);
|
|
96
|
+
expect(priceFull?.output_cost_per_token).toBe(20 / 1000000);
|
|
97
|
+
|
|
98
|
+
const priceBare = findPrice('custom-test-model');
|
|
99
|
+
expect(priceBare).toBeDefined();
|
|
100
|
+
expect(priceBare?.input_cost_per_token).toBe(10 / 1000000);
|
|
101
|
+
|
|
102
|
+
const anthropic = findPrice('claude-test');
|
|
103
|
+
expect(anthropic?.cache_creation_input_token_cost).toBeCloseTo((3 / 1_000_000) * 1.25, 12);
|
|
104
|
+
expect(anthropic?.cache_read_input_token_cost).toBeCloseTo((3 / 1_000_000) * 0.1, 12);
|
|
105
|
+
|
|
106
|
+
const openai = findPrice('gpt-test');
|
|
107
|
+
expect(openai?.cache_read_input_token_cost).toBeCloseTo((2 / 1_000_000) * 0.5, 12);
|
|
108
|
+
|
|
109
|
+
// Throttling: consecutive call without 'force' should not invoke mockFetch
|
|
110
|
+
const ok2 = await fetchPrices({ fetchImpl: mockFetch, force: false });
|
|
111
|
+
expect(ok2).toBe(true);
|
|
112
|
+
expect(fetchCount).toBe(1);
|
|
113
|
+
|
|
114
|
+
// Failure cases should preserve the existing table and return false
|
|
115
|
+
const badFetch = async () => {
|
|
116
|
+
throw new Error('network down');
|
|
117
|
+
};
|
|
118
|
+
const ok3 = await fetchPrices({ fetchImpl: badFetch, force: true });
|
|
119
|
+
expect(ok3).toBe(false);
|
|
120
|
+
expect(findPrice('custom-test-model')).toBeDefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns false when the API responds with an error or invalid body', async () => {
|
|
124
|
+
expect(
|
|
125
|
+
await fetchPrices({
|
|
126
|
+
fetchImpl: async () => ({ ok: false }) as Response,
|
|
127
|
+
force: true,
|
|
128
|
+
}),
|
|
129
|
+
).toBe(false);
|
|
130
|
+
expect(
|
|
131
|
+
await fetchPrices({
|
|
132
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({}) }) as Response,
|
|
133
|
+
force: true,
|
|
134
|
+
}),
|
|
135
|
+
).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"rewriteRelativeImportExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"types": [
|
|
15
|
+
"node"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src"
|
|
20
|
+
]
|
|
21
|
+
}
|