letmecode 0.1.1 → 0.1.3
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 +253 -32
- package/ink-app/dist/providers/claude.js +145 -44
- package/ink-app/dist/providers/codex.js +24 -15
- package/ink-app/dist/providers/contract.js +54 -9
- package/ink-app/dist/providers/copilot.js +388 -0
- package/ink-app/dist/providers/daily.js +64 -0
- package/ink-app/dist/providers/index.js +3 -1
- package/ink-app/dist/providers/limits.js +5 -5
- package/package.json +14 -11
|
@@ -0,0 +1,388 @@
|
|
|
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.max(0, usage.cachedInputTokens ?? 0) : 0;
|
|
226
|
+
const cacheWriteInputTokens = hasCacheInfo ? Math.max(0, usage.cacheCreationInputTokens ?? 0) : 0;
|
|
227
|
+
const uncachedInputTokens = hasCacheInfo
|
|
228
|
+
? Math.max(0, usage.inputTokens - cachedInputTokens - cacheWriteInputTokens)
|
|
229
|
+
: 0;
|
|
230
|
+
return {
|
|
231
|
+
inputTotalTokens: usage.inputTokens,
|
|
232
|
+
outputTokens: usage.outputTokens,
|
|
233
|
+
reasoningOutputTokens: Math.min(usage.reasoningOutputTokens ?? 0, usage.outputTokens),
|
|
234
|
+
totalTokens: usage.inputTokens + usage.outputTokens,
|
|
235
|
+
estimatedCredits: creditsFor(modelId, usage),
|
|
236
|
+
eventCount: 1,
|
|
237
|
+
tokenBreakdown: {
|
|
238
|
+
schema: "openai",
|
|
239
|
+
nonCachedInputTokens: uncachedInputTokens,
|
|
240
|
+
cachedInputTokens,
|
|
241
|
+
outputTokens: usage.outputTokens
|
|
242
|
+
},
|
|
243
|
+
cacheStatus: hasCacheInfo ? "known" : "unavailable",
|
|
244
|
+
estimatedCreditsStatus: hasKnownCreditPricing ? "known" : "unavailable"
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function creditsFor(modelId, usage) {
|
|
248
|
+
if (isNonBillableModel(modelId)) {
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
const rate = rateForModel(modelId, usage.inputTokens);
|
|
252
|
+
if (!rate) {
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
if (usage.cachedInputTokens === undefined && usage.cacheCreationInputTokens === undefined) {
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
const cacheRead = Math.min(usage.cachedInputTokens ?? 0, usage.inputTokens);
|
|
259
|
+
const cacheWrite = Math.min(usage.cacheCreationInputTokens ?? 0, Math.max(0, usage.inputTokens - cacheRead));
|
|
260
|
+
const regularInput = Math.max(0, usage.inputTokens - cacheRead - cacheWrite);
|
|
261
|
+
return ((regularInput / 1000000) * rate.input +
|
|
262
|
+
(cacheRead / 1000000) * rate.cachedInput +
|
|
263
|
+
(cacheWrite / 1000000) * (rate.cacheWrite ?? rate.input) +
|
|
264
|
+
(usage.outputTokens / 1000000) * rate.output);
|
|
265
|
+
}
|
|
266
|
+
function rateForModel(modelId, inputTokens) {
|
|
267
|
+
const candidates = Object.keys(RATE_CARD).sort((left, right) => right.length - left.length);
|
|
268
|
+
const model = candidates.find((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`));
|
|
269
|
+
if (!model) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
const longContextRate = LONG_CONTEXT_RATE_CARD[model];
|
|
273
|
+
if (longContextRate && inputTokens > longContextRate.thresholdInputTokens) {
|
|
274
|
+
return longContextRate;
|
|
275
|
+
}
|
|
276
|
+
return RATE_CARD[model];
|
|
277
|
+
}
|
|
278
|
+
function isNonBillableModel(modelId) {
|
|
279
|
+
return NON_BILLABLE_MODEL_PREFIXES.some((prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`));
|
|
280
|
+
}
|
|
281
|
+
function addModelUsage(byModel, modelId, deltaTotals) {
|
|
282
|
+
const resolvedModelId = modelId || "unknown";
|
|
283
|
+
const totals = byModel.get(resolvedModelId) ?? createEmptyUsageTotals("openai");
|
|
284
|
+
addUsageTotals(totals, deltaTotals);
|
|
285
|
+
byModel.set(resolvedModelId, totals);
|
|
286
|
+
}
|
|
287
|
+
function numberAttribute(attributes, key) {
|
|
288
|
+
const value = attributes[key];
|
|
289
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
function stringAttribute(attributes, key) {
|
|
295
|
+
const value = attributes[key];
|
|
296
|
+
if (typeof value === "string" && value) {
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
function hrTimeToMs(value) {
|
|
302
|
+
if (!Array.isArray(value)) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const [seconds, nanoseconds] = value;
|
|
306
|
+
if (typeof seconds !== "number" ||
|
|
307
|
+
!Number.isFinite(seconds) ||
|
|
308
|
+
typeof nanoseconds !== "number" ||
|
|
309
|
+
!Number.isFinite(nanoseconds)) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
return seconds * 1000 + nanoseconds / 1000000;
|
|
313
|
+
}
|
|
314
|
+
async function isReadableFile(filePath) {
|
|
315
|
+
try {
|
|
316
|
+
const stat = await fs.promises.stat(filePath);
|
|
317
|
+
return stat.isFile();
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function isDirectory(filePath) {
|
|
324
|
+
try {
|
|
325
|
+
const stat = await fs.promises.stat(filePath);
|
|
326
|
+
return stat.isDirectory();
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function isCopilotVsCodeLoggingEnabled(root, outfile) {
|
|
333
|
+
const settings = await readJsonSettings(await getVsCodeSettingsPath(root));
|
|
334
|
+
const configuredOutfile = settings["github.copilot.chat.otel.outfile"];
|
|
335
|
+
return (settings["github.copilot.chat.otel.enabled"] === true &&
|
|
336
|
+
settings["github.copilot.chat.otel.exporterType"] === "file" &&
|
|
337
|
+
typeof configuredOutfile === "string" &&
|
|
338
|
+
normalizeComparablePath(configuredOutfile) === normalizeComparablePath(toVsCodeOutfilePath(outfile)));
|
|
339
|
+
}
|
|
340
|
+
function normalizeComparablePath(filePath) {
|
|
341
|
+
const normalized = path.resolve(filePath).replace(/\\/g, "/");
|
|
342
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
343
|
+
}
|
|
344
|
+
async function readJsonSettings(filePath) {
|
|
345
|
+
return parseJsoncSettings(await readTextFileOrEmpty(filePath));
|
|
346
|
+
}
|
|
347
|
+
async function readTextFileOrEmpty(filePath) {
|
|
348
|
+
try {
|
|
349
|
+
return await fs.promises.readFile(filePath, "utf8");
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
if (error.code === "ENOENT") {
|
|
353
|
+
return "";
|
|
354
|
+
}
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function parseJsoncSettings(raw) {
|
|
359
|
+
if (!raw.trim()) {
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
const parsed = parse(raw);
|
|
363
|
+
return asRecord(parsed) ?? {};
|
|
364
|
+
}
|
|
365
|
+
function updateJsoncSettings(raw, values) {
|
|
366
|
+
let text = raw.trim() ? raw : "{\n}";
|
|
367
|
+
let changed = false;
|
|
368
|
+
for (const [key, value] of Object.entries(values)) {
|
|
369
|
+
if (parseJsoncSettings(text)[key] === value) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const edits = modify(text, [key], value, {
|
|
373
|
+
formattingOptions: {
|
|
374
|
+
eol: "\n",
|
|
375
|
+
insertSpaces: true,
|
|
376
|
+
tabSize: 4
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
if (edits.length > 0) {
|
|
380
|
+
text = applyEdits(text, edits);
|
|
381
|
+
changed = true;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (changed && !text.endsWith("\n")) {
|
|
385
|
+
text += "\n";
|
|
386
|
+
}
|
|
387
|
+
return { text, changed };
|
|
388
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { addUsageTotals, cloneUsageTotals } 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: cloneUsageTotals(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: cloneUsageTotals(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";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addUsageTotals, createEmptyUsageTotals } from "./contract.js";
|
|
1
|
+
import { addUsageTotals, cloneUsageTotals, createEmptyUsageTotals } from "./contract.js";
|
|
2
2
|
export function createLimitWindowAggregates() {
|
|
3
3
|
return new Map();
|
|
4
4
|
}
|
|
@@ -71,7 +71,7 @@ function collapseNearbyWindows(rows) {
|
|
|
71
71
|
if (!existing) {
|
|
72
72
|
collapsed.set(key, {
|
|
73
73
|
...row,
|
|
74
|
-
totals:
|
|
74
|
+
totals: cloneUsageTotals(row.totals)
|
|
75
75
|
});
|
|
76
76
|
continue;
|
|
77
77
|
}
|
|
@@ -93,7 +93,7 @@ function collapseNearbyWindows(rows) {
|
|
|
93
93
|
function computeWindowTotals(events) {
|
|
94
94
|
// Session files are not guaranteed to be parsed in timestamp order, so
|
|
95
95
|
// saturation has to be applied after we sort the captured window events.
|
|
96
|
-
const totals = createEmptyUsageTotals();
|
|
96
|
+
const totals = createEmptyUsageTotals(events[0]?.totals.tokenBreakdown.schema ?? "openai");
|
|
97
97
|
let sawBelowCap = false;
|
|
98
98
|
let isExhausted = false;
|
|
99
99
|
for (const event of [...events].sort((left, right) => left.eventTimeMs - right.eventTimeMs)) {
|
|
@@ -132,7 +132,7 @@ function upsertWindow(windows, scope, rateLimits, window, eventTimeMs, deltaTota
|
|
|
132
132
|
lastSeenMs: eventTimeMs,
|
|
133
133
|
minUsedPercent: usedPercent,
|
|
134
134
|
maxUsedPercent: usedPercent,
|
|
135
|
-
events: [{ eventTimeMs, usedPercent, totals:
|
|
135
|
+
events: [{ eventTimeMs, usedPercent, totals: cloneUsageTotals(deltaTotals) }]
|
|
136
136
|
});
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
@@ -142,5 +142,5 @@ function upsertWindow(windows, scope, rateLimits, window, eventTimeMs, deltaTota
|
|
|
142
142
|
existing.lastSeenMs = Math.max(existing.lastSeenMs, eventTimeMs);
|
|
143
143
|
existing.minUsedPercent = Math.min(existing.minUsedPercent, usedPercent);
|
|
144
144
|
existing.maxUsedPercent = Math.max(existing.maxUsedPercent, usedPercent);
|
|
145
|
-
existing.events.push({ eventTimeMs, usedPercent, totals:
|
|
145
|
+
existing.events.push({ eventTimeMs, usedPercent, totals: cloneUsageTotals(deltaTotals) });
|
|
146
146
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letmecode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Provider-based terminal usage dashboard for LetMeCode.",
|
|
5
5
|
"author": "devforth.io",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"packageManager": "pnpm@10.28.2",
|
|
7
8
|
"type": "commonjs",
|
|
8
9
|
"bin": "./bin/letmecode.js",
|
|
9
10
|
"files": [
|
|
@@ -18,6 +19,16 @@
|
|
|
18
19
|
"publishConfig": {
|
|
19
20
|
"access": "public"
|
|
20
21
|
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true }); require('node:fs').rmSync('ink-app/dist', { recursive: true, force: true });\"",
|
|
24
|
+
"build": "npm run clean && tsc -p tsconfig.json && tsc -p ink-app/tsconfig.json",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"prestart": "npm run build",
|
|
27
|
+
"start": "node ./bin/letmecode.js",
|
|
28
|
+
"pretest": "npm run build",
|
|
29
|
+
"smoke": "node ./bin/letmecode.js",
|
|
30
|
+
"test": "node --test ink-app/test/*.test.mjs"
|
|
31
|
+
},
|
|
21
32
|
"keywords": [
|
|
22
33
|
"cli",
|
|
23
34
|
"ink",
|
|
@@ -26,20 +37,12 @@
|
|
|
26
37
|
],
|
|
27
38
|
"dependencies": {
|
|
28
39
|
"ink": "4.4.1",
|
|
40
|
+
"jsonc-parser": "^3.3.1",
|
|
29
41
|
"react": "18.3.1"
|
|
30
42
|
},
|
|
31
43
|
"devDependencies": {
|
|
32
44
|
"@types/node": "^24.0.7",
|
|
33
45
|
"@types/react": "^18.3.24",
|
|
34
46
|
"typescript": "^5.8.3"
|
|
35
|
-
},
|
|
36
|
-
"scripts": {
|
|
37
|
-
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true }); require('node:fs').rmSync('ink-app/dist', { recursive: true, force: true });\"",
|
|
38
|
-
"build": "npm run clean && tsc -p tsconfig.json && tsc -p ink-app/tsconfig.json",
|
|
39
|
-
"prestart": "npm run build",
|
|
40
|
-
"start": "node ./bin/letmecode.js",
|
|
41
|
-
"pretest": "npm run build",
|
|
42
|
-
"smoke": "node ./bin/letmecode.js",
|
|
43
|
-
"test": "node --test ink-app/test/*.test.mjs"
|
|
44
47
|
}
|
|
45
|
-
}
|
|
48
|
+
}
|