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.
@@ -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; merged same-key rows by per-field maxima to avoid double-counting cumulative snapshots.`);
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, sessionsRoot));
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, sessionsRoot) {
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
- const mergedUsage = mergeClaudeUsage(previous.usage, next.usage);
641
- const modelId = selectMergedEventModelId(previous, next);
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, mergedUsage),
545
+ usageSignature: buildUsageSignatureFromParts(sessionId, modelId, usage),
650
546
  timestampMs: Math.max(normalizeTimestamp(previous.timestampMs), normalizeTimestamp(next.timestampMs)),
651
547
  modelId,
652
- usage: mergedUsage,
653
- totals: usageToTotals(modelId, mergedUsage),
548
+ usage,
549
+ totals: usageToTotals(modelId, usage),
654
550
  rateLimits: latestEvent.rateLimits ?? previous.rateLimits ?? next.rateLimits
655
551
  };
656
552
  }
657
- function mergeClaudeUsage(previous, next) {
658
- return {
659
- inputTokens: Math.max(previous.inputTokens, next.inputTokens),
660
- cacheReadInputTokens: Math.max(previous.cacheReadInputTokens, next.cacheReadInputTokens),
661
- cacheCreationInputTokens: Math.max(previous.cacheCreationInputTokens, next.cacheCreationInputTokens),
662
- cacheCreation5mInputTokens: Math.max(previous.cacheCreation5mInputTokens, next.cacheCreation5mInputTokens),
663
- cacheCreation1hInputTokens: Math.max(previous.cacheCreation1hInputTokens, next.cacheCreation1hInputTokens),
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
- if (isInternalClaudeModel(previous.modelId) && !isInternalClaudeModel(next.modelId)) {
673
- return next.modelId;
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
- if (isInternalClaudeModel(next.modelId) && !isInternalClaudeModel(previous.modelId)) {
676
- return previous.modelId;
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 next.totals.estimatedCredits >= previous.totals.estimatedCredits
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.*?\bresets\s+(.+)$/i);
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
- const windowMinutes = label === "session" ? CLAUDE_SESSION_WINDOW_MINUTES : CLAUDE_WEEK_WINDOW_MINUTES;
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 isSonnetOnlyWeek = label === "week" && windowQualifier === "sonnet only";
1084
- const limitId = isSonnetOnlyWeek ? "current-week-sonnet-only" : `current-${label}`;
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.cacheStatus === "unavailable") {
36
- target.cacheStatus = "unavailable";
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
+ }