letmecode 0.1.19 → 0.1.21
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 +10 -27
- package/ink-app/dist/index.js +68 -35
- package/ink-app/dist/providers/antigravity/models.js +46 -0
- package/ink-app/dist/providers/antigravity/provider.js +288 -0
- package/ink-app/dist/providers/antigravity/quota-parser.js +49 -0
- package/ink-app/dist/providers/antigravity/rpc/client.js +54 -0
- package/ink-app/dist/providers/antigravity/rpc/discovery.js +84 -0
- package/ink-app/dist/providers/antigravity/rpc/quota.js +25 -0
- package/ink-app/dist/providers/antigravity/rpc/usage.js +80 -0
- package/ink-app/dist/providers/antigravity/types.js +1 -0
- package/ink-app/dist/providers/antigravity/usage-parse.js +23 -0
- package/ink-app/dist/providers/antigravity.js +2 -537
- package/ink-app/dist/providers/claude.js +71 -152
- package/ink-app/dist/providers/contract.js +5 -2
- package/ink-app/dist/providers/copilot/models.js +55 -0
- package/ink-app/dist/providers/copilot/otel/configure.js +134 -0
- package/ink-app/dist/providers/copilot/otel/discover.js +94 -0
- package/ink-app/dist/providers/copilot/otel/parse.js +228 -0
- package/ink-app/dist/providers/copilot/provider.js +259 -0
- package/ink-app/dist/providers/copilot/quota.js +257 -0
- package/ink-app/dist/providers/copilot/usage/aggregate.js +84 -0
- package/ink-app/dist/providers/copilot.js +4 -373
- package/ink-app/dist/providers/index.js +1 -1
- package/ink-app/dist/reporting.js +7 -1
- package/package.json +1 -1
|
@@ -82,7 +82,6 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
82
82
|
`malformed=${file.malformedLines}`,
|
|
83
83
|
`assistantUsageEvents=${file.events.length}`,
|
|
84
84
|
`matchingEvents=${matchingEvents.length}`,
|
|
85
|
-
`source=${file.sourceKind}`,
|
|
86
85
|
`entrypoints=${summarizeEventCounts(file.events.map((event) => event.entrypoint || "<empty>"))}`,
|
|
87
86
|
`models=${summarizeDistinctValues(file.events.map((event) => event.modelId || "unknown"))}`
|
|
88
87
|
].join(" "));
|
|
@@ -125,7 +124,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
125
124
|
warnings.push(`Collapsed ${parsedEvents.duplicateUsageKeys} duplicate Claude usage event(s) by request/message key.`);
|
|
126
125
|
}
|
|
127
126
|
if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
|
|
128
|
-
warnings.push(`Detected ${parsedEvents.duplicateUsageKeyCollisions} Claude usage key collision(s) with different token usage;
|
|
127
|
+
warnings.push(`Detected ${parsedEvents.duplicateUsageKeyCollisions} Claude usage key collision(s) with different token usage; kept the most complete same-key snapshot to avoid double-counting cumulative snapshots.`);
|
|
129
128
|
}
|
|
130
129
|
if (options.verbose && parsedEvents.duplicateUnkeyedEvents > 0) {
|
|
131
130
|
warnings.push(`Collapsed ${parsedEvents.duplicateUnkeyedEvents} adjacent duplicate unkeyed Claude usage event(s) by usage signature.`);
|
|
@@ -387,26 +386,20 @@ async function loadParsedClaudeSessionFiles(sessionsRoot, traceLogger) {
|
|
|
387
386
|
const files = [];
|
|
388
387
|
traceClaude(traceLogger, `Scanning session files under ${sessionsRoot}.`);
|
|
389
388
|
for await (const filePath of walkSessionFiles(sessionsRoot)) {
|
|
390
|
-
files.push(await parseSessionFile(filePath
|
|
389
|
+
files.push(await parseSessionFile(filePath));
|
|
391
390
|
}
|
|
392
|
-
inferClaudeSessionFileSources(files);
|
|
393
391
|
traceClaude(traceLogger, `Completed session file scan under ${sessionsRoot}: ${files.length} file(s) parsed.`);
|
|
394
392
|
return files;
|
|
395
393
|
})();
|
|
396
394
|
parsedClaudeSessionFilesCache.set(cacheKey, pending);
|
|
397
395
|
return pending;
|
|
398
396
|
}
|
|
399
|
-
async function parseSessionFile(filePath
|
|
397
|
+
async function parseSessionFile(filePath) {
|
|
400
398
|
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
401
399
|
const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
402
400
|
let linesRead = 0;
|
|
403
401
|
let malformedLines = 0;
|
|
404
402
|
const events = [];
|
|
405
|
-
const assistantEntryPoints = new Set();
|
|
406
|
-
let hasIdeOpenedFileAttachment = false;
|
|
407
|
-
let hasIdeOpenedFileMarker = false;
|
|
408
|
-
let hasIdeTooling = false;
|
|
409
|
-
let hasQueueOperations = false;
|
|
410
403
|
for await (const line of lineReader) {
|
|
411
404
|
linesRead += 1;
|
|
412
405
|
if (!line.trim()) {
|
|
@@ -420,19 +413,6 @@ async function parseSessionFile(filePath, sessionsRoot) {
|
|
|
420
413
|
malformedLines += 1;
|
|
421
414
|
continue;
|
|
422
415
|
}
|
|
423
|
-
if (payloadObject.type === "queue-operation") {
|
|
424
|
-
hasQueueOperations = true;
|
|
425
|
-
}
|
|
426
|
-
if (messageContainsIdeOpenedFileMarker(asRecord(payloadObject.message))) {
|
|
427
|
-
hasIdeOpenedFileMarker = true;
|
|
428
|
-
}
|
|
429
|
-
const attachment = asRecord(payloadObject.attachment);
|
|
430
|
-
if (attachment?.type === "opened_file_in_ide") {
|
|
431
|
-
hasIdeOpenedFileAttachment = true;
|
|
432
|
-
}
|
|
433
|
-
if (attachmentHasIdeTooling(attachment)) {
|
|
434
|
-
hasIdeTooling = true;
|
|
435
|
-
}
|
|
436
416
|
if (payloadObject.type !== "assistant") {
|
|
437
417
|
continue;
|
|
438
418
|
}
|
|
@@ -448,7 +428,6 @@ async function parseSessionFile(filePath, sessionsRoot) {
|
|
|
448
428
|
const normalizedUsage = normalizeUsage(usage);
|
|
449
429
|
const usageKeys = buildUsageEventKeys(payloadObject, message);
|
|
450
430
|
const usageSignature = buildUsageSignature(payloadObject, modelId, normalizedUsage);
|
|
451
|
-
assistantEntryPoints.add(entrypoint);
|
|
452
431
|
events.push({
|
|
453
432
|
entrypoint,
|
|
454
433
|
filePath,
|
|
@@ -464,100 +443,11 @@ async function parseSessionFile(filePath, sessionsRoot) {
|
|
|
464
443
|
}
|
|
465
444
|
return {
|
|
466
445
|
filePath,
|
|
467
|
-
sessionGroupKey: buildClaudeSessionGroupKey(sessionsRoot, filePath),
|
|
468
446
|
linesRead,
|
|
469
447
|
malformedLines,
|
|
470
|
-
sourceKind: "unknown",
|
|
471
|
-
sourceReason: "unclassified",
|
|
472
|
-
signals: {
|
|
473
|
-
assistantEntryPoints: [...assistantEntryPoints].sort(),
|
|
474
|
-
hasIdeOpenedFileAttachment,
|
|
475
|
-
hasIdeOpenedFileMarker,
|
|
476
|
-
hasIdeTooling,
|
|
477
|
-
hasQueueOperations
|
|
478
|
-
},
|
|
479
448
|
events
|
|
480
449
|
};
|
|
481
450
|
}
|
|
482
|
-
function buildClaudeSessionGroupKey(sessionsRoot, filePath) {
|
|
483
|
-
const relativePath = path.relative(sessionsRoot, filePath);
|
|
484
|
-
if (!relativePath || relativePath.startsWith("..")) {
|
|
485
|
-
return filePath;
|
|
486
|
-
}
|
|
487
|
-
const normalizedRelativePath = relativePath.split(path.sep).join("/");
|
|
488
|
-
const subagentMatch = normalizedRelativePath.match(/^(.*\/[^/]+)\/subagents\/[^/]+\.jsonl$/);
|
|
489
|
-
if (subagentMatch?.[1]) {
|
|
490
|
-
return subagentMatch[1];
|
|
491
|
-
}
|
|
492
|
-
return normalizedRelativePath.replace(/\.jsonl$/i, "");
|
|
493
|
-
}
|
|
494
|
-
function inferClaudeSessionFileSources(files) {
|
|
495
|
-
const groups = new Map();
|
|
496
|
-
for (const file of files) {
|
|
497
|
-
const group = groups.get(file.sessionGroupKey) ?? {
|
|
498
|
-
assistantEntryPoints: new Set(),
|
|
499
|
-
hasIdeHints: false
|
|
500
|
-
};
|
|
501
|
-
for (const entrypoint of file.signals.assistantEntryPoints) {
|
|
502
|
-
group.assistantEntryPoints.add(entrypoint);
|
|
503
|
-
}
|
|
504
|
-
group.hasIdeHints =
|
|
505
|
-
group.hasIdeHints ||
|
|
506
|
-
file.signals.hasIdeOpenedFileAttachment ||
|
|
507
|
-
file.signals.hasIdeOpenedFileMarker ||
|
|
508
|
-
file.signals.hasIdeTooling ||
|
|
509
|
-
file.signals.hasQueueOperations;
|
|
510
|
-
groups.set(file.sessionGroupKey, group);
|
|
511
|
-
}
|
|
512
|
-
for (const file of files) {
|
|
513
|
-
const group = groups.get(file.sessionGroupKey);
|
|
514
|
-
const { kind, reason } = classifyClaudeSessionGroup(group);
|
|
515
|
-
file.sourceKind = kind;
|
|
516
|
-
file.sourceReason = reason;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
function classifyClaudeSessionGroup(group) {
|
|
520
|
-
if (!group) {
|
|
521
|
-
return { kind: "unknown", reason: "missing session group signals" };
|
|
522
|
-
}
|
|
523
|
-
if (group.assistantEntryPoints.has("claude-vscode")) {
|
|
524
|
-
return { kind: "vscode", reason: "explicit claude-vscode entrypoint" };
|
|
525
|
-
}
|
|
526
|
-
if (group.assistantEntryPoints.has("sdk-cli") || group.assistantEntryPoints.has("claude")) {
|
|
527
|
-
return { kind: "cli", reason: "explicit sdk-cli/claude entrypoint" };
|
|
528
|
-
}
|
|
529
|
-
if (group.assistantEntryPoints.has("cli")) {
|
|
530
|
-
return group.hasIdeHints
|
|
531
|
-
? { kind: "vscode", reason: "generic cli entrypoint with IDE session hints" }
|
|
532
|
-
: { kind: "cli", reason: "generic cli entrypoint without IDE session hints" };
|
|
533
|
-
}
|
|
534
|
-
return { kind: "unknown", reason: "no assistant entrypoints" };
|
|
535
|
-
}
|
|
536
|
-
function attachmentHasIdeTooling(attachment) {
|
|
537
|
-
if (attachment?.type !== "deferred_tools_delta") {
|
|
538
|
-
return false;
|
|
539
|
-
}
|
|
540
|
-
return extractStringArray(attachment.addedNames).some((name) => name.startsWith("mcp__ide__"));
|
|
541
|
-
}
|
|
542
|
-
function messageContainsIdeOpenedFileMarker(message) {
|
|
543
|
-
const content = message?.content;
|
|
544
|
-
if (typeof content === "string") {
|
|
545
|
-
return content.includes("<ide_opened_file>");
|
|
546
|
-
}
|
|
547
|
-
if (!Array.isArray(content)) {
|
|
548
|
-
return false;
|
|
549
|
-
}
|
|
550
|
-
return content.some((item) => {
|
|
551
|
-
const contentItem = asRecord(item);
|
|
552
|
-
return typeof contentItem?.text === "string" && contentItem.text.includes("<ide_opened_file>");
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
function extractStringArray(value) {
|
|
556
|
-
if (!Array.isArray(value)) {
|
|
557
|
-
return [];
|
|
558
|
-
}
|
|
559
|
-
return value.filter((item) => typeof item === "string");
|
|
560
|
-
}
|
|
561
451
|
function buildUsageEventKeys(payloadObject, message) {
|
|
562
452
|
const sessionId = String(payloadObject.sessionId ?? "");
|
|
563
453
|
const requestId = typeof payloadObject.requestId === "string" ? payloadObject.requestId : "";
|
|
@@ -637,8 +527,14 @@ function recordParsedUsageEvent(parsedEvents, event) {
|
|
|
637
527
|
}
|
|
638
528
|
}
|
|
639
529
|
function mergeParsedUsageEvents(previous, next) {
|
|
640
|
-
|
|
641
|
-
|
|
530
|
+
// Same-key events are repeated/streamed snapshots of one logical request. Rather than
|
|
531
|
+
// synthesizing a field-wise maximum (which can fabricate token totals when a snapshot
|
|
532
|
+
// splits cache-write tokens across the 5m/1h buckets differently), keep the single most
|
|
533
|
+
// complete real snapshot and discard the rest.
|
|
534
|
+
const primaryEvent = selectMergedSnapshotEvent(previous, next);
|
|
535
|
+
const otherEvent = primaryEvent === previous ? next : previous;
|
|
536
|
+
const usage = primaryEvent.usage;
|
|
537
|
+
const modelId = selectMergedEventModelId(primaryEvent, otherEvent);
|
|
642
538
|
const latestEvent = normalizeTimestamp(next.timestampMs) >= normalizeTimestamp(previous.timestampMs) ? next : previous;
|
|
643
539
|
const sessionId = extractUsageKeySessionId(previous.usageKeys) || extractUsageKeySessionId(next.usageKeys);
|
|
644
540
|
return {
|
|
@@ -646,38 +542,34 @@ function mergeParsedUsageEvents(previous, next) {
|
|
|
646
542
|
filePath: latestEvent.filePath,
|
|
647
543
|
lineNumber: latestEvent.lineNumber,
|
|
648
544
|
usageKeys: [...new Set([...previous.usageKeys, ...next.usageKeys])],
|
|
649
|
-
usageSignature: buildUsageSignatureFromParts(sessionId, modelId,
|
|
545
|
+
usageSignature: buildUsageSignatureFromParts(sessionId, modelId, usage),
|
|
650
546
|
timestampMs: Math.max(normalizeTimestamp(previous.timestampMs), normalizeTimestamp(next.timestampMs)),
|
|
651
547
|
modelId,
|
|
652
|
-
usage
|
|
653
|
-
totals: usageToTotals(modelId,
|
|
548
|
+
usage,
|
|
549
|
+
totals: usageToTotals(modelId, usage),
|
|
654
550
|
rateLimits: latestEvent.rateLimits ?? previous.rateLimits ?? next.rateLimits
|
|
655
551
|
};
|
|
656
552
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
outputTokens: Math.max(previous.outputTokens, next.outputTokens),
|
|
665
|
-
inferenceGeo: next.inferenceGeo || previous.inferenceGeo
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
function selectMergedEventModelId(previous, next) {
|
|
669
|
-
if (previous.modelId === next.modelId) {
|
|
670
|
-
return previous.modelId;
|
|
553
|
+
// Pick the snapshot that carries the most usage. Cumulative snapshots are monotonic, so the
|
|
554
|
+
// largest total is the final state; this also keeps a real synthetic-followup row (0 tokens)
|
|
555
|
+
// from clobbering the real usage it follows. Ties fall back to the later, then the earlier-seen
|
|
556
|
+
// event for deterministic output.
|
|
557
|
+
function selectMergedSnapshotEvent(previous, next) {
|
|
558
|
+
if (next.totals.totalTokens !== previous.totals.totalTokens) {
|
|
559
|
+
return next.totals.totalTokens > previous.totals.totalTokens ? next : previous;
|
|
671
560
|
}
|
|
672
|
-
|
|
673
|
-
|
|
561
|
+
return normalizeTimestamp(next.timestampMs) > normalizeTimestamp(previous.timestampMs) ? next : previous;
|
|
562
|
+
}
|
|
563
|
+
function selectMergedEventModelId(primary, other) {
|
|
564
|
+
if (primary.modelId === other.modelId) {
|
|
565
|
+
return primary.modelId;
|
|
674
566
|
}
|
|
675
|
-
|
|
676
|
-
|
|
567
|
+
// The chosen snapshot's own model is authoritative, except when it is the internal
|
|
568
|
+
// <synthetic> placeholder and the other event names a real, priceable model.
|
|
569
|
+
if (isInternalClaudeModel(primary.modelId) && !isInternalClaudeModel(other.modelId)) {
|
|
570
|
+
return other.modelId;
|
|
677
571
|
}
|
|
678
|
-
return
|
|
679
|
-
? next.modelId
|
|
680
|
-
: previous.modelId;
|
|
572
|
+
return primary.modelId;
|
|
681
573
|
}
|
|
682
574
|
function canCollapseAdjacentUnkeyedUsageEvents(previous, next) {
|
|
683
575
|
return previous.filePath === next.filePath && next.lineNumber === previous.lineNumber + 1;
|
|
@@ -1063,34 +955,58 @@ function parseLiveUsageWindowSnapshots(usageOutput, now) {
|
|
|
1063
955
|
if (!usageOutput) {
|
|
1064
956
|
return [];
|
|
1065
957
|
}
|
|
1066
|
-
const snapshots = new Map();
|
|
1067
958
|
const normalizedOutput = usageOutput.replace(ANSI_ESCAPE_SEQUENCE, "");
|
|
959
|
+
const parsedLines = [];
|
|
1068
960
|
for (const line of normalizedOutput.split(/\r?\n/)) {
|
|
1069
961
|
const match = line
|
|
1070
962
|
.trim()
|
|
1071
|
-
.match(/^Current\s+(session|week)(?:\s+\(([^)]+)\))?:\s+(\d+)%\s+used\b
|
|
963
|
+
.match(/^Current\s+(session|week)(?:\s+\(([^)]+)\))?:\s+(\d+)%\s+used\b(?:.*?\bresets\s+(.+))?$/i);
|
|
1072
964
|
if (!match) {
|
|
1073
965
|
continue;
|
|
1074
966
|
}
|
|
1075
|
-
const label = match[1].toLowerCase() === "session" ? "session" : "week";
|
|
1076
|
-
const windowQualifier = (match[2] ?? "").trim().toLowerCase();
|
|
1077
967
|
const usedPercent = Number(match[3]);
|
|
1078
|
-
|
|
1079
|
-
const resetsAtMs = parseResetTimestampUtc(match[4], now.getTime(), windowMinutes);
|
|
1080
|
-
if (!Number.isFinite(usedPercent) || !resetsAtMs) {
|
|
968
|
+
if (!Number.isFinite(usedPercent)) {
|
|
1081
969
|
continue;
|
|
1082
970
|
}
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
snapshots.set(limitId, {
|
|
1086
|
-
scope: label === "session" ? "primary" : "secondary",
|
|
971
|
+
const label = match[1].toLowerCase() === "session" ? "session" : "week";
|
|
972
|
+
parsedLines.push({
|
|
1087
973
|
label,
|
|
974
|
+
windowQualifier: (match[2] ?? "").trim().toLowerCase(),
|
|
975
|
+
usedPercent,
|
|
976
|
+
windowMinutes: label === "session" ? CLAUDE_SESSION_WINDOW_MINUTES : CLAUDE_WEEK_WINDOW_MINUTES,
|
|
977
|
+
resetString: match[4]?.trim() || null
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
// Per-scope reset times printed by Claude. Lines like "Current week (Sonnet
|
|
981
|
+
// only)" omit the reset because it is identical to the "all models" week, so
|
|
982
|
+
// a missing reset inherits the resolved reset of the same scope.
|
|
983
|
+
const resetMsByLabel = new Map();
|
|
984
|
+
for (const parsed of parsedLines) {
|
|
985
|
+
if (!parsed.resetString || resetMsByLabel.has(parsed.label)) {
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const resetsAtMs = parseResetTimestampUtc(parsed.resetString, now.getTime(), parsed.windowMinutes);
|
|
989
|
+
if (resetsAtMs) {
|
|
990
|
+
resetMsByLabel.set(parsed.label, resetsAtMs);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const snapshots = new Map();
|
|
994
|
+
for (const parsed of parsedLines) {
|
|
995
|
+
const resetsAtMs = resetMsByLabel.get(parsed.label) ?? null;
|
|
996
|
+
if (!resetsAtMs) {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
const isSonnetOnlyWeek = parsed.label === "week" && parsed.windowQualifier === "sonnet only";
|
|
1000
|
+
const limitId = isSonnetOnlyWeek ? "current-week-sonnet-only" : `current-${parsed.label}`;
|
|
1001
|
+
snapshots.set(limitId, {
|
|
1002
|
+
scope: parsed.label === "session" ? "primary" : "secondary",
|
|
1003
|
+
label: parsed.label,
|
|
1088
1004
|
limitId,
|
|
1089
1005
|
modelScope: isSonnetOnlyWeek ? "sonnet-only" : "all-models",
|
|
1090
1006
|
modelType: isSonnetOnlyWeek ? "sonnet only" : undefined,
|
|
1091
|
-
usedPercent,
|
|
1007
|
+
usedPercent: parsed.usedPercent,
|
|
1092
1008
|
resetsAtMs,
|
|
1093
|
-
windowMinutes
|
|
1009
|
+
windowMinutes: parsed.windowMinutes
|
|
1094
1010
|
});
|
|
1095
1011
|
}
|
|
1096
1012
|
return [...snapshots.values()].sort((left, right) => left.windowMinutes - right.windowMinutes || left.limitId.localeCompare(right.limitId));
|
|
@@ -1147,6 +1063,9 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
|
|
|
1147
1063
|
event.timestampMs < snapshot.resetsAtMs &&
|
|
1148
1064
|
matchesClaudeLiveSnapshotModelScope(snapshot, event.modelId));
|
|
1149
1065
|
const totals = sumUsageTotals(inWindowEvents.map((event) => event.totals));
|
|
1066
|
+
if (snapshot.usedPercent > 0 && totals.eventCount === 0) {
|
|
1067
|
+
totals.estimatedCreditsStatus = "unavailable";
|
|
1068
|
+
}
|
|
1150
1069
|
const fallbackLastSeenMs = Math.min(now.getTime(), snapshot.resetsAtMs);
|
|
1151
1070
|
const firstSeenMs = inWindowEvents.reduce((minimum, event) => Math.min(minimum, event.timestampMs), Number.POSITIVE_INFINITY);
|
|
1152
1071
|
const lastSeenMs = inWindowEvents.reduce((maximum, event) => Math.max(maximum, event.timestampMs), Number.NEGATIVE_INFINITY);
|
|
@@ -32,8 +32,11 @@ export function addUsageTotals(target, source) {
|
|
|
32
32
|
target.totalTokens += source.totalTokens;
|
|
33
33
|
target.estimatedCredits += source.estimatedCredits;
|
|
34
34
|
target.eventCount += source.eventCount;
|
|
35
|
-
if (source.
|
|
36
|
-
target.
|
|
35
|
+
if (source.cacheReadStatus === "unavailable") {
|
|
36
|
+
target.cacheReadStatus = "unavailable";
|
|
37
|
+
}
|
|
38
|
+
if (source.cacheWriteStatus === "unavailable") {
|
|
39
|
+
target.cacheWriteStatus = "unavailable";
|
|
37
40
|
}
|
|
38
41
|
if (source.estimatedCreditsStatus === "unavailable") {
|
|
39
42
|
target.estimatedCreditsStatus = "unavailable";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolveUsageRate } from "../pricing.js";
|
|
2
|
+
/**
|
|
3
|
+
* Copilot-specific estimated API-equivalent rate card (micro-credits per
|
|
4
|
+
* million tokens). This is intentionally separate from the Codex and
|
|
5
|
+
* Antigravity rate cards — Copilot bills the same model families at different
|
|
6
|
+
* effective rates, so there is no single shared source of truth to reuse.
|
|
7
|
+
*/
|
|
8
|
+
export const RATE_CARD = {
|
|
9
|
+
"gpt-5-mini": { input: 25, cacheRead: 2.5, cacheWrite: 25, cacheWrite5m: 25, cacheWrite1h: 25, output: 200 },
|
|
10
|
+
"gpt-5.3-codex": { input: 175, cacheRead: 17.5, cacheWrite: 175, cacheWrite5m: 175, cacheWrite1h: 175, output: 1400 },
|
|
11
|
+
"gpt-5.4": { input: 250, cacheRead: 25, cacheWrite: 250, cacheWrite5m: 250, cacheWrite1h: 250, output: 1500, longContext: { thresholdTokens: 272000, rate: { input: 500, cacheRead: 50, cacheWrite: 500, cacheWrite5m: 500, cacheWrite1h: 500, output: 2250 } } },
|
|
12
|
+
"gpt-5.4-mini": { input: 75, cacheRead: 7.5, cacheWrite: 75, cacheWrite5m: 75, cacheWrite1h: 75, output: 450 },
|
|
13
|
+
"gpt-5.4-nano": { input: 20, cacheRead: 2, cacheWrite: 20, cacheWrite5m: 20, cacheWrite1h: 20, output: 125 },
|
|
14
|
+
"gpt-5.5": { input: 500, cacheRead: 50, cacheWrite: 500, cacheWrite5m: 500, cacheWrite1h: 500, output: 3000, longContext: { thresholdTokens: 272000, rate: { input: 1000, cacheRead: 100, cacheWrite: 1000, cacheWrite5m: 1000, cacheWrite1h: 1000, output: 4500 } } },
|
|
15
|
+
"claude-haiku-4-5": { input: 100, cacheRead: 10, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 200, output: 500 },
|
|
16
|
+
"claude-sonnet-4-5": { input: 300, cacheRead: 30, cacheWrite: 375, cacheWrite5m: 375, cacheWrite1h: 600, output: 1500 },
|
|
17
|
+
"claude-sonnet-4-6": { input: 300, cacheRead: 30, cacheWrite: 375, cacheWrite5m: 375, cacheWrite1h: 600, output: 1500 },
|
|
18
|
+
"claude-opus-4-5": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
|
|
19
|
+
"claude-opus-4-6": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
|
|
20
|
+
"claude-opus-4-7": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
|
|
21
|
+
"claude-opus-4-8": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
|
|
22
|
+
"claude-fable-5": { input: 1000, cacheRead: 100, cacheWrite: 1250, cacheWrite5m: 1250, cacheWrite1h: 2000, output: 5000 },
|
|
23
|
+
"gemini-2.5-pro": { input: 125, cacheRead: 12.5, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 125, output: 1000 },
|
|
24
|
+
"gemini-3-flash": { input: 50, cacheRead: 5, cacheWrite: 50, cacheWrite5m: 50, cacheWrite1h: 50, output: 300 },
|
|
25
|
+
"gemini-3.1-pro": { input: 200, cacheRead: 20, cacheWrite: 200, cacheWrite5m: 200, cacheWrite1h: 200, output: 1200, longContext: { thresholdTokens: 200000, rate: { input: 400, cacheRead: 40, cacheWrite: 400, cacheWrite5m: 400, cacheWrite1h: 400, output: 1800 } } },
|
|
26
|
+
"gemini-3.5-flash": { input: 150, cacheRead: 15, cacheWrite: 150, cacheWrite5m: 150, cacheWrite1h: 150, output: 900 },
|
|
27
|
+
"mai-code-1-flash": { input: 75, cacheRead: 7.5, cacheWrite: 75, cacheWrite5m: 75, cacheWrite1h: 75, output: 450 },
|
|
28
|
+
"raptor-mini": { input: 25, cacheRead: 2.5, cacheWrite: 25, cacheWrite5m: 25, cacheWrite1h: 25, output: 200 }
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Model id prefixes that Copilot does not bill (inline completions / next-edit
|
|
32
|
+
* suggestions). These are zero-rated rather than "unknown" so they never turn
|
|
33
|
+
* aggregate credit totals unknown.
|
|
34
|
+
*/
|
|
35
|
+
export const NON_BILLABLE_MODEL_PREFIXES = [
|
|
36
|
+
"copilot-nes",
|
|
37
|
+
"copilot-suggestion",
|
|
38
|
+
"copilot-suggestions"
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Canonicalize a Copilot model id. The exporter already emits stable,
|
|
42
|
+
* human-readable ids (including dated suffixes like `gpt-5.4-2026-03-01`), and
|
|
43
|
+
* the dashboard surfaces those verbatim, so this only guards the empty case.
|
|
44
|
+
* Prefix-based rate resolution (see {@link rateForCopilotModel}) handles dated
|
|
45
|
+
* suffixes without collapsing the displayed id.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeCopilotModelId(modelId) {
|
|
48
|
+
return modelId || "unknown";
|
|
49
|
+
}
|
|
50
|
+
export function rateForCopilotModel(modelId, inputTokens) {
|
|
51
|
+
return resolveUsageRate(RATE_CARD, modelId, inputTokens, { prefixMatch: true });
|
|
52
|
+
}
|
|
53
|
+
export function isNonBillableCopilotModel(modelId) {
|
|
54
|
+
return NON_BILLABLE_MODEL_PREFIXES.some((prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`));
|
|
55
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
5
|
+
import { asRecord } from "../../limits.js";
|
|
6
|
+
const VSCODE_OTEL_SETTINGS = {
|
|
7
|
+
"github.copilot.chat.otel.enabled": true,
|
|
8
|
+
"github.copilot.chat.otel.exporterType": "file",
|
|
9
|
+
"github.copilot.chat.otel.captureContent": false
|
|
10
|
+
};
|
|
11
|
+
export async function configureCopilotVsCodeLogging(options = {}) {
|
|
12
|
+
const root = path.resolve(options.root ?? os.homedir());
|
|
13
|
+
const outfile = getCopilotOtelPath(root);
|
|
14
|
+
const settingsPath = options.settingsPath ?? (await getVsCodeSettingsPath(root));
|
|
15
|
+
const settingsText = await readTextFileOrEmpty(settingsPath);
|
|
16
|
+
const { text, changed } = updateJsoncSettings(settingsText, {
|
|
17
|
+
...VSCODE_OTEL_SETTINGS,
|
|
18
|
+
"github.copilot.chat.otel.outfile": toVsCodeOutfilePath(outfile)
|
|
19
|
+
});
|
|
20
|
+
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
21
|
+
await fs.promises.mkdir(path.dirname(outfile), { recursive: true });
|
|
22
|
+
if (changed) {
|
|
23
|
+
await fs.promises.writeFile(settingsPath, text, "utf8");
|
|
24
|
+
}
|
|
25
|
+
return { settingsPath, outfile, changed };
|
|
26
|
+
}
|
|
27
|
+
export function getCopilotOtelPath(root) {
|
|
28
|
+
return path.join(root, ".copilot", "otel", "vscode.jsonl");
|
|
29
|
+
}
|
|
30
|
+
export function toVsCodeOutfilePath(filePath) {
|
|
31
|
+
return process.platform === "win32" ? filePath.replace(/\\/g, "/") : filePath;
|
|
32
|
+
}
|
|
33
|
+
export function getCopilotCliOtelEnv(outfile) {
|
|
34
|
+
return {
|
|
35
|
+
COPILOT_OTEL_ENABLED: "true",
|
|
36
|
+
COPILOT_OTEL_FILE_EXPORTER_PATH: outfile
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function getVsCodeSettingsPath(root) {
|
|
40
|
+
const userRoots = getVsCodeUserRoots(root);
|
|
41
|
+
for (const userRoot of userRoots) {
|
|
42
|
+
if (await isDirectory(userRoot)) {
|
|
43
|
+
return path.join(userRoot, "settings.json");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return path.join(userRoots[0], "settings.json");
|
|
47
|
+
}
|
|
48
|
+
export function getVsCodeUserRoots(root) {
|
|
49
|
+
if (process.platform === "darwin") {
|
|
50
|
+
const applicationSupport = path.join(root, "Library", "Application Support");
|
|
51
|
+
return [
|
|
52
|
+
path.join(applicationSupport, "Code", "User"),
|
|
53
|
+
path.join(applicationSupport, "Code - Insiders", "User")
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
if (process.platform === "win32") {
|
|
57
|
+
const appData = process.env.APPDATA ?? path.join(root, "AppData", "Roaming");
|
|
58
|
+
return [path.join(appData, "Code", "User"), path.join(appData, "Code - Insiders", "User")];
|
|
59
|
+
}
|
|
60
|
+
const configRoot = path.join(root, ".config");
|
|
61
|
+
return [path.join(configRoot, "Code", "User"), path.join(configRoot, "Code - Insiders", "User")];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* For each VS Code user root (stable + Insiders), read settings.json and report
|
|
65
|
+
* the configured Copilot OTEL outfile when file export is enabled. Used by the
|
|
66
|
+
* provider to detect "logging configured but the file has not been created yet".
|
|
67
|
+
*/
|
|
68
|
+
export async function getConfiguredCopilotOutfiles(root) {
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const userRoot of getVsCodeUserRoots(root)) {
|
|
71
|
+
const settings = await readJsonSettings(path.join(userRoot, "settings.json"));
|
|
72
|
+
const enabled = settings["github.copilot.chat.otel.enabled"] === true;
|
|
73
|
+
const exporterType = settings["github.copilot.chat.otel.exporterType"];
|
|
74
|
+
const outfile = settings["github.copilot.chat.otel.outfile"];
|
|
75
|
+
if (enabled && exporterType === "file" && typeof outfile === "string") {
|
|
76
|
+
results.push({ path: path.resolve(outfile), enabled: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
async function isDirectory(filePath) {
|
|
82
|
+
try {
|
|
83
|
+
const stat = await fs.promises.stat(filePath);
|
|
84
|
+
return stat.isDirectory();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function readJsonSettings(filePath) {
|
|
91
|
+
return parseJsoncSettings(await readTextFileOrEmpty(filePath));
|
|
92
|
+
}
|
|
93
|
+
async function readTextFileOrEmpty(filePath) {
|
|
94
|
+
try {
|
|
95
|
+
return await fs.promises.readFile(filePath, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT") {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function parseJsoncSettings(raw) {
|
|
105
|
+
if (!raw.trim()) {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
const parsed = parse(raw);
|
|
109
|
+
return asRecord(parsed) ?? {};
|
|
110
|
+
}
|
|
111
|
+
function updateJsoncSettings(raw, values) {
|
|
112
|
+
let text = raw.trim() ? raw : "{\n}";
|
|
113
|
+
let changed = false;
|
|
114
|
+
for (const [key, value] of Object.entries(values)) {
|
|
115
|
+
if (parseJsoncSettings(text)[key] === value) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const edits = modify(text, [key], value, {
|
|
119
|
+
formattingOptions: {
|
|
120
|
+
eol: "\n",
|
|
121
|
+
insertSpaces: true,
|
|
122
|
+
tabSize: 4
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
if (edits.length > 0) {
|
|
126
|
+
text = applyEdits(text, edits);
|
|
127
|
+
changed = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (changed && !text.endsWith("\n")) {
|
|
131
|
+
text += "\n";
|
|
132
|
+
}
|
|
133
|
+
return { text, changed };
|
|
134
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getConfiguredCopilotOutfiles, getCopilotOtelPath } from "./configure.js";
|
|
4
|
+
function dedupKey(resolvedPath) {
|
|
5
|
+
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
|
|
6
|
+
}
|
|
7
|
+
function isPermissionError(error) {
|
|
8
|
+
const code = error instanceof Error ? error.code : undefined;
|
|
9
|
+
return code === "EACCES" || code === "EPERM";
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Discover the Copilot OTEL JSONL files to read, from three sources:
|
|
13
|
+
* 1. the `COPILOT_OTEL_FILE_EXPORTER_PATH` env var (Copilot CLI),
|
|
14
|
+
* 2. the `outfile` configured in VS Code / Insiders settings, and
|
|
15
|
+
* 3. every `*.jsonl` in `<root>/.copilot/otel/`.
|
|
16
|
+
* This covers the VS Code extension, a standalone Copilot CLI, and the CLI run
|
|
17
|
+
* from VS Code, on Linux/Windows/macOS. Paths are resolved and de-duplicated
|
|
18
|
+
* (case-insensitively on Windows); the first occurrence wins.
|
|
19
|
+
*/
|
|
20
|
+
export async function discoverCopilotOtelFiles(options) {
|
|
21
|
+
const root = options?.root ?? process.cwd();
|
|
22
|
+
const env = options?.env ?? process.env;
|
|
23
|
+
const warnings = [];
|
|
24
|
+
const candidatePaths = [];
|
|
25
|
+
// 1. Environment exporter path.
|
|
26
|
+
const envPath = env.COPILOT_OTEL_FILE_EXPORTER_PATH;
|
|
27
|
+
if (typeof envPath === "string" && envPath.length > 0) {
|
|
28
|
+
candidatePaths.push(envPath);
|
|
29
|
+
}
|
|
30
|
+
// 2. VS Code / Insiders configured outfiles.
|
|
31
|
+
try {
|
|
32
|
+
for (const entry of await getConfiguredCopilotOutfiles(root)) {
|
|
33
|
+
candidatePaths.push(entry.path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (isPermissionError(error)) {
|
|
38
|
+
warnings.push("Failed to read VS Code Copilot settings: permission denied.");
|
|
39
|
+
}
|
|
40
|
+
// Missing settings or any other read issue is not an error here.
|
|
41
|
+
}
|
|
42
|
+
// 3. Directory scan of <root>/.copilot/otel/*.jsonl.
|
|
43
|
+
const otelDir = path.dirname(getCopilotOtelPath(root));
|
|
44
|
+
try {
|
|
45
|
+
const entries = await fs.readdir(otelDir, { withFileTypes: true });
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (entry.name.toLowerCase().endsWith(".jsonl")) {
|
|
51
|
+
candidatePaths.push(path.join(otelDir, entry.name));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (isPermissionError(error)) {
|
|
57
|
+
warnings.push(`Failed to read Copilot OTEL directory ${otelDir}: permission denied.`);
|
|
58
|
+
}
|
|
59
|
+
// ENOENT (missing directory) and similar are not errors — skip.
|
|
60
|
+
}
|
|
61
|
+
// Resolve, de-dup by path (first wins), and keep readable regular files.
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const files = [];
|
|
64
|
+
for (const candidate of candidatePaths) {
|
|
65
|
+
const resolved = path.resolve(candidate);
|
|
66
|
+
const key = dedupKey(resolved);
|
|
67
|
+
if (seen.has(key)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
seen.add(key);
|
|
71
|
+
try {
|
|
72
|
+
const stats = await fs.stat(resolved);
|
|
73
|
+
if (!stats.isFile()) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
await fs.access(resolved, fs.constants.R_OK);
|
|
77
|
+
files.push({ path: resolved, modifiedAtMs: stats.mtimeMs });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (isPermissionError(error)) {
|
|
81
|
+
warnings.push(`Failed to read Copilot OTEL file ${resolved}: permission denied.`);
|
|
82
|
+
}
|
|
83
|
+
// Missing file or other failures simply drop the candidate.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Stable sort by modifiedAtMs ASC, then path ASC.
|
|
87
|
+
files.sort((a, b) => {
|
|
88
|
+
if (a.modifiedAtMs !== b.modifiedAtMs) {
|
|
89
|
+
return a.modifiedAtMs - b.modifiedAtMs;
|
|
90
|
+
}
|
|
91
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
92
|
+
});
|
|
93
|
+
return { files, warnings };
|
|
94
|
+
}
|