letmecode 0.1.1 → 0.1.2
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 +35 -9
- package/ink-app/dist/index.js +194 -21
- package/ink-app/dist/providers/claude.js +117 -31
- package/ink-app/dist/providers/codex.js +11 -4
- package/ink-app/dist/providers/contract.js +6 -0
- package/ink-app/dist/providers/copilot.js +380 -0
- package/ink-app/dist/providers/daily.js +64 -0
- package/ink-app/dist/providers/index.js +3 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
|
-
# letmecode
|
|
1
|
+
# letmecode - Discover your detailed agent ussage Codex | Claude
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Local development
|
|
3
|
+
Ussage:
|
|
6
4
|
|
|
7
5
|
```bash
|
|
8
|
-
|
|
9
|
-
pnpm start
|
|
6
|
+
npx -y letmecode
|
|
10
7
|
```
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
<img width="2308" height="1491" alt="image" src="https://github.com/user-attachments/assets/f3f52d79-00e3-4ff5-bf2f-65f8be632aaa" />
|
|
10
|
+
|
|
11
|
+
## Controls
|
|
12
|
+
|
|
13
|
+
| Key | Action |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `[` / `]` | Switch providers |
|
|
16
|
+
| `Tab` / `Shift+Tab` | Switch providers when supported by the terminal |
|
|
17
|
+
| `Up` / `Down` or `k` / `j` | Switch dashboard sections |
|
|
18
|
+
| `Left` / `Right` | Select the previous or next table row |
|
|
19
|
+
| `Enter` | Run the selected provider action |
|
|
20
|
+
| `1` | Select the Copilot VS Code setup action |
|
|
21
|
+
| `h` / `l` | Select a Copilot setup action |
|
|
22
|
+
| `q` or `Esc` | Quit |
|
|
23
|
+
|
|
24
|
+
## Copilot
|
|
13
25
|
|
|
14
|
-
|
|
26
|
+
Copilot CLI usage is read from `~/.copilot/session-state`.
|
|
27
|
+
|
|
28
|
+
VS Code extension usage needs file OTEL logging first. Select the `Copilot` provider, choose `Start logging VS Code` with `1` or `h` / `l`, then press `Enter`; letmecode will update the current user's VS Code settings with:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"github.copilot.chat.otel.enabled": true,
|
|
33
|
+
"github.copilot.chat.otel.exporterType": "file",
|
|
34
|
+
"github.copilot.chat.otel.outfile": "~/.copilot/otel/vscode.jsonl",
|
|
35
|
+
"github.copilot.chat.otel.captureContent": false
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Local development
|
|
15
40
|
|
|
16
41
|
```bash
|
|
17
|
-
|
|
42
|
+
pnpm install
|
|
43
|
+
pnpm start
|
|
18
44
|
```
|
package/ink-app/dist/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text, useApp, useInput, render } from "ink";
|
|
4
|
-
import { createProviders } from "./providers/index.js";
|
|
4
|
+
import { configureCopilotVsCodeLogging, createProviders } from "./providers/index.js";
|
|
5
5
|
const VERTICAL_TABS = [
|
|
6
6
|
{ id: "limit-windows", label: "Limits" },
|
|
7
7
|
{ id: "summary", label: "Summary" },
|
|
8
|
+
{ id: "day-to-day-analyses", label: "day to day" },
|
|
8
9
|
{ id: "usage-by-model", label: "by model" }
|
|
9
10
|
];
|
|
10
11
|
const CODEX_CREDIT_COST_USD = 0.01;
|
|
12
|
+
const VERTICAL_TAB_WIDTH = 12;
|
|
11
13
|
const LIMIT_WINDOW_COLUMNS = {
|
|
12
14
|
plan: 8,
|
|
13
15
|
window: 8,
|
|
@@ -24,27 +26,46 @@ const MODEL_USAGE_COLUMNS = {
|
|
|
24
26
|
credits: 12,
|
|
25
27
|
value: 12
|
|
26
28
|
};
|
|
27
|
-
|
|
29
|
+
const DAY_USAGE_COLUMNS = {
|
|
30
|
+
day: 11,
|
|
31
|
+
events: 6,
|
|
32
|
+
input: 11,
|
|
33
|
+
output: 10,
|
|
34
|
+
value: 10
|
|
35
|
+
};
|
|
36
|
+
const COPILOT_ACTIONS = [
|
|
37
|
+
{ id: "vscode", label: "Start logging VS Code", enabled: true }
|
|
38
|
+
];
|
|
39
|
+
function App(props) {
|
|
28
40
|
const { exit } = useApp();
|
|
29
41
|
const providers = React.useState(() => createProviders())[0];
|
|
30
42
|
const [providerStates, setProviderStates] = useState(providers.map((provider) => ({ provider, status: "loading" })));
|
|
31
|
-
const [
|
|
43
|
+
const [selectedProviderId, setSelectedProviderId] = useState(providers[0]?.id ?? "");
|
|
44
|
+
const [hasUserSelectedProvider, setHasUserSelectedProvider] = useState(false);
|
|
32
45
|
const [selectedVerticalTabIndex, setSelectedVerticalTabIndex] = useState(0);
|
|
33
46
|
const [selectedLimitRowIndex, setSelectedLimitRowIndex] = useState(0);
|
|
47
|
+
const [selectedDayRowIndex, setSelectedDayRowIndex] = useState(0);
|
|
34
48
|
const [selectedModelRowIndex, setSelectedModelRowIndex] = useState(0);
|
|
35
|
-
const
|
|
49
|
+
const [selectedCopilotActionIndex, setSelectedCopilotActionIndex] = useState(0);
|
|
50
|
+
const [copilotActionMessage, setCopilotActionMessage] = useState();
|
|
51
|
+
const sortedProviderStates = React.useMemo(() => sortProviderStatesByUsage(providerStates), [providerStates]);
|
|
52
|
+
const selectedProviderIndex = Math.max(0, sortedProviderStates.findIndex((state) => state.provider.id === selectedProviderId));
|
|
53
|
+
const selectedProvider = sortedProviderStates[selectedProviderIndex];
|
|
36
54
|
const selectedVerticalTab = VERTICAL_TABS[selectedVerticalTabIndex];
|
|
37
55
|
const limitRows = getLimitRows(selectedProvider);
|
|
56
|
+
const dayRows = getDayRows(selectedProvider);
|
|
38
57
|
const modelRows = getModelRows(selectedProvider);
|
|
39
58
|
const activeLimitRowIndex = clampSelectionIndex(selectedLimitRowIndex, limitRows.length);
|
|
59
|
+
const activeDayRowIndex = clampSelectionIndex(selectedDayRowIndex, dayRows.length);
|
|
40
60
|
const activeModelRowIndex = clampSelectionIndex(selectedModelRowIndex, modelRows.length);
|
|
41
61
|
const selectedLimitRow = activeLimitRowIndex >= 0 ? limitRows[activeLimitRowIndex] : undefined;
|
|
62
|
+
const selectedDayRow = activeDayRowIndex >= 0 ? dayRows[activeDayRowIndex] : undefined;
|
|
42
63
|
const selectedModelRow = activeModelRowIndex >= 0 ? modelRows[activeModelRowIndex] : undefined;
|
|
43
64
|
useEffect(() => {
|
|
44
65
|
let cancelled = false;
|
|
45
66
|
for (const provider of providers) {
|
|
46
67
|
void provider
|
|
47
|
-
.getStats()
|
|
68
|
+
.getStats(props.statsOptions)
|
|
48
69
|
.then((stats) => {
|
|
49
70
|
if (cancelled) {
|
|
50
71
|
return;
|
|
@@ -66,13 +87,39 @@ function App() {
|
|
|
66
87
|
return () => {
|
|
67
88
|
cancelled = true;
|
|
68
89
|
};
|
|
69
|
-
}, [providers]);
|
|
90
|
+
}, [props.statsOptions, providers]);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (hasUserSelectedProvider || providerStates.some((state) => state.status === "loading")) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const topProvider = sortedProviderStates[0];
|
|
96
|
+
if (providerUsageScore(topProvider) <= 0) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setSelectedProviderId(topProvider.provider.id);
|
|
100
|
+
}, [hasUserSelectedProvider, providerStates, sortedProviderStates]);
|
|
70
101
|
useInput((input, key) => {
|
|
71
102
|
if (input === "q" || key.escape) {
|
|
72
103
|
exit();
|
|
73
104
|
return;
|
|
74
105
|
}
|
|
75
|
-
if (
|
|
106
|
+
if (selectedProvider.provider.id === "copilot" && input >= "1" && input <= String(COPILOT_ACTIONS.length)) {
|
|
107
|
+
setSelectedCopilotActionIndex(Number(input) - 1);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (selectedProvider.provider.id === "copilot" && key.return) {
|
|
111
|
+
runCopilotAction(COPILOT_ACTIONS[selectedCopilotActionIndex].id, setCopilotActionMessage);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (selectedProvider.provider.id === "copilot" && input === "l") {
|
|
115
|
+
setSelectedCopilotActionIndex((current) => (current + 1) % COPILOT_ACTIONS.length);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (selectedProvider.provider.id === "copilot" && input === "h") {
|
|
119
|
+
setSelectedCopilotActionIndex((current) => (current - 1 + COPILOT_ACTIONS.length) % COPILOT_ACTIONS.length);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (key.rightArrow) {
|
|
76
123
|
if (selectedVerticalTab.id === "limit-windows") {
|
|
77
124
|
setSelectedLimitRowIndex(clampSelectionIndex(activeLimitRowIndex + 1, limitRows.length));
|
|
78
125
|
return;
|
|
@@ -81,8 +128,12 @@ function App() {
|
|
|
81
128
|
setSelectedModelRowIndex(clampSelectionIndex(activeModelRowIndex + 1, modelRows.length));
|
|
82
129
|
return;
|
|
83
130
|
}
|
|
131
|
+
if (selectedVerticalTab.id === "day-to-day-analyses") {
|
|
132
|
+
setSelectedDayRowIndex(clampSelectionIndex(activeDayRowIndex + 1, dayRows.length));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
84
135
|
}
|
|
85
|
-
if (
|
|
136
|
+
if (key.leftArrow) {
|
|
86
137
|
if (selectedVerticalTab.id === "limit-windows") {
|
|
87
138
|
setSelectedLimitRowIndex(clampSelectionIndex(activeLimitRowIndex - 1, limitRows.length));
|
|
88
139
|
return;
|
|
@@ -91,13 +142,20 @@ function App() {
|
|
|
91
142
|
setSelectedModelRowIndex(clampSelectionIndex(activeModelRowIndex - 1, modelRows.length));
|
|
92
143
|
return;
|
|
93
144
|
}
|
|
145
|
+
if (selectedVerticalTab.id === "day-to-day-analyses") {
|
|
146
|
+
setSelectedDayRowIndex(clampSelectionIndex(activeDayRowIndex - 1, dayRows.length));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
94
149
|
}
|
|
95
|
-
if ((
|
|
96
|
-
|
|
150
|
+
if ((key.tab && !key.shift) || input === "]") {
|
|
151
|
+
setSelectedProviderId(sortedProviderStates[(selectedProviderIndex + 1) % sortedProviderStates.length].provider.id);
|
|
152
|
+
setHasUserSelectedProvider(true);
|
|
97
153
|
return;
|
|
98
154
|
}
|
|
99
|
-
if ((
|
|
100
|
-
|
|
155
|
+
if ((key.tab && key.shift) || input === "[") {
|
|
156
|
+
setSelectedProviderId(sortedProviderStates[(selectedProviderIndex - 1 + sortedProviderStates.length) % sortedProviderStates.length]
|
|
157
|
+
.provider.id);
|
|
158
|
+
setHasUserSelectedProvider(true);
|
|
101
159
|
return;
|
|
102
160
|
}
|
|
103
161
|
if (key.downArrow || input === "j") {
|
|
@@ -108,7 +166,39 @@ function App() {
|
|
|
108
166
|
setSelectedVerticalTabIndex((current) => (current - 1 + VERTICAL_TABS.length) % VERTICAL_TABS.length);
|
|
109
167
|
}
|
|
110
168
|
});
|
|
111
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "letmecode usage dashboard" }), _jsx(Text, { color: "gray", children: "
|
|
169
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "letmecode usage dashboard" }), _jsx(Text, { color: "gray", children: "[/]/tab to switch providers, j/k or up/down for details, left/right to select a row, enter for actions, q to quit" }), _jsx(Box, { marginTop: 1, children: sortedProviderStates.map((state) => (_jsx(ProviderTab, { label: state.provider.label, active: state.provider.id === selectedProvider.provider.id, status: state.status }, state.provider.id))) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Box, { flexDirection: "column", width: VERTICAL_TAB_WIDTH, marginRight: 2, children: VERTICAL_TABS.map((tab, index) => (_jsx(VerticalTab, { label: tab.label, active: index === selectedVerticalTabIndex }, tab.id))) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(ContentPanel, { providerState: selectedProvider, tabId: selectedVerticalTab.id, selectedLimitRowKey: selectedLimitRow ? getLimitRowKey(selectedLimitRow) : undefined, selectedDayKey: selectedDayRow?.dayKey, selectedModelId: selectedModelRow?.modelId }) })] }), _jsx(SelectionDetailsPanel, { providerState: selectedProvider, tabId: selectedVerticalTab.id, selectedLimitRow: selectedLimitRow, selectedDayRow: selectedDayRow, selectedModelRow: selectedModelRow }), _jsx(CopilotActionsPanel, { providerState: selectedProvider, actionMessage: copilotActionMessage, selectedActionIndex: selectedCopilotActionIndex }), selectedProvider.status === "ready" && selectedProvider.stats.warnings.length > 0 ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Warnings" }), selectedProvider.stats.warnings.map((warning) => (_jsx(Text, { children: warning }, warning)))] })) : null] }));
|
|
170
|
+
}
|
|
171
|
+
function CopilotActionsPanel(props) {
|
|
172
|
+
if (props.providerState.provider.id !== "copilot") {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const hasNoUsage = props.providerState.status === "ready" && props.providerState.stats.summary.tokenEvents === 0;
|
|
176
|
+
const accentColor = hasNoUsage ? "red" : "cyan";
|
|
177
|
+
return (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: accentColor, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: accentColor, children: "Copilot setup" }), _jsx(Box, { children: COPILOT_ACTIONS.map((action, index) => (_jsx(Box, { marginRight: 1, children: _jsx(Text, { inverse: index === (props.selectedActionIndex ?? 0), bold: hasNoUsage && action.id === "vscode", color: action.enabled ? (hasNoUsage && action.id === "vscode" ? accentColor : undefined) : "gray", children: `${index + 1} ${action.label}` }) }, action.id))) }), _jsx(Text, { color: hasNoUsage ? accentColor : "gray", children: "Press 1 or h/l to select an action, enter to run selected." }), props.actionMessage ? _jsx(Text, { children: props.actionMessage }) : null] }));
|
|
178
|
+
}
|
|
179
|
+
function runCopilotAction(actionId, setCopilotActionMessage) {
|
|
180
|
+
setCopilotActionMessage("Updating VS Code settings...");
|
|
181
|
+
void configureCopilotVsCodeLogging()
|
|
182
|
+
.then((result) => {
|
|
183
|
+
setCopilotActionMessage(formatCopilotLoggingResult(result));
|
|
184
|
+
})
|
|
185
|
+
.catch((error) => {
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
187
|
+
setCopilotActionMessage(`Failed to update VS Code settings: ${message}`);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function formatCopilotLoggingResult(result) {
|
|
191
|
+
const status = result.changed ? "VS Code logging enabled" : "VS Code logging already enabled";
|
|
192
|
+
return [
|
|
193
|
+
`${status}: ${result.outfile}`,
|
|
194
|
+
`Settings written to: ${result.settingsPath}`,
|
|
195
|
+
'Open "Preferences: Open User Settings (JSON)" in VS Code and verify that this is the active file.',
|
|
196
|
+
"Expected settings:",
|
|
197
|
+
'"github.copilot.chat.otel.enabled": true',
|
|
198
|
+
'"github.copilot.chat.otel.exporterType": "file"',
|
|
199
|
+
'"github.copilot.chat.otel.captureContent": false',
|
|
200
|
+
`"github.copilot.chat.otel.outfile": "${result.outfile}"`
|
|
201
|
+
].join("\n");
|
|
112
202
|
}
|
|
113
203
|
function ProviderTab(props) {
|
|
114
204
|
const statusColor = props.status === "error" ? "red" : props.status === "loading" ? "yellow" : "green";
|
|
@@ -116,12 +206,12 @@ function ProviderTab(props) {
|
|
|
116
206
|
return (_jsx(Box, { marginRight: 1, children: _jsx(Text, { inverse: props.active, color: statusColor, children: tabLabel }) }));
|
|
117
207
|
}
|
|
118
208
|
function VerticalTab(props) {
|
|
119
|
-
return (_jsx(Text, { inverse: props.active, children: props.active ? ` ${props.label} ` : ` ${props.label}` }));
|
|
209
|
+
return (_jsx(Box, { width: VERTICAL_TAB_WIDTH, children: _jsx(Text, { wrap: "truncate-end", inverse: props.active, children: props.active ? ` ${props.label} ` : ` ${props.label}` }) }));
|
|
120
210
|
}
|
|
121
211
|
function SummaryPanel(props) {
|
|
122
212
|
const { summary } = props.stats;
|
|
123
213
|
const inputPerOutput = formatInputPerOutput(summary.totals);
|
|
124
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: props.stats.providerLabel }), _jsxs(Text, { children: ["root: ", summary.rootLabel, " (", summary.rootPath, ")"] }), _jsxs(Text, { children: ["files: ", formatInteger(summary.filesScanned), " lines: ", formatInteger(summary.linesRead), " token events: ", formatInteger(summary.tokenEvents)] }), _jsxs(Text, { children: ["input: ", formatInteger(summary.totals.inputTokens), " cached: ",
|
|
214
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: props.stats.providerLabel }), _jsxs(Text, { children: ["root: ", summary.rootLabel, " (", summary.rootPath, ")"] }), _jsxs(Text, { children: ["files: ", formatInteger(summary.filesScanned), " lines: ", formatInteger(summary.linesRead), " token events: ", formatInteger(summary.tokenEvents)] }), _jsxs(Text, { children: ["input: ", formatInteger(summary.totals.inputTokens), " cached: ", formatCacheTokens(summary.totals, "cached"), " non-cached: ", formatCacheTokens(summary.totals, "non-cached")] }), _jsxs(Text, { children: ["output: ", formatInteger(summary.totals.outputTokens), " reasoning: ", formatInteger(summary.totals.reasoningOutputTokens), " total: ", formatInteger(summary.totals.totalTokens)] }), _jsxs(Text, { children: ["estimated credits: ", formatUsageCredits(summary.totals)] }), _jsxs(Text, { children: ["IpO: ", inputPerOutput.cached, ":", inputPerOutput.nonCached, ":", inputPerOutput.output] }), _jsxs(Text, { children: ["models: ", summary.distinctModels.join(", ") || "none"] }), _jsxs(Text, { children: ["plans: ", summary.distinctPlanTypes.join(", ") || "none"] })] }));
|
|
125
215
|
}
|
|
126
216
|
function ContentPanel(props) {
|
|
127
217
|
if (props.providerState.status === "loading") {
|
|
@@ -136,10 +226,13 @@ function ContentPanel(props) {
|
|
|
136
226
|
if (props.tabId === "summary") {
|
|
137
227
|
return _jsx(SummaryPanel, { stats: props.providerState.stats });
|
|
138
228
|
}
|
|
229
|
+
if (props.tabId === "day-to-day-analyses") {
|
|
230
|
+
return _jsx(DayToDayPanel, { stats: props.providerState.stats, selectedDayKey: props.selectedDayKey });
|
|
231
|
+
}
|
|
139
232
|
return _jsx(UsageByModelPanel, { stats: props.providerState.stats, selectedModelId: props.selectedModelId });
|
|
140
233
|
}
|
|
141
234
|
function LimitWindowsPanel(props) {
|
|
142
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Primary" }), _jsx(LimitWindowSection, { windows: props.stats.primaryLimitWindows, selectedRowKey: props.selectedRowKey }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: "Secondary" }), _jsx(LimitWindowSection, { windows: props.stats.secondaryLimitWindows, selectedRowKey: props.selectedRowKey })] }));
|
|
235
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Primary Limit Windows" }), _jsx(LimitWindowSection, { windows: props.stats.primaryLimitWindows, selectedRowKey: props.selectedRowKey }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: "Secondary Limit Windows" }), _jsx(LimitWindowSection, { windows: props.stats.secondaryLimitWindows, selectedRowKey: props.selectedRowKey })] }));
|
|
143
236
|
}
|
|
144
237
|
function LimitWindowSection(props) {
|
|
145
238
|
if (props.windows.length === 0) {
|
|
@@ -159,8 +252,17 @@ function UsageByModelPanel(props) {
|
|
|
159
252
|
const totals = props.stats.summary.totals;
|
|
160
253
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [pad("model", MODEL_USAGE_COLUMNS.model), " ", pad("input", MODEL_USAGE_COLUMNS.input), " ", pad("cached", MODEL_USAGE_COLUMNS.cached), " ", pad("non-cached", MODEL_USAGE_COLUMNS.nonCached), " ", pad("output", MODEL_USAGE_COLUMNS.output), " ", pad("credits", MODEL_USAGE_COLUMNS.credits), " value"] }), props.stats.modelUsage.map((row) => {
|
|
161
254
|
const isSelected = props.selectedModelId === row.modelId;
|
|
162
|
-
return (_jsxs(Text, { inverse: isSelected, color: isSelected ? "cyan" : undefined, children: [pad(row.modelId, MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(row.totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(
|
|
163
|
-
}), _jsxs(Text, { color: "cyan", children: [pad("TOTAL", MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(
|
|
255
|
+
return (_jsxs(Text, { inverse: isSelected, color: isSelected ? "cyan" : undefined, children: [pad(row.modelId, MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(row.totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(formatCacheTokens(row.totals, "cached"), MODEL_USAGE_COLUMNS.cached), " ", pad(formatCacheTokens(row.totals, "non-cached"), MODEL_USAGE_COLUMNS.nonCached), " ", pad(formatInteger(row.totals.outputTokens), MODEL_USAGE_COLUMNS.output), " ", pad(formatUsageCredits(row.totals), MODEL_USAGE_COLUMNS.credits), " ", pad(formatUsageUsd(row.totals), MODEL_USAGE_COLUMNS.value)] }, row.modelId));
|
|
256
|
+
}), _jsxs(Text, { color: "cyan", children: [pad("TOTAL", MODEL_USAGE_COLUMNS.model), " ", pad(formatInteger(totals.inputTokens), MODEL_USAGE_COLUMNS.input), " ", pad(formatCacheTokens(totals, "cached"), MODEL_USAGE_COLUMNS.cached), " ", pad(formatCacheTokens(totals, "non-cached"), MODEL_USAGE_COLUMNS.nonCached), " ", pad(formatInteger(totals.outputTokens), MODEL_USAGE_COLUMNS.output), " ", pad(formatUsageCredits(totals), MODEL_USAGE_COLUMNS.credits), " ", pad(formatUsageUsd(totals), MODEL_USAGE_COLUMNS.value)] })] }));
|
|
257
|
+
}
|
|
258
|
+
function DayToDayPanel(props) {
|
|
259
|
+
if (props.stats.dayUsage.length === 0) {
|
|
260
|
+
return _jsx(Text, { color: "gray", children: "No day-by-day usage found." });
|
|
261
|
+
}
|
|
262
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [pad("day", DAY_USAGE_COLUMNS.day), " ", pad("events", DAY_USAGE_COLUMNS.events), " ", pad("input", DAY_USAGE_COLUMNS.input), " ", pad("output", DAY_USAGE_COLUMNS.output), " value"] }), props.stats.dayUsage.map((row) => {
|
|
263
|
+
const isSelected = props.selectedDayKey === row.dayKey;
|
|
264
|
+
return (_jsxs(Text, { inverse: isSelected, color: isSelected ? "cyan" : undefined, children: [pad(formatUtcDay(row.dayKey), DAY_USAGE_COLUMNS.day), " ", pad(formatInteger(row.totals.eventCount), DAY_USAGE_COLUMNS.events), " ", pad(formatInteger(row.totals.inputTokens), DAY_USAGE_COLUMNS.input), " ", pad(formatInteger(row.totals.outputTokens), DAY_USAGE_COLUMNS.output), " ", pad(formatUsageUsd(row.totals), DAY_USAGE_COLUMNS.value)] }, row.dayKey));
|
|
265
|
+
})] }));
|
|
164
266
|
}
|
|
165
267
|
function SelectionDetailsPanel(props) {
|
|
166
268
|
if (props.providerState.status !== "ready") {
|
|
@@ -170,6 +272,10 @@ function SelectionDetailsPanel(props) {
|
|
|
170
272
|
const row = props.selectedLimitRow;
|
|
171
273
|
return (_jsxs(Box, { marginTop: 1, borderStyle: "round", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Limit details" }), _jsxs(Text, { children: [row.scope, " plan: ", row.planType, " window: ", formatWindowMinutes(row.windowMinutes), " used: ", row.minUsedPercent, "%", "->", row.maxUsedPercent, "% limit: ", row.limitId] }), _jsxs(Text, { children: ["range: ", formatLocalDateTime(row.startTimeUtcIso), " ", "->", " ", formatLocalDateTime(row.endTimeUtcIso), " events: ", formatInteger(row.eventCount)] }), _jsx(UsageTotalsDetails, { totals: row.totals })] }));
|
|
172
274
|
}
|
|
275
|
+
if (props.tabId === "day-to-day-analyses" && props.selectedDayRow) {
|
|
276
|
+
const row = props.selectedDayRow;
|
|
277
|
+
return (_jsxs(Box, { marginTop: 1, borderStyle: "round", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Day details" }), _jsxs(Text, { children: ["day: ", formatUtcDay(row.dayKey), " events: ", formatInteger(row.totals.eventCount), " models: ", formatInteger(row.distinctModels.length), " plans: ", formatInteger(row.distinctPlanTypes.length)] }), _jsxs(Text, { children: ["range: ", formatEventRange(row.firstEventUtcIso, row.lastEventUtcIso)] }), _jsxs(Text, { children: ["input: ", formatInteger(row.totals.inputTokens), " cached: ", formatCacheTokens(row.totals, "cached")] }), _jsxs(Text, { children: ["non-cached: ", formatCacheTokens(row.totals, "non-cached"), " output: ", formatInteger(row.totals.outputTokens)] }), _jsxs(Text, { children: ["models: ", row.distinctModels.join(", ") || "none"] }), _jsxs(Text, { children: ["plans: ", row.distinctPlanTypes.join(", ") || "none"] }), _jsx(UsageTotalsDetails, { totals: row.totals })] }));
|
|
278
|
+
}
|
|
173
279
|
if (props.tabId === "usage-by-model" && props.selectedModelRow) {
|
|
174
280
|
return (_jsxs(Box, { marginTop: 1, borderStyle: "round", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Model details" }), _jsxs(Text, { children: ["model: ", props.selectedModelRow.modelId, " events: ", formatInteger(props.selectedModelRow.totals.eventCount)] }), _jsx(UsageTotalsDetails, { totals: props.selectedModelRow.totals })] }));
|
|
175
281
|
}
|
|
@@ -178,18 +284,38 @@ function SelectionDetailsPanel(props) {
|
|
|
178
284
|
function UsageTotalsDetails(props) {
|
|
179
285
|
const { totals } = props;
|
|
180
286
|
const inputPerOutput = formatInputPerOutput(totals);
|
|
181
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Total credits burned: ",
|
|
287
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Total credits burned: ", formatUsageCredits(totals)] }), _jsxs(Text, { children: ["Credits Value (@ $0.01/credit): ", formatUsageUsd(totals)] }), _jsxs(Text, { children: ["IpO: ", inputPerOutput.cached, ":", inputPerOutput.nonCached, ":", inputPerOutput.output] })] }));
|
|
182
288
|
}
|
|
183
289
|
function formatInteger(value) {
|
|
184
290
|
return Math.round(value).toLocaleString("en-US");
|
|
185
291
|
}
|
|
186
292
|
function formatCredits(value) {
|
|
293
|
+
if (value > 0 && value < 0.01) {
|
|
294
|
+
return "<0.01";
|
|
295
|
+
}
|
|
187
296
|
return value.toLocaleString("en-US", {
|
|
188
297
|
minimumFractionDigits: 2,
|
|
189
298
|
maximumFractionDigits: 2
|
|
190
299
|
});
|
|
191
300
|
}
|
|
301
|
+
function formatUsageCredits(totals) {
|
|
302
|
+
return totals.estimatedCreditsStatus === "unavailable" ? "unknown" : formatCredits(totals.estimatedCredits);
|
|
303
|
+
}
|
|
304
|
+
function formatUsageUsd(totals) {
|
|
305
|
+
return totals.estimatedCreditsStatus === "unavailable"
|
|
306
|
+
? "unknown"
|
|
307
|
+
: formatUsd(totals.estimatedCredits * CODEX_CREDIT_COST_USD);
|
|
308
|
+
}
|
|
309
|
+
function formatCacheTokens(totals, kind) {
|
|
310
|
+
if (totals.cacheStatus === "unavailable") {
|
|
311
|
+
return "unknown";
|
|
312
|
+
}
|
|
313
|
+
return formatInteger(kind === "cached" ? totals.cachedInputTokens : totals.nonCachedInputTokens);
|
|
314
|
+
}
|
|
192
315
|
function formatUsd(value) {
|
|
316
|
+
if (value > 0 && value < 0.0001) {
|
|
317
|
+
return "<$0.0001";
|
|
318
|
+
}
|
|
193
319
|
return value.toLocaleString("en-US", {
|
|
194
320
|
currency: "USD",
|
|
195
321
|
style: "currency",
|
|
@@ -217,10 +343,32 @@ function formatLocalDateTime(value) {
|
|
|
217
343
|
const lookup = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
218
344
|
return `${lookup.day} ${lookup.month} ${lookup.year} ${lookup.hour}:${lookup.minute}`;
|
|
219
345
|
}
|
|
346
|
+
function formatUtcDay(value) {
|
|
347
|
+
if (value === "unknown") {
|
|
348
|
+
return "unknown";
|
|
349
|
+
}
|
|
350
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
351
|
+
month: "short",
|
|
352
|
+
day: "2-digit",
|
|
353
|
+
year: "2-digit",
|
|
354
|
+
timeZone: "UTC"
|
|
355
|
+
}).formatToParts(new Date(`${value}T00:00:00.000Z`));
|
|
356
|
+
const lookup = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
357
|
+
return `${lookup.day} ${lookup.month} ${lookup.year}`;
|
|
358
|
+
}
|
|
359
|
+
function formatEventRange(firstEventUtcIso, lastEventUtcIso) {
|
|
360
|
+
if (!firstEventUtcIso || !lastEventUtcIso) {
|
|
361
|
+
return "unknown";
|
|
362
|
+
}
|
|
363
|
+
return `${formatLocalDateTime(firstEventUtcIso)} -> ${formatLocalDateTime(lastEventUtcIso)}`;
|
|
364
|
+
}
|
|
220
365
|
function pad(value, length) {
|
|
221
366
|
return value.length >= length ? value.slice(0, length) : value.padEnd(length);
|
|
222
367
|
}
|
|
223
368
|
function formatInputPerOutput(totals) {
|
|
369
|
+
if (totals.cacheStatus === "unavailable") {
|
|
370
|
+
return { cached: "unknown", nonCached: "unknown", output: "1" };
|
|
371
|
+
}
|
|
224
372
|
if (totals.outputTokens <= 0) {
|
|
225
373
|
return { cached: "0", nonCached: "0", output: "0" };
|
|
226
374
|
}
|
|
@@ -236,6 +384,20 @@ function clampSelectionIndex(value, rowCount) {
|
|
|
236
384
|
}
|
|
237
385
|
return Math.max(0, Math.min(value, rowCount - 1));
|
|
238
386
|
}
|
|
387
|
+
function sortProviderStatesByUsage(states) {
|
|
388
|
+
return states
|
|
389
|
+
.map((state, index) => ({ state, index }))
|
|
390
|
+
.sort((left, right) => providerUsageScore(right.state) - providerUsageScore(left.state) ||
|
|
391
|
+
left.index - right.index)
|
|
392
|
+
.map((entry) => entry.state);
|
|
393
|
+
}
|
|
394
|
+
function providerUsageScore(state) {
|
|
395
|
+
if (state.status !== "ready") {
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
const totals = state.stats.summary.totals;
|
|
399
|
+
return totals.inputTokens + totals.cachedInputTokens + totals.outputTokens;
|
|
400
|
+
}
|
|
239
401
|
function getLimitRows(providerState) {
|
|
240
402
|
if (providerState.status !== "ready") {
|
|
241
403
|
return [];
|
|
@@ -248,10 +410,21 @@ function getModelRows(providerState) {
|
|
|
248
410
|
}
|
|
249
411
|
return providerState.stats.modelUsage;
|
|
250
412
|
}
|
|
413
|
+
function getDayRows(providerState) {
|
|
414
|
+
if (providerState.status !== "ready") {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
return providerState.stats.dayUsage;
|
|
418
|
+
}
|
|
251
419
|
function getLimitRowKey(row) {
|
|
252
420
|
return `${row.scope}-${row.planType}-${row.limitId}-${row.startTimeUtcIso}-${row.endTimeUtcIso}`;
|
|
253
421
|
}
|
|
254
|
-
|
|
255
|
-
|
|
422
|
+
function parseStatsOptions(argv) {
|
|
423
|
+
return {
|
|
424
|
+
verbose: argv.includes("-v") || argv.includes("--verbose")
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
export function main(argv = process.argv.slice(2)) {
|
|
428
|
+
render(_jsx(App, { statsOptions: parseStatsOptions(argv) }));
|
|
256
429
|
}
|
|
257
430
|
main();
|
|
@@ -4,31 +4,34 @@ import path from "node:path";
|
|
|
4
4
|
import readline from "node:readline";
|
|
5
5
|
import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
|
|
6
6
|
import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
|
|
7
|
+
import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
|
|
7
8
|
const RATE_CARD = {
|
|
8
|
-
"claude-opus-4-8": { input:
|
|
9
|
-
"claude-opus-4-7": { input:
|
|
10
|
-
"claude-opus-4-6": { input:
|
|
11
|
-
"claude-opus-4-5": { input:
|
|
12
|
-
"claude-opus-4-1": { input:
|
|
13
|
-
"claude-opus-4": { input:
|
|
14
|
-
"claude-sonnet-4-6": { input:
|
|
15
|
-
"claude-sonnet-4-5": { input:
|
|
16
|
-
"claude-sonnet-4": { input:
|
|
17
|
-
"claude-haiku-4-5": { input:
|
|
18
|
-
"claude-haiku-3-5": { input:
|
|
9
|
+
"claude-opus-4-8": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
|
|
10
|
+
"claude-opus-4-7": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
|
|
11
|
+
"claude-opus-4-6": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
|
|
12
|
+
"claude-opus-4-5": { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5, output: 25 },
|
|
13
|
+
"claude-opus-4-1": { input: 15, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5, output: 75 },
|
|
14
|
+
"claude-opus-4": { input: 15, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5, output: 75 },
|
|
15
|
+
"claude-sonnet-4-6": { input: 3, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3, output: 15 },
|
|
16
|
+
"claude-sonnet-4-5": { input: 3, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3, output: 15 },
|
|
17
|
+
"claude-sonnet-4": { input: 3, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3, output: 15 },
|
|
18
|
+
"claude-haiku-4-5": { input: 1, cacheWrite5m: 1.25, cacheWrite1h: 2, cacheRead: 0.1, output: 5 },
|
|
19
|
+
"claude-haiku-3-5": { input: 0.8, cacheWrite5m: 1, cacheWrite1h: 1.6, cacheRead: 0.08, output: 4 }
|
|
19
20
|
};
|
|
21
|
+
const USD_TO_CREDITS = 100;
|
|
20
22
|
export class ClaudeUsageProvider extends UsageProviderBase {
|
|
21
23
|
constructor(options = {}) {
|
|
22
24
|
super("claude", "Claude");
|
|
23
25
|
this.root = path.resolve(options.root ?? os.homedir());
|
|
24
26
|
}
|
|
25
|
-
async getStats() {
|
|
27
|
+
async getStats(options = {}) {
|
|
26
28
|
const sessionsRoot = path.join(this.root, ".claude", "projects");
|
|
27
29
|
const byModel = new Map();
|
|
30
|
+
const byDay = createDailyUsageAggregates();
|
|
28
31
|
const windows = createLimitWindowAggregates();
|
|
29
32
|
const planTypes = new Set();
|
|
30
33
|
const warnings = [];
|
|
31
|
-
const
|
|
34
|
+
const parsedEvents = createParsedUsageEventAccumulator();
|
|
32
35
|
const parseTotals = {
|
|
33
36
|
filesScanned: 0,
|
|
34
37
|
linesRead: 0,
|
|
@@ -37,14 +40,34 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
37
40
|
};
|
|
38
41
|
for await (const file of walkSessionFiles(sessionsRoot)) {
|
|
39
42
|
parseTotals.filesScanned += 1;
|
|
40
|
-
const fileStats = await parseSessionFile(file,
|
|
43
|
+
const fileStats = await parseSessionFile(file, parsedEvents);
|
|
41
44
|
parseTotals.linesRead += fileStats.linesRead;
|
|
42
|
-
parseTotals.tokenEvents += fileStats.tokenEvents;
|
|
43
45
|
parseTotals.malformedLines += fileStats.malformedLines;
|
|
44
46
|
}
|
|
47
|
+
const selectedEvents = [
|
|
48
|
+
...parsedEvents.keyedEvents.values(),
|
|
49
|
+
...parsedEvents.unkeyedEvents.values()
|
|
50
|
+
];
|
|
51
|
+
for (const event of selectedEvents) {
|
|
52
|
+
addModelUsage(byModel, event.modelId, event.totals);
|
|
53
|
+
const planType = typeof event.rateLimits?.plan_type === "string" ? event.rateLimits.plan_type : undefined;
|
|
54
|
+
const safeEventTimeMs = Number.isFinite(event.timestampMs) ? event.timestampMs : 0;
|
|
55
|
+
addDailyUsage(byDay, event.timestampMs, event.modelId, planType, event.totals);
|
|
56
|
+
applyRateLimits(windows, event.rateLimits, safeEventTimeMs, event.totals, planTypes);
|
|
57
|
+
}
|
|
58
|
+
parseTotals.tokenEvents = selectedEvents.length;
|
|
45
59
|
if (parseTotals.malformedLines > 0) {
|
|
46
60
|
warnings.push(`Skipped ${parseTotals.malformedLines} malformed JSONL line(s).`);
|
|
47
61
|
}
|
|
62
|
+
if (options.verbose && parsedEvents.duplicateUsageKeys > 0) {
|
|
63
|
+
warnings.push(`Collapsed ${parsedEvents.duplicateUsageKeys} duplicate Claude usage event(s) by request/message key.`);
|
|
64
|
+
}
|
|
65
|
+
if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
|
|
66
|
+
warnings.push(`Detected ${parsedEvents.duplicateUsageKeyCollisions} Claude usage key collision(s) with different token usage; keeping the highest-cost/latest event per key.`);
|
|
67
|
+
}
|
|
68
|
+
if (options.verbose && parsedEvents.duplicateUnkeyedEvents > 0) {
|
|
69
|
+
warnings.push(`Collapsed ${parsedEvents.duplicateUnkeyedEvents} duplicate unkeyed Claude usage event(s) by usage signature.`);
|
|
70
|
+
}
|
|
48
71
|
const modelUsage = [...byModel.entries()]
|
|
49
72
|
.map(([modelId, totals]) => ({ modelId, totals }))
|
|
50
73
|
.sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
|
|
@@ -58,6 +81,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
58
81
|
warnings.push(`No Claude session files found under ${sessionsRoot}.`);
|
|
59
82
|
}
|
|
60
83
|
const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
|
|
84
|
+
const dayUsage = buildDailyUsageRows(byDay);
|
|
61
85
|
const [primaryLimitWindows, secondaryLimitWindows] = buildWindowLists(windows);
|
|
62
86
|
if (parseTotals.filesScanned > 0 &&
|
|
63
87
|
parseTotals.tokenEvents > 0 &&
|
|
@@ -79,6 +103,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
79
103
|
rootPath: sessionsRoot
|
|
80
104
|
},
|
|
81
105
|
modelUsage,
|
|
106
|
+
dayUsage,
|
|
82
107
|
primaryLimitWindows,
|
|
83
108
|
secondaryLimitWindows,
|
|
84
109
|
warnings
|
|
@@ -124,7 +149,8 @@ function creditsFor(modelId, usage) {
|
|
|
124
149
|
(usage.cacheCreation1hInputTokens / 1000000) * rate.cacheWrite1h +
|
|
125
150
|
(cacheWriteFallbackTokens / 1000000) * rate.cacheWrite5m +
|
|
126
151
|
(usage.outputTokens / 1000000) * rate.output) *
|
|
127
|
-
inferenceMultiplier
|
|
152
|
+
inferenceMultiplier *
|
|
153
|
+
USD_TO_CREDITS);
|
|
128
154
|
}
|
|
129
155
|
function usageToTotals(modelId, usage) {
|
|
130
156
|
const nonCachedInputTokens = usage.inputTokens + usage.cacheCreationInputTokens;
|
|
@@ -167,11 +193,10 @@ async function* walkSessionFiles(directory) {
|
|
|
167
193
|
}
|
|
168
194
|
}
|
|
169
195
|
}
|
|
170
|
-
async function parseSessionFile(filePath,
|
|
196
|
+
async function parseSessionFile(filePath, parsedEvents) {
|
|
171
197
|
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
172
198
|
const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
173
199
|
let linesRead = 0;
|
|
174
|
-
let tokenEvents = 0;
|
|
175
200
|
let malformedLines = 0;
|
|
176
201
|
for await (const line of lineReader) {
|
|
177
202
|
linesRead += 1;
|
|
@@ -194,22 +219,23 @@ async function parseSessionFile(filePath, byModel, windows, planTypes, seenUsage
|
|
|
194
219
|
if (!usage) {
|
|
195
220
|
continue;
|
|
196
221
|
}
|
|
197
|
-
const usageKey = buildUsageEventKey(payloadObject, message);
|
|
198
|
-
if (usageKey && seenUsageEvents.has(usageKey)) {
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
if (usageKey) {
|
|
202
|
-
seenUsageEvents.add(usageKey);
|
|
203
|
-
}
|
|
204
222
|
const modelId = String(message?.model ?? "unknown");
|
|
205
|
-
const deltaTotals = usageToTotals(modelId, normalizeUsage(usage));
|
|
206
|
-
addModelUsage(byModel, modelId, deltaTotals);
|
|
207
|
-
tokenEvents += 1;
|
|
208
223
|
const eventTimeMs = Date.parse(String(payloadObject.timestamp ?? ""));
|
|
209
|
-
const
|
|
210
|
-
|
|
224
|
+
const rateLimits = extractRateLimits(payloadObject, message);
|
|
225
|
+
const normalizedUsage = normalizeUsage(usage);
|
|
226
|
+
const usageKey = buildUsageEventKey(payloadObject, message);
|
|
227
|
+
const usageSignature = buildUsageSignature(payloadObject, modelId, normalizedUsage);
|
|
228
|
+
const parsedEvent = {
|
|
229
|
+
usageKey,
|
|
230
|
+
usageSignature,
|
|
231
|
+
timestampMs: eventTimeMs,
|
|
232
|
+
modelId,
|
|
233
|
+
totals: usageToTotals(modelId, normalizedUsage),
|
|
234
|
+
rateLimits
|
|
235
|
+
};
|
|
236
|
+
recordParsedUsageEvent(parsedEvents, parsedEvent);
|
|
211
237
|
}
|
|
212
|
-
return { linesRead,
|
|
238
|
+
return { linesRead, malformedLines };
|
|
213
239
|
}
|
|
214
240
|
function buildUsageEventKey(payloadObject, message) {
|
|
215
241
|
const sessionId = String(payloadObject.sessionId ?? "");
|
|
@@ -220,6 +246,66 @@ function buildUsageEventKey(payloadObject, message) {
|
|
|
220
246
|
}
|
|
221
247
|
return `${sessionId}|${requestId || messageId}`;
|
|
222
248
|
}
|
|
249
|
+
function buildUsageSignature(payloadObject, modelId, usage) {
|
|
250
|
+
return [
|
|
251
|
+
String(payloadObject.sessionId ?? ""),
|
|
252
|
+
modelId,
|
|
253
|
+
usage.inputTokens,
|
|
254
|
+
usage.cacheCreationInputTokens,
|
|
255
|
+
usage.cacheCreation5mInputTokens,
|
|
256
|
+
usage.cacheCreation1hInputTokens,
|
|
257
|
+
usage.cacheReadInputTokens,
|
|
258
|
+
usage.outputTokens,
|
|
259
|
+
usage.inferenceGeo
|
|
260
|
+
].join("|");
|
|
261
|
+
}
|
|
262
|
+
function createParsedUsageEventAccumulator() {
|
|
263
|
+
return {
|
|
264
|
+
keyedEvents: new Map(),
|
|
265
|
+
unkeyedEvents: new Map(),
|
|
266
|
+
duplicateUsageKeys: 0,
|
|
267
|
+
duplicateUsageKeyCollisions: 0,
|
|
268
|
+
duplicateUnkeyedEvents: 0
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function recordParsedUsageEvent(parsedEvents, event) {
|
|
272
|
+
if (event.usageKey) {
|
|
273
|
+
const previous = parsedEvents.keyedEvents.get(event.usageKey);
|
|
274
|
+
if (!previous) {
|
|
275
|
+
parsedEvents.keyedEvents.set(event.usageKey, event);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
parsedEvents.duplicateUsageKeys += 1;
|
|
279
|
+
if (previous.usageSignature !== event.usageSignature) {
|
|
280
|
+
parsedEvents.duplicateUsageKeyCollisions += 1;
|
|
281
|
+
}
|
|
282
|
+
if (shouldReplaceUsageEvent(previous, event)) {
|
|
283
|
+
parsedEvents.keyedEvents.set(event.usageKey, event);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const previous = parsedEvents.unkeyedEvents.get(event.usageSignature);
|
|
288
|
+
if (!previous) {
|
|
289
|
+
parsedEvents.unkeyedEvents.set(event.usageSignature, event);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
parsedEvents.duplicateUnkeyedEvents += 1;
|
|
293
|
+
if (shouldReplaceUsageEvent(previous, event)) {
|
|
294
|
+
parsedEvents.unkeyedEvents.set(event.usageSignature, event);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function shouldReplaceUsageEvent(previous, next) {
|
|
298
|
+
if (next.totals.estimatedCredits > previous.totals.estimatedCredits) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
if (next.totals.estimatedCredits === previous.totals.estimatedCredits) {
|
|
302
|
+
return normalizeTimestamp(next.timestampMs) > normalizeTimestamp(previous.timestampMs);
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
function normalizeTimestamp(value) {
|
|
307
|
+
return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
|
|
308
|
+
}
|
|
223
309
|
function extractRateLimits(payloadObject, message) {
|
|
224
310
|
return asRecord(payloadObject.rate_limits) ?? asRecord(message?.rate_limits);
|
|
225
311
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import readline from "node:readline";
|
|
5
5
|
import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
|
|
6
6
|
import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
|
|
7
|
+
import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
|
|
7
8
|
const RATE_CARD = {
|
|
8
9
|
"gpt-5.5": { input: 125, cachedInput: 12.5, output: 750 },
|
|
9
10
|
"gpt-5.4": { input: 62.5, cachedInput: 6.25, output: 375 },
|
|
@@ -14,9 +15,10 @@ export class CodexUsageProvider extends UsageProviderBase {
|
|
|
14
15
|
super("codex", "Codex");
|
|
15
16
|
this.root = path.resolve(options.root ?? os.homedir());
|
|
16
17
|
}
|
|
17
|
-
async getStats() {
|
|
18
|
+
async getStats(_options = {}) {
|
|
18
19
|
const sessionsRoot = path.join(this.root, ".codex", "sessions");
|
|
19
20
|
const byModel = new Map();
|
|
21
|
+
const byDay = createDailyUsageAggregates();
|
|
20
22
|
const windows = createLimitWindowAggregates();
|
|
21
23
|
const planTypes = new Set();
|
|
22
24
|
const warnings = [];
|
|
@@ -28,7 +30,7 @@ export class CodexUsageProvider extends UsageProviderBase {
|
|
|
28
30
|
};
|
|
29
31
|
for await (const file of walkSessionFiles(sessionsRoot)) {
|
|
30
32
|
parseTotals.filesScanned += 1;
|
|
31
|
-
const fileStats = await parseSessionFile(file, byModel, windows, planTypes);
|
|
33
|
+
const fileStats = await parseSessionFile(file, byModel, byDay, windows, planTypes);
|
|
32
34
|
parseTotals.linesRead += fileStats.linesRead;
|
|
33
35
|
parseTotals.tokenEvents += fileStats.tokenEvents;
|
|
34
36
|
parseTotals.malformedLines += fileStats.malformedLines;
|
|
@@ -49,6 +51,7 @@ export class CodexUsageProvider extends UsageProviderBase {
|
|
|
49
51
|
warnings.push(`No Codex session files found under ${sessionsRoot}.`);
|
|
50
52
|
}
|
|
51
53
|
const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
|
|
54
|
+
const dayUsage = buildDailyUsageRows(byDay);
|
|
52
55
|
const [primaryLimitWindows, secondaryLimitWindows] = buildWindowLists(windows);
|
|
53
56
|
return {
|
|
54
57
|
providerId: this.id,
|
|
@@ -64,6 +67,7 @@ export class CodexUsageProvider extends UsageProviderBase {
|
|
|
64
67
|
rootPath: sessionsRoot
|
|
65
68
|
},
|
|
66
69
|
modelUsage,
|
|
70
|
+
dayUsage,
|
|
67
71
|
primaryLimitWindows,
|
|
68
72
|
secondaryLimitWindows,
|
|
69
73
|
warnings
|
|
@@ -156,7 +160,7 @@ async function* walkSessionFiles(directory) {
|
|
|
156
160
|
}
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
|
-
async function parseSessionFile(filePath, byModel, windows, planTypes) {
|
|
163
|
+
async function parseSessionFile(filePath, byModel, byDay, windows, planTypes) {
|
|
160
164
|
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
161
165
|
const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
162
166
|
let currentModel = "unknown";
|
|
@@ -202,7 +206,10 @@ async function parseSessionFile(filePath, byModel, windows, planTypes) {
|
|
|
202
206
|
addModelUsage(byModel, resolvedModelId, deltaTotals);
|
|
203
207
|
const eventTimeMs = Date.parse(String(payloadObject.timestamp ?? ""));
|
|
204
208
|
const safeEventTimeMs = Number.isFinite(eventTimeMs) ? eventTimeMs : 0;
|
|
205
|
-
|
|
209
|
+
const rateLimits = asRecord(payload.rate_limits);
|
|
210
|
+
const planType = typeof rateLimits?.plan_type === "string" ? rateLimits.plan_type : undefined;
|
|
211
|
+
addDailyUsage(byDay, eventTimeMs, resolvedModelId, planType, deltaTotals);
|
|
212
|
+
applyRateLimits(windows, rateLimits, safeEventTimeMs, deltaTotals, planTypes);
|
|
206
213
|
}
|
|
207
214
|
return { linesRead, tokenEvents, malformedLines };
|
|
208
215
|
}
|
|
@@ -25,6 +25,12 @@ export function addUsageTotals(target, source) {
|
|
|
25
25
|
target.totalTokens += source.totalTokens;
|
|
26
26
|
target.estimatedCredits += source.estimatedCredits;
|
|
27
27
|
target.eventCount += source.eventCount;
|
|
28
|
+
if (source.cacheStatus === "unavailable") {
|
|
29
|
+
target.cacheStatus = "unavailable";
|
|
30
|
+
}
|
|
31
|
+
if (source.estimatedCreditsStatus === "unavailable") {
|
|
32
|
+
target.estimatedCreditsStatus = "unavailable";
|
|
33
|
+
}
|
|
28
34
|
}
|
|
29
35
|
export function sumUsageTotals(rows) {
|
|
30
36
|
const totals = createEmptyUsageTotals();
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
|
|
7
|
+
import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
|
|
8
|
+
import { asRecord } from "./limits.js";
|
|
9
|
+
const VSCODE_OTEL_SETTINGS = {
|
|
10
|
+
"github.copilot.chat.otel.enabled": true,
|
|
11
|
+
"github.copilot.chat.otel.exporterType": "file",
|
|
12
|
+
"github.copilot.chat.otel.captureContent": false
|
|
13
|
+
};
|
|
14
|
+
const RATE_CARD = {
|
|
15
|
+
"gpt-5-mini": { input: 25, cachedInput: 2.5, output: 200 },
|
|
16
|
+
"gpt-5.3-codex": { input: 175, cachedInput: 17.5, output: 1400 },
|
|
17
|
+
"gpt-5.4": { input: 250, cachedInput: 25, output: 1500 },
|
|
18
|
+
"gpt-5.4-mini": { input: 75, cachedInput: 7.5, output: 450 },
|
|
19
|
+
"gpt-5.4-nano": { input: 20, cachedInput: 2, output: 125 },
|
|
20
|
+
"gpt-5.5": { input: 500, cachedInput: 50, output: 3000 },
|
|
21
|
+
"claude-haiku-4-5": { input: 100, cachedInput: 10, cacheWrite: 125, output: 500 },
|
|
22
|
+
"claude-sonnet-4-5": { input: 300, cachedInput: 30, cacheWrite: 375, output: 1500 },
|
|
23
|
+
"claude-sonnet-4-6": { input: 300, cachedInput: 30, cacheWrite: 375, output: 1500 },
|
|
24
|
+
"claude-opus-4-5": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
|
|
25
|
+
"claude-opus-4-6": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
|
|
26
|
+
"claude-opus-4-7": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
|
|
27
|
+
"claude-opus-4-8": { input: 500, cachedInput: 50, cacheWrite: 625, output: 2500 },
|
|
28
|
+
"claude-fable-5": { input: 1000, cachedInput: 100, cacheWrite: 1250, output: 5000 },
|
|
29
|
+
"gemini-2.5-pro": { input: 125, cachedInput: 12.5, output: 1000 },
|
|
30
|
+
"gemini-3-flash": { input: 50, cachedInput: 5, output: 300 },
|
|
31
|
+
"gemini-3.1-pro": { input: 200, cachedInput: 20, output: 1200 },
|
|
32
|
+
"gemini-3.5-flash": { input: 150, cachedInput: 15, output: 900 },
|
|
33
|
+
"mai-code-1-flash": { input: 75, cachedInput: 7.5, output: 450 },
|
|
34
|
+
"raptor-mini": { input: 25, cachedInput: 2.5, output: 200 }
|
|
35
|
+
};
|
|
36
|
+
const LONG_CONTEXT_RATE_CARD = {
|
|
37
|
+
"gpt-5.4": { thresholdInputTokens: 272000, input: 500, cachedInput: 50, output: 2250 },
|
|
38
|
+
"gpt-5.5": { thresholdInputTokens: 272000, input: 1000, cachedInput: 100, output: 4500 },
|
|
39
|
+
"gemini-3.1-pro": { thresholdInputTokens: 200000, input: 400, cachedInput: 40, output: 1800 }
|
|
40
|
+
};
|
|
41
|
+
const NON_BILLABLE_MODEL_PREFIXES = ["copilot-nes", "copilot-suggestion"];
|
|
42
|
+
export class CopilotUsageProvider extends UsageProviderBase {
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
super("copilot", "Copilot");
|
|
45
|
+
this.root = path.resolve(options.root ?? os.homedir());
|
|
46
|
+
}
|
|
47
|
+
async getStats() {
|
|
48
|
+
const vscodeOtelFile = getCopilotOtelPath(this.root);
|
|
49
|
+
const byModel = new Map();
|
|
50
|
+
const byDay = createDailyUsageAggregates();
|
|
51
|
+
const warnings = [];
|
|
52
|
+
const parseTotals = {
|
|
53
|
+
linesRead: 0,
|
|
54
|
+
tokenEvents: 0,
|
|
55
|
+
malformedLines: 0
|
|
56
|
+
};
|
|
57
|
+
const vscodeOtelFileExists = await isReadableFile(vscodeOtelFile);
|
|
58
|
+
if (vscodeOtelFileExists) {
|
|
59
|
+
const fileStats = await parseCopilotJsonlFile(vscodeOtelFile, byModel, byDay);
|
|
60
|
+
parseTotals.linesRead += fileStats.linesRead;
|
|
61
|
+
parseTotals.tokenEvents += fileStats.tokenEvents;
|
|
62
|
+
parseTotals.malformedLines += fileStats.malformedLines;
|
|
63
|
+
}
|
|
64
|
+
else if (await isCopilotVsCodeLoggingEnabled(this.root, vscodeOtelFile)) {
|
|
65
|
+
warnings.push(`VS Code Copilot logging is enabled, but ${vscodeOtelFile} has not been created yet. Reload VS Code and send a Copilot Chat request.`);
|
|
66
|
+
}
|
|
67
|
+
if (parseTotals.malformedLines > 0) {
|
|
68
|
+
warnings.push(`Skipped ${parseTotals.malformedLines} malformed Copilot JSONL line(s).`);
|
|
69
|
+
}
|
|
70
|
+
const filesScanned = vscodeOtelFileExists ? 1 : 0;
|
|
71
|
+
if (filesScanned === 0) {
|
|
72
|
+
warnings.push(`No Copilot VS Code OTEL usage file found at ${vscodeOtelFile}.`);
|
|
73
|
+
}
|
|
74
|
+
else if (parseTotals.tokenEvents === 0) {
|
|
75
|
+
warnings.push("No Copilot token usage events found. For VS Code, run Start logging VS Code and reload VS Code.");
|
|
76
|
+
}
|
|
77
|
+
const modelUsage = [...byModel.entries()]
|
|
78
|
+
.map(([modelId, totals]) => ({ modelId, totals }))
|
|
79
|
+
.sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
|
|
80
|
+
const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
|
|
81
|
+
if (summaryTotals.cacheStatus === "unavailable") {
|
|
82
|
+
warnings.push("Copilot cache token attributes are unavailable for some events; cached/non-cached tokens and estimated credits are shown as unknown.");
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
providerId: this.id,
|
|
86
|
+
providerLabel: this.label,
|
|
87
|
+
summary: {
|
|
88
|
+
filesScanned,
|
|
89
|
+
linesRead: parseTotals.linesRead,
|
|
90
|
+
tokenEvents: parseTotals.tokenEvents,
|
|
91
|
+
totals: summaryTotals,
|
|
92
|
+
distinctModels: modelUsage.map((row) => row.modelId),
|
|
93
|
+
distinctPlanTypes: [],
|
|
94
|
+
rootLabel: "~/.copilot/otel/vscode.jsonl",
|
|
95
|
+
rootPath: vscodeOtelFile
|
|
96
|
+
},
|
|
97
|
+
modelUsage,
|
|
98
|
+
dayUsage: buildDailyUsageRows(byDay),
|
|
99
|
+
primaryLimitWindows: [],
|
|
100
|
+
secondaryLimitWindows: [],
|
|
101
|
+
warnings
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function configureCopilotVsCodeLogging(options = {}) {
|
|
106
|
+
const root = path.resolve(options.root ?? os.homedir());
|
|
107
|
+
const outfile = getCopilotOtelPath(root);
|
|
108
|
+
const settingsPath = options.settingsPath ?? (await getVsCodeSettingsPath(root));
|
|
109
|
+
const settingsText = await readTextFileOrEmpty(settingsPath);
|
|
110
|
+
const { text, changed } = updateJsoncSettings(settingsText, {
|
|
111
|
+
...VSCODE_OTEL_SETTINGS,
|
|
112
|
+
"github.copilot.chat.otel.outfile": toVsCodeOutfilePath(outfile)
|
|
113
|
+
});
|
|
114
|
+
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
115
|
+
await fs.promises.mkdir(path.dirname(outfile), { recursive: true });
|
|
116
|
+
if (changed) {
|
|
117
|
+
await fs.promises.writeFile(settingsPath, text, "utf8");
|
|
118
|
+
}
|
|
119
|
+
return { settingsPath, outfile, changed };
|
|
120
|
+
}
|
|
121
|
+
function getCopilotOtelPath(root) {
|
|
122
|
+
return path.join(root, ".copilot", "otel", "vscode.jsonl");
|
|
123
|
+
}
|
|
124
|
+
function toVsCodeOutfilePath(filePath) {
|
|
125
|
+
return process.platform === "win32" ? filePath.replace(/\\/g, "/") : filePath;
|
|
126
|
+
}
|
|
127
|
+
async function getVsCodeSettingsPath(root) {
|
|
128
|
+
const userRoots = getVsCodeUserRoots(root);
|
|
129
|
+
for (const userRoot of userRoots) {
|
|
130
|
+
if (await isDirectory(userRoot)) {
|
|
131
|
+
return path.join(userRoot, "settings.json");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return path.join(userRoots[0], "settings.json");
|
|
135
|
+
}
|
|
136
|
+
function getVsCodeUserRoots(root) {
|
|
137
|
+
if (process.platform === "darwin") {
|
|
138
|
+
const applicationSupport = path.join(root, "Library", "Application Support");
|
|
139
|
+
return [
|
|
140
|
+
path.join(applicationSupport, "Code", "User"),
|
|
141
|
+
path.join(applicationSupport, "Code - Insiders", "User")
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
if (process.platform === "win32") {
|
|
145
|
+
const appData = process.env.APPDATA ?? path.join(root, "AppData", "Roaming");
|
|
146
|
+
return [path.join(appData, "Code", "User"), path.join(appData, "Code - Insiders", "User")];
|
|
147
|
+
}
|
|
148
|
+
const configRoot = path.join(root, ".config");
|
|
149
|
+
return [path.join(configRoot, "Code", "User"), path.join(configRoot, "Code - Insiders", "User")];
|
|
150
|
+
}
|
|
151
|
+
async function parseCopilotJsonlFile(filePath, byModel, byDay) {
|
|
152
|
+
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
153
|
+
const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
154
|
+
const parseTotals = {
|
|
155
|
+
linesRead: 0,
|
|
156
|
+
tokenEvents: 0,
|
|
157
|
+
malformedLines: 0
|
|
158
|
+
};
|
|
159
|
+
for await (const line of lineReader) {
|
|
160
|
+
parseTotals.linesRead += 1;
|
|
161
|
+
if (!line.trim()) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
let payload;
|
|
165
|
+
try {
|
|
166
|
+
payload = JSON.parse(line);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
parseTotals.malformedLines += 1;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const event = extractCopilotUsageEvent(payload);
|
|
173
|
+
if (event) {
|
|
174
|
+
parseTotals.tokenEvents += 1;
|
|
175
|
+
addModelUsage(byModel, event.modelId, event.totals);
|
|
176
|
+
addDailyUsage(byDay, event.timestampMs, event.modelId, undefined, event.totals);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return parseTotals;
|
|
180
|
+
}
|
|
181
|
+
function extractCopilotUsageEvent(payload) {
|
|
182
|
+
const record = asRecord(payload);
|
|
183
|
+
if (!record) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const attributes = asRecord(record.attributes);
|
|
187
|
+
if (!attributes || !isCopilotChatSpan(attributes)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const usage = usageFromAttributes(attributes);
|
|
191
|
+
if (!usage) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const modelId = stringAttribute(attributes, "gen_ai.response.model") ?? "unknown";
|
|
195
|
+
const timestampMs = hrTimeToMs(record.hrTime) ?? Number.NaN;
|
|
196
|
+
return {
|
|
197
|
+
timestampMs,
|
|
198
|
+
modelId,
|
|
199
|
+
totals: createUsageTotals(modelId, usage)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function usageFromAttributes(attributes) {
|
|
203
|
+
const inputTokens = numberAttribute(attributes, "gen_ai.usage.input_tokens") ?? 0;
|
|
204
|
+
const outputTokens = numberAttribute(attributes, "gen_ai.usage.output_tokens") ?? 0;
|
|
205
|
+
const reasoningOutputTokens = numberAttribute(attributes, "gen_ai.usage.reasoning.output_tokens");
|
|
206
|
+
const cachedInputTokens = numberAttribute(attributes, "gen_ai.usage.cache_read.input_tokens");
|
|
207
|
+
const cacheCreationInputTokens = numberAttribute(attributes, "gen_ai.usage.cache_creation.input_tokens");
|
|
208
|
+
if (inputTokens <= 0 && outputTokens <= 0 && (reasoningOutputTokens ?? 0) <= 0) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
inputTokens,
|
|
213
|
+
cachedInputTokens,
|
|
214
|
+
cacheCreationInputTokens,
|
|
215
|
+
outputTokens,
|
|
216
|
+
reasoningOutputTokens
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function isCopilotChatSpan(attributes) {
|
|
220
|
+
return stringAttribute(attributes, "gen_ai.operation.name") === "chat";
|
|
221
|
+
}
|
|
222
|
+
function createUsageTotals(modelId, usage) {
|
|
223
|
+
const hasCacheInfo = usage.cachedInputTokens !== undefined || usage.cacheCreationInputTokens !== undefined;
|
|
224
|
+
const hasKnownCreditPricing = isNonBillableModel(modelId) || (hasCacheInfo && rateForModel(modelId, usage.inputTokens) !== undefined);
|
|
225
|
+
const cachedInputTokens = hasCacheInfo ? Math.min(usage.cachedInputTokens ?? 0, usage.inputTokens) : 0;
|
|
226
|
+
return {
|
|
227
|
+
inputTokens: usage.inputTokens,
|
|
228
|
+
cachedInputTokens,
|
|
229
|
+
nonCachedInputTokens: hasCacheInfo ? Math.max(0, usage.inputTokens - cachedInputTokens) : 0,
|
|
230
|
+
outputTokens: usage.outputTokens,
|
|
231
|
+
reasoningOutputTokens: Math.min(usage.reasoningOutputTokens ?? 0, usage.outputTokens),
|
|
232
|
+
totalTokens: usage.inputTokens + usage.outputTokens,
|
|
233
|
+
estimatedCredits: creditsFor(modelId, usage),
|
|
234
|
+
eventCount: 1,
|
|
235
|
+
cacheStatus: hasCacheInfo ? "known" : "unavailable",
|
|
236
|
+
estimatedCreditsStatus: hasKnownCreditPricing ? "known" : "unavailable"
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function creditsFor(modelId, usage) {
|
|
240
|
+
if (isNonBillableModel(modelId)) {
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
const rate = rateForModel(modelId, usage.inputTokens);
|
|
244
|
+
if (!rate) {
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
if (usage.cachedInputTokens === undefined && usage.cacheCreationInputTokens === undefined) {
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
const cacheRead = Math.min(usage.cachedInputTokens ?? 0, usage.inputTokens);
|
|
251
|
+
const cacheWrite = Math.min(usage.cacheCreationInputTokens ?? 0, Math.max(0, usage.inputTokens - cacheRead));
|
|
252
|
+
const regularInput = Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
|
|
253
|
+
return ((regularInput / 1000000) * rate.input +
|
|
254
|
+
(cacheRead / 1000000) * rate.cachedInput +
|
|
255
|
+
(cacheWrite / 1000000) * (rate.cacheWrite ?? rate.input) +
|
|
256
|
+
(usage.outputTokens / 1000000) * rate.output);
|
|
257
|
+
}
|
|
258
|
+
function rateForModel(modelId, inputTokens) {
|
|
259
|
+
const candidates = Object.keys(RATE_CARD).sort((left, right) => right.length - left.length);
|
|
260
|
+
const model = candidates.find((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`));
|
|
261
|
+
if (!model) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
const longContextRate = LONG_CONTEXT_RATE_CARD[model];
|
|
265
|
+
if (longContextRate && inputTokens > longContextRate.thresholdInputTokens) {
|
|
266
|
+
return longContextRate;
|
|
267
|
+
}
|
|
268
|
+
return RATE_CARD[model];
|
|
269
|
+
}
|
|
270
|
+
function isNonBillableModel(modelId) {
|
|
271
|
+
return NON_BILLABLE_MODEL_PREFIXES.some((prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`));
|
|
272
|
+
}
|
|
273
|
+
function addModelUsage(byModel, modelId, deltaTotals) {
|
|
274
|
+
const resolvedModelId = modelId || "unknown";
|
|
275
|
+
const totals = byModel.get(resolvedModelId) ?? createEmptyUsageTotals();
|
|
276
|
+
addUsageTotals(totals, deltaTotals);
|
|
277
|
+
byModel.set(resolvedModelId, totals);
|
|
278
|
+
}
|
|
279
|
+
function numberAttribute(attributes, key) {
|
|
280
|
+
const value = attributes[key];
|
|
281
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
function stringAttribute(attributes, key) {
|
|
287
|
+
const value = attributes[key];
|
|
288
|
+
if (typeof value === "string" && value) {
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
function hrTimeToMs(value) {
|
|
294
|
+
if (!Array.isArray(value)) {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
const [seconds, nanoseconds] = value;
|
|
298
|
+
if (typeof seconds !== "number" ||
|
|
299
|
+
!Number.isFinite(seconds) ||
|
|
300
|
+
typeof nanoseconds !== "number" ||
|
|
301
|
+
!Number.isFinite(nanoseconds)) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
return seconds * 1000 + nanoseconds / 1000000;
|
|
305
|
+
}
|
|
306
|
+
async function isReadableFile(filePath) {
|
|
307
|
+
try {
|
|
308
|
+
const stat = await fs.promises.stat(filePath);
|
|
309
|
+
return stat.isFile();
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async function isDirectory(filePath) {
|
|
316
|
+
try {
|
|
317
|
+
const stat = await fs.promises.stat(filePath);
|
|
318
|
+
return stat.isDirectory();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function isCopilotVsCodeLoggingEnabled(root, outfile) {
|
|
325
|
+
const settings = await readJsonSettings(await getVsCodeSettingsPath(root));
|
|
326
|
+
const configuredOutfile = settings["github.copilot.chat.otel.outfile"];
|
|
327
|
+
return (settings["github.copilot.chat.otel.enabled"] === true &&
|
|
328
|
+
settings["github.copilot.chat.otel.exporterType"] === "file" &&
|
|
329
|
+
typeof configuredOutfile === "string" &&
|
|
330
|
+
normalizeComparablePath(configuredOutfile) === normalizeComparablePath(toVsCodeOutfilePath(outfile)));
|
|
331
|
+
}
|
|
332
|
+
function normalizeComparablePath(filePath) {
|
|
333
|
+
const normalized = path.resolve(filePath).replace(/\\/g, "/");
|
|
334
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
335
|
+
}
|
|
336
|
+
async function readJsonSettings(filePath) {
|
|
337
|
+
return parseJsoncSettings(await readTextFileOrEmpty(filePath));
|
|
338
|
+
}
|
|
339
|
+
async function readTextFileOrEmpty(filePath) {
|
|
340
|
+
try {
|
|
341
|
+
return await fs.promises.readFile(filePath, "utf8");
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
if (error.code === "ENOENT") {
|
|
345
|
+
return "";
|
|
346
|
+
}
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function parseJsoncSettings(raw) {
|
|
351
|
+
if (!raw.trim()) {
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
const parsed = parse(raw);
|
|
355
|
+
return asRecord(parsed) ?? {};
|
|
356
|
+
}
|
|
357
|
+
function updateJsoncSettings(raw, values) {
|
|
358
|
+
let text = raw.trim() ? raw : "{\n}";
|
|
359
|
+
let changed = false;
|
|
360
|
+
for (const [key, value] of Object.entries(values)) {
|
|
361
|
+
if (parseJsoncSettings(text)[key] === value) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const edits = modify(text, [key], value, {
|
|
365
|
+
formattingOptions: {
|
|
366
|
+
eol: "\n",
|
|
367
|
+
insertSpaces: true,
|
|
368
|
+
tabSize: 4
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
if (edits.length > 0) {
|
|
372
|
+
text = applyEdits(text, edits);
|
|
373
|
+
changed = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (changed && !text.endsWith("\n")) {
|
|
377
|
+
text += "\n";
|
|
378
|
+
}
|
|
379
|
+
return { text, changed };
|
|
380
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { addUsageTotals } from "./contract.js";
|
|
2
|
+
export function createDailyUsageAggregates() {
|
|
3
|
+
return new Map();
|
|
4
|
+
}
|
|
5
|
+
export function addDailyUsage(rows, eventTimeMs, modelId, planType, deltaTotals) {
|
|
6
|
+
const { dayKey, sortTimeMs } = resolveDayBucket(eventTimeMs);
|
|
7
|
+
const resolvedModelId = modelId || "unknown";
|
|
8
|
+
const existing = rows.get(dayKey);
|
|
9
|
+
if (!existing) {
|
|
10
|
+
const models = new Set();
|
|
11
|
+
models.add(resolvedModelId);
|
|
12
|
+
const planTypes = new Set();
|
|
13
|
+
if (planType) {
|
|
14
|
+
planTypes.add(planType);
|
|
15
|
+
}
|
|
16
|
+
rows.set(dayKey, {
|
|
17
|
+
dayKey,
|
|
18
|
+
sortTimeMs,
|
|
19
|
+
firstEventMs: Number.isFinite(eventTimeMs) ? eventTimeMs : null,
|
|
20
|
+
lastEventMs: Number.isFinite(eventTimeMs) ? eventTimeMs : null,
|
|
21
|
+
totals: { ...deltaTotals },
|
|
22
|
+
models,
|
|
23
|
+
planTypes
|
|
24
|
+
});
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
addUsageTotals(existing.totals, deltaTotals);
|
|
28
|
+
existing.models.add(resolvedModelId);
|
|
29
|
+
if (planType) {
|
|
30
|
+
existing.planTypes.add(planType);
|
|
31
|
+
}
|
|
32
|
+
if (Number.isFinite(eventTimeMs)) {
|
|
33
|
+
existing.sortTimeMs = Math.max(existing.sortTimeMs, sortTimeMs);
|
|
34
|
+
existing.firstEventMs =
|
|
35
|
+
existing.firstEventMs === null ? eventTimeMs : Math.min(existing.firstEventMs, eventTimeMs);
|
|
36
|
+
existing.lastEventMs =
|
|
37
|
+
existing.lastEventMs === null ? eventTimeMs : Math.max(existing.lastEventMs, eventTimeMs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function buildDailyUsageRows(rows) {
|
|
41
|
+
return [...rows.values()]
|
|
42
|
+
.sort((left, right) => right.sortTimeMs - left.sortTimeMs || right.dayKey.localeCompare(left.dayKey))
|
|
43
|
+
.map((row) => ({
|
|
44
|
+
dayKey: row.dayKey,
|
|
45
|
+
firstEventUtcIso: row.firstEventMs === null ? null : formatIsoFromMilliseconds(row.firstEventMs),
|
|
46
|
+
lastEventUtcIso: row.lastEventMs === null ? null : formatIsoFromMilliseconds(row.lastEventMs),
|
|
47
|
+
distinctModels: [...row.models].sort(),
|
|
48
|
+
distinctPlanTypes: [...row.planTypes].sort(),
|
|
49
|
+
totals: { ...row.totals }
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function resolveDayBucket(eventTimeMs) {
|
|
53
|
+
if (!Number.isFinite(eventTimeMs)) {
|
|
54
|
+
return { dayKey: "unknown", sortTimeMs: Number.NEGATIVE_INFINITY };
|
|
55
|
+
}
|
|
56
|
+
const dayKey = new Date(eventTimeMs).toISOString().slice(0, 10);
|
|
57
|
+
return {
|
|
58
|
+
dayKey,
|
|
59
|
+
sortTimeMs: Date.parse(`${dayKey}T00:00:00.000Z`)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function formatIsoFromMilliseconds(milliseconds) {
|
|
63
|
+
return new Date(milliseconds).toISOString().replace(".000Z", "Z");
|
|
64
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ClaudeUsageProvider } from "./claude.js";
|
|
2
2
|
import { CodexUsageProvider } from "./codex.js";
|
|
3
|
+
import { CopilotUsageProvider } from "./copilot.js";
|
|
3
4
|
export function createProviders() {
|
|
4
|
-
return [new CodexUsageProvider(), new ClaudeUsageProvider()];
|
|
5
|
+
return [new CodexUsageProvider(), new ClaudeUsageProvider(), new CopilotUsageProvider()];
|
|
5
6
|
}
|
|
6
7
|
export { ClaudeUsageProvider } from "./claude.js";
|
|
7
8
|
export { CodexUsageProvider } from "./codex.js";
|
|
9
|
+
export { CopilotUsageProvider, configureCopilotVsCodeLogging } from "./copilot.js";
|
|
8
10
|
export { UsageProviderBase } from "./contract.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmecode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Provider-based terminal usage dashboard for LetMeCode.",
|
|
5
5
|
"author": "devforth.io",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"ink": "4.4.1",
|
|
29
|
+
"jsonc-parser": "^3.3.1",
|
|
29
30
|
"react": "18.3.1"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|