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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { asRecord } from "../../limits.js";
|
|
4
|
+
const INPUT_KEY = "gen_ai.usage.input_tokens";
|
|
5
|
+
const OUTPUT_KEY = "gen_ai.usage.output_tokens";
|
|
6
|
+
const CACHE_READ_KEYS = [
|
|
7
|
+
"gen_ai.usage.cache_read.input_tokens",
|
|
8
|
+
"gen_ai.usage.cache_read_input_tokens"
|
|
9
|
+
];
|
|
10
|
+
const CACHE_WRITE_KEYS = [
|
|
11
|
+
"gen_ai.usage.cache_write.input_tokens",
|
|
12
|
+
"gen_ai.usage.cache_creation.input_tokens",
|
|
13
|
+
"gen_ai.usage.cache_write_input_tokens",
|
|
14
|
+
"gen_ai.usage.cache_creation_input_tokens"
|
|
15
|
+
];
|
|
16
|
+
const REASONING_KEYS = [
|
|
17
|
+
"gen_ai.usage.reasoning.output_tokens",
|
|
18
|
+
"gen_ai.usage.reasoning_tokens"
|
|
19
|
+
];
|
|
20
|
+
// Completion time first, then start, then the high-resolution clocks.
|
|
21
|
+
const TIMESTAMP_KEYS = ["endTime", "startTime", "hrTime", "_hrTime", "time"];
|
|
22
|
+
/**
|
|
23
|
+
* Stream the discovered JSONL files and produce de-duplicated canonical Copilot
|
|
24
|
+
* chat token events. Only records explicitly recognizable as chat spans are
|
|
25
|
+
* kept (see {@link isChatSpan}); anything else is ignored rather than guessed.
|
|
26
|
+
*/
|
|
27
|
+
export async function parseCopilotOtelFiles(files) {
|
|
28
|
+
const events = [];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const warnings = [];
|
|
31
|
+
let linesRead = 0;
|
|
32
|
+
let malformedLines = 0;
|
|
33
|
+
let duplicatesRemoved = 0;
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
try {
|
|
36
|
+
await readJsonlFile(file, (payload, lineNumber) => {
|
|
37
|
+
linesRead += 1;
|
|
38
|
+
if (payload === MALFORMED) {
|
|
39
|
+
malformedLines += 1;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const event = toUsageEvent(payload, file, lineNumber);
|
|
43
|
+
if (!event) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const key = event.traceId && event.spanId
|
|
47
|
+
? `${event.traceId}:${event.spanId}`
|
|
48
|
+
: `${event.filePath}:${event.lineNumber}`;
|
|
49
|
+
if (seen.has(key)) {
|
|
50
|
+
duplicatesRemoved += 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
seen.add(key);
|
|
54
|
+
events.push(event);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const reason = isPermissionError(error) ? "permission denied" : "read error";
|
|
59
|
+
warnings.push(`Failed to read Copilot OTEL file ${file.path}: ${reason}.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
events.sort(compareEvents);
|
|
63
|
+
return { events, filesScanned: files.length, linesRead, malformedLines, duplicatesRemoved, warnings };
|
|
64
|
+
}
|
|
65
|
+
const MALFORMED = Symbol("malformed");
|
|
66
|
+
async function readJsonlFile(file, onLine) {
|
|
67
|
+
const stream = fs.createReadStream(file.path, { encoding: "utf8" });
|
|
68
|
+
const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
69
|
+
let lineNumber = 0;
|
|
70
|
+
try {
|
|
71
|
+
for await (const line of lineReader) {
|
|
72
|
+
lineNumber += 1;
|
|
73
|
+
if (!line.trim()) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
onLine(JSON.parse(line), lineNumber);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
onLine(MALFORMED, lineNumber);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
lineReader.close();
|
|
86
|
+
stream.destroy();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function toUsageEvent(payload, file, lineNumber) {
|
|
90
|
+
const record = asRecord(payload);
|
|
91
|
+
if (!record) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const attributes = flatAttributes(record.attributes);
|
|
95
|
+
if (!attributes || !isChatSpan(record, attributes)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const inputTokens = tokenValue(attributes[INPUT_KEY]);
|
|
99
|
+
const outputTokens = tokenValue(attributes[OUTPUT_KEY]);
|
|
100
|
+
const cacheReadInputTokens = firstTokenValue(attributes, CACHE_READ_KEYS);
|
|
101
|
+
const cacheWriteInputTokens = firstTokenValue(attributes, CACHE_WRITE_KEYS);
|
|
102
|
+
const reasoningOutputTokens = firstTokenValue(attributes, REASONING_KEYS);
|
|
103
|
+
if (inputTokens <= 0 &&
|
|
104
|
+
outputTokens <= 0 &&
|
|
105
|
+
cacheReadInputTokens <= 0 &&
|
|
106
|
+
cacheWriteInputTokens <= 0 &&
|
|
107
|
+
reasoningOutputTokens <= 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const hasCache = CACHE_READ_KEYS.some((k) => attributes[k] !== undefined) ||
|
|
111
|
+
CACHE_WRITE_KEYS.some((k) => attributes[k] !== undefined);
|
|
112
|
+
const cacheStatus = hasCache ? "known" : "unavailable";
|
|
113
|
+
const event = {
|
|
114
|
+
timestampMs: resolveTimestampMs(record) ?? file.modifiedAtMs,
|
|
115
|
+
modelId: resolveModelId(record, attributes),
|
|
116
|
+
inputTokens,
|
|
117
|
+
outputTokens,
|
|
118
|
+
cacheReadInputTokens,
|
|
119
|
+
cacheWriteInputTokens,
|
|
120
|
+
reasoningOutputTokens,
|
|
121
|
+
cacheReadStatus: cacheStatus,
|
|
122
|
+
cacheWriteStatus: cacheStatus,
|
|
123
|
+
filePath: file.path,
|
|
124
|
+
lineNumber
|
|
125
|
+
};
|
|
126
|
+
const traceId = stringValue(attributes["gen_ai.trace.id"]) ??
|
|
127
|
+
stringValue(attributes.trace_id) ??
|
|
128
|
+
stringValue(record.traceId) ??
|
|
129
|
+
spanContextValue(record, "traceId");
|
|
130
|
+
if (traceId) {
|
|
131
|
+
event.traceId = traceId;
|
|
132
|
+
}
|
|
133
|
+
const spanId = stringValue(record.spanId) ??
|
|
134
|
+
stringValue(attributes.span_id) ??
|
|
135
|
+
spanContextValue(record, "spanId");
|
|
136
|
+
if (spanId) {
|
|
137
|
+
event.spanId = spanId;
|
|
138
|
+
}
|
|
139
|
+
const responseId = stringValue(attributes["gen_ai.response.id"]);
|
|
140
|
+
if (responseId) {
|
|
141
|
+
event.responseId = responseId;
|
|
142
|
+
}
|
|
143
|
+
return event;
|
|
144
|
+
}
|
|
145
|
+
/** A canonical Copilot chat span: the only record kind we count. */
|
|
146
|
+
function isChatSpan(record, attributes) {
|
|
147
|
+
const operation = stringValue(attributes["gen_ai.operation.name"]);
|
|
148
|
+
const name = stringValue(record.name) ?? "";
|
|
149
|
+
return operation === "chat" || name === "chat" || name.startsWith("chat ");
|
|
150
|
+
}
|
|
151
|
+
function resolveModelId(record, attributes) {
|
|
152
|
+
return (stringValue(attributes["gen_ai.response.model"]) ??
|
|
153
|
+
stringValue(attributes["gen_ai.request.model"]) ??
|
|
154
|
+
stringValue(record.model) ??
|
|
155
|
+
"unknown");
|
|
156
|
+
}
|
|
157
|
+
function flatAttributes(value) {
|
|
158
|
+
return Array.isArray(value) ? null : asRecord(value);
|
|
159
|
+
}
|
|
160
|
+
function spanContextValue(record, key) {
|
|
161
|
+
const spanContext = asRecord(record.spanContext);
|
|
162
|
+
return spanContext ? stringValue(spanContext[key]) : undefined;
|
|
163
|
+
}
|
|
164
|
+
function resolveTimestampMs(record) {
|
|
165
|
+
for (const key of TIMESTAMP_KEYS) {
|
|
166
|
+
const ms = timestampToMs(record[key]);
|
|
167
|
+
if (ms !== undefined) {
|
|
168
|
+
return ms;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
function timestampToMs(value) {
|
|
174
|
+
// [seconds, nanoseconds] hrTime/startTime/endTime form.
|
|
175
|
+
if (Array.isArray(value)) {
|
|
176
|
+
const [seconds, nanoseconds] = value;
|
|
177
|
+
if (typeof seconds === "number" && typeof nanoseconds === "number") {
|
|
178
|
+
return seconds * 1000 + nanoseconds / 1000000;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
183
|
+
return value < 1e11 ? value * 1000 : value; // unix seconds vs milliseconds
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === "string") {
|
|
186
|
+
const trimmed = value.trim();
|
|
187
|
+
if (!trimmed) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
if (/^\d+$/.test(trimmed)) {
|
|
191
|
+
const n = Number(trimmed);
|
|
192
|
+
return n < 1e11 ? n * 1000 : n;
|
|
193
|
+
}
|
|
194
|
+
const parsed = Date.parse(trimmed);
|
|
195
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
function firstTokenValue(attributes, keys) {
|
|
200
|
+
for (const key of keys) {
|
|
201
|
+
if (attributes[key] !== undefined) {
|
|
202
|
+
return tokenValue(attributes[key]);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
function tokenValue(value) {
|
|
208
|
+
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
|
209
|
+
return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
|
|
210
|
+
}
|
|
211
|
+
function stringValue(value) {
|
|
212
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
213
|
+
}
|
|
214
|
+
function compareEvents(a, b) {
|
|
215
|
+
if (a.timestampMs !== b.timestampMs) {
|
|
216
|
+
return a.timestampMs - b.timestampMs;
|
|
217
|
+
}
|
|
218
|
+
if (a.filePath !== b.filePath) {
|
|
219
|
+
return a.filePath < b.filePath ? -1 : 1;
|
|
220
|
+
}
|
|
221
|
+
return a.lineNumber - b.lineNumber;
|
|
222
|
+
}
|
|
223
|
+
function isPermissionError(error) {
|
|
224
|
+
const code = error && typeof error === "object" && "code" in error
|
|
225
|
+
? error.code
|
|
226
|
+
: undefined;
|
|
227
|
+
return code === "EACCES" || code === "EPERM";
|
|
228
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { UsageProviderBase, createEmptyUsageTotals } from "../contract.js";
|
|
4
|
+
import { configureCopilotVsCodeLogging, getCopilotCliOtelEnv, getConfiguredCopilotOutfiles } from "./otel/configure.js";
|
|
5
|
+
import { discoverCopilotOtelFiles } from "./otel/discover.js";
|
|
6
|
+
import { parseCopilotOtelFiles } from "./otel/parse.js";
|
|
7
|
+
import { getCopilotUserInfo, subtractOneUtcCalendarMonth } from "./quota.js";
|
|
8
|
+
import { aggregateCopilotUsage, filterCopilotUsageEvents } from "./usage/aggregate.js";
|
|
9
|
+
// The token-metered bucket that maps to the "AI Credits" window.
|
|
10
|
+
const AI_CREDITS_QUOTA_ID = "premium_interactions";
|
|
11
|
+
export { configureCopilotVsCodeLogging, getCopilotCliOtelEnv };
|
|
12
|
+
// Quota buckets shown as the most prominent (primary) limit windows.
|
|
13
|
+
const PRIMARY_QUOTA_IDS = new Set([
|
|
14
|
+
"premium_interactions",
|
|
15
|
+
"chat",
|
|
16
|
+
"completions"
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* Joins two INDEPENDENT sources — the Copilot quota HTTP API and local OTEL
|
|
20
|
+
* JSONL token usage. A failure in either degrades to a warning and never blocks
|
|
21
|
+
* the other.
|
|
22
|
+
*/
|
|
23
|
+
export class CopilotUsageProvider extends UsageProviderBase {
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
super("copilot", "Copilot");
|
|
26
|
+
this.root = path.resolve(options.root ?? os.homedir());
|
|
27
|
+
this.env = options.env ?? process.env;
|
|
28
|
+
this.fetchUserInfo = options.fetchUserInfo ?? getCopilotUserInfo;
|
|
29
|
+
}
|
|
30
|
+
async getStats(_options = {}) {
|
|
31
|
+
const [quotaResult, usageResult] = await Promise.allSettled([
|
|
32
|
+
this.fetchUserInfo({ env: this.env }),
|
|
33
|
+
this.loadUsage()
|
|
34
|
+
]);
|
|
35
|
+
const warnings = [];
|
|
36
|
+
let quotaInfo;
|
|
37
|
+
if (quotaResult.status === "fulfilled") {
|
|
38
|
+
warnings.push(...quotaResult.value.warnings);
|
|
39
|
+
quotaInfo = quotaResult.value.quotaInfo;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
warnings.push("Copilot plan and quota are unavailable.");
|
|
43
|
+
}
|
|
44
|
+
const usage = usageResult.status === "fulfilled"
|
|
45
|
+
? usageResult.value
|
|
46
|
+
: {
|
|
47
|
+
filesScanned: 0,
|
|
48
|
+
linesRead: 0,
|
|
49
|
+
events: [],
|
|
50
|
+
aggregated: aggregateCopilotUsage([]),
|
|
51
|
+
warnings: ["Copilot OTEL usage is unavailable."]
|
|
52
|
+
};
|
|
53
|
+
warnings.push(...usage.warnings);
|
|
54
|
+
const { windows, unknownLabels, windowWarnings } = quotaInfo
|
|
55
|
+
? buildLimitWindows(quotaInfo, usage.events)
|
|
56
|
+
: { windows: [], unknownLabels: [], windowWarnings: [] };
|
|
57
|
+
if (unknownLabels.length > 0) {
|
|
58
|
+
warnings.push(`Copilot quota usage is unknown for: ${unknownLabels.join(", ")}.`);
|
|
59
|
+
}
|
|
60
|
+
warnings.push(...windowWarnings);
|
|
61
|
+
const { aggregated } = usage;
|
|
62
|
+
return {
|
|
63
|
+
providerId: this.id,
|
|
64
|
+
providerLabel: this.label,
|
|
65
|
+
summary: {
|
|
66
|
+
filesScanned: usage.filesScanned,
|
|
67
|
+
linesRead: usage.linesRead,
|
|
68
|
+
tokenEvents: aggregated.tokenEvents,
|
|
69
|
+
totals: aggregated.summaryTotals,
|
|
70
|
+
distinctModels: aggregated.distinctModels,
|
|
71
|
+
distinctPlanTypes: quotaInfo?.plan ? [quotaInfo.plan] : [],
|
|
72
|
+
rootLabel: "~/.copilot/otel",
|
|
73
|
+
rootPath: path.join(this.root, ".copilot", "otel")
|
|
74
|
+
},
|
|
75
|
+
modelUsage: aggregated.modelUsage,
|
|
76
|
+
dayUsage: aggregated.dayUsage,
|
|
77
|
+
primaryLimitWindows: windows.filter((w) => w.scope === "primary"),
|
|
78
|
+
secondaryLimitWindows: windows.filter((w) => w.scope === "secondary"),
|
|
79
|
+
warnings: dedupeWarnings(warnings)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Discover → parse → aggregate local OTEL usage, collecting warnings. */
|
|
83
|
+
async loadUsage() {
|
|
84
|
+
const discovery = await discoverCopilotOtelFiles({ root: this.root, env: this.env });
|
|
85
|
+
const parsed = await parseCopilotOtelFiles(discovery.files);
|
|
86
|
+
const aggregated = aggregateCopilotUsage(parsed.events);
|
|
87
|
+
const warnings = [...discovery.warnings, ...parsed.warnings];
|
|
88
|
+
if (parsed.malformedLines > 0) {
|
|
89
|
+
warnings.push(`Skipped ${parsed.malformedLines} malformed Copilot JSONL line(s).`);
|
|
90
|
+
}
|
|
91
|
+
if (discovery.files.length === 0) {
|
|
92
|
+
warnings.push((await describeMissingOtelFile(this.root)) ?? "No Copilot OTEL files were found.");
|
|
93
|
+
}
|
|
94
|
+
else if (aggregated.tokenEvents === 0) {
|
|
95
|
+
warnings.push("No Copilot token usage events were found in the discovered OTEL file(s).");
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
filesScanned: parsed.filesScanned,
|
|
99
|
+
linesRead: parsed.linesRead,
|
|
100
|
+
events: parsed.events,
|
|
101
|
+
aggregated,
|
|
102
|
+
warnings
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* When no OTEL files were discovered but VS Code is configured to export to one,
|
|
108
|
+
* surface the actionable "logging is on, file not created yet" hint.
|
|
109
|
+
*/
|
|
110
|
+
async function describeMissingOtelFile(root) {
|
|
111
|
+
let configured;
|
|
112
|
+
try {
|
|
113
|
+
configured = await getConfiguredCopilotOutfiles(root);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const missing = configured.find((entry) => entry.enabled);
|
|
119
|
+
return missing
|
|
120
|
+
? `VS Code Copilot logging is enabled, but ${missing.path} has not been created yet. Reload VS Code and send a Copilot Chat request.`
|
|
121
|
+
: undefined;
|
|
122
|
+
}
|
|
123
|
+
function buildLimitWindows(quotaInfo, events) {
|
|
124
|
+
const planType = quotaInfo.plan ?? "unknown";
|
|
125
|
+
const billing = deriveBillingWindow(quotaInfo.resetAt);
|
|
126
|
+
const windows = [];
|
|
127
|
+
const unknownLabels = [];
|
|
128
|
+
const windowWarnings = [];
|
|
129
|
+
for (const quota of quotaInfo.quotas) {
|
|
130
|
+
// Unlimited buckets (e.g. chat/completions on paid plans) are not limits, so
|
|
131
|
+
// they get no window and no "unknown" warning.
|
|
132
|
+
if (quota.unlimited) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const usedPercent = usedPercentOf(quota);
|
|
136
|
+
// LimitWindowRow cannot represent an unknown percent without showing a false
|
|
137
|
+
// 0%, so an unusable bucket is omitted and reported as a warning instead.
|
|
138
|
+
if (usedPercent === undefined) {
|
|
139
|
+
unknownLabels.push(quota.label);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const isAiCredits = quotaInfo.tokenBasedBilling === true && quota.id === AI_CREDITS_QUOTA_ID;
|
|
143
|
+
const startIso = billing ? billing.startIso : resolveResetIso(quotaInfo.resetAt);
|
|
144
|
+
const endIso = billing ? billing.endIso : startIso;
|
|
145
|
+
const windowMinutes = billing ? billing.windowMinutes : 0;
|
|
146
|
+
let totals = createEmptyUsageTotals();
|
|
147
|
+
let modelUsage = [];
|
|
148
|
+
let eventCount = 0;
|
|
149
|
+
let firstSeenIso = startIso;
|
|
150
|
+
let lastSeenIso = startIso;
|
|
151
|
+
// For the metered AI Credits bucket, join the official percentage with the
|
|
152
|
+
// local OTEL token usage that falls inside this billing window.
|
|
153
|
+
if (isAiCredits && billing) {
|
|
154
|
+
const windowEvents = filterCopilotUsageEvents(events, billing.startMs, billing.endMs);
|
|
155
|
+
const windowUsage = aggregateCopilotUsage(windowEvents);
|
|
156
|
+
totals = windowUsage.summaryTotals;
|
|
157
|
+
modelUsage = windowUsage.modelUsage;
|
|
158
|
+
eventCount = windowUsage.tokenEvents;
|
|
159
|
+
if (windowEvents.length > 0) {
|
|
160
|
+
const times = windowEvents.map((event) => event.timestampMs);
|
|
161
|
+
firstSeenIso = new Date(Math.min(...times)).toISOString();
|
|
162
|
+
lastSeenIso = new Date(Math.max(...times)).toISOString();
|
|
163
|
+
// Some Copilot surfaces export no cache token attributes; without them
|
|
164
|
+
// the API-equivalent cost cannot be computed, so say so explicitly
|
|
165
|
+
// instead of presenting a misleading number.
|
|
166
|
+
if (totals.cacheReadStatus === "unavailable" ||
|
|
167
|
+
totals.cacheWriteStatus === "unavailable") {
|
|
168
|
+
windowWarnings.push("Copilot did not report cache token counts for some events, so the API-equivalent cost cannot be estimated exactly.");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (officialUsageIsPositive(quota)) {
|
|
172
|
+
// GitHub reports consumption but local telemetry has no matching events.
|
|
173
|
+
// Don't present a trusted $0 — mark the cost unknown and warn.
|
|
174
|
+
totals = {
|
|
175
|
+
...totals,
|
|
176
|
+
estimatedCreditsStatus: "unavailable",
|
|
177
|
+
cacheReadStatus: "unavailable",
|
|
178
|
+
cacheWriteStatus: "unavailable"
|
|
179
|
+
};
|
|
180
|
+
windowWarnings.push("Copilot reports usage in the current billing period, but no matching local OTEL events were found. Local token totals are incomplete.");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
windows.push({
|
|
184
|
+
scope: PRIMARY_QUOTA_IDS.has(quota.id) ? "primary" : "secondary",
|
|
185
|
+
planType,
|
|
186
|
+
limitId: quota.id,
|
|
187
|
+
modelType: isAiCredits ? "AI Credits" : quota.label,
|
|
188
|
+
windowMinutes,
|
|
189
|
+
startTimeUtcIso: startIso,
|
|
190
|
+
endTimeUtcIso: endIso,
|
|
191
|
+
firstSeenUtcIso: firstSeenIso,
|
|
192
|
+
lastSeenUtcIso: lastSeenIso,
|
|
193
|
+
minUsedPercent: usedPercent,
|
|
194
|
+
maxUsedPercent: usedPercent,
|
|
195
|
+
totals,
|
|
196
|
+
modelUsage,
|
|
197
|
+
eventCount
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return { windows, unknownLabels, windowWarnings };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Copilot's monthly subscriber quota always resets on the 1st at 00:00 UTC, so
|
|
204
|
+
* the current window is [1st of the previous month, 1st of the reset month).
|
|
205
|
+
* Returns null when no/invalid reset date is available.
|
|
206
|
+
*/
|
|
207
|
+
function deriveBillingWindow(resetAt) {
|
|
208
|
+
if (!resetAt) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const resetMs = Date.parse(resetAt);
|
|
212
|
+
if (!Number.isFinite(resetMs)) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const reset = new Date(resetMs);
|
|
216
|
+
const end = new Date(Date.UTC(reset.getUTCFullYear(), reset.getUTCMonth(), 1));
|
|
217
|
+
const start = subtractOneUtcCalendarMonth(end);
|
|
218
|
+
const startMs = start.getTime();
|
|
219
|
+
const endMs = end.getTime();
|
|
220
|
+
return {
|
|
221
|
+
startMs,
|
|
222
|
+
endMs,
|
|
223
|
+
startIso: start.toISOString(),
|
|
224
|
+
endIso: end.toISOString(),
|
|
225
|
+
windowMinutes: (endMs - startMs) / 60000
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function officialUsageIsPositive(quota) {
|
|
229
|
+
return ((quota.used !== undefined && quota.used > 0) ||
|
|
230
|
+
(quota.usedPercent !== undefined && quota.usedPercent > 0));
|
|
231
|
+
}
|
|
232
|
+
/** The used percent when derivable from valid data, otherwise undefined. */
|
|
233
|
+
function usedPercentOf(quota) {
|
|
234
|
+
if (quota.usedPercent !== undefined) {
|
|
235
|
+
return clampPercent(quota.usedPercent);
|
|
236
|
+
}
|
|
237
|
+
if (quota.remainingPercent !== undefined) {
|
|
238
|
+
return clampPercent(100 - quota.remainingPercent);
|
|
239
|
+
}
|
|
240
|
+
if (quota.total !== undefined && quota.total > 0 && quota.used !== undefined) {
|
|
241
|
+
return clampPercent((quota.used / quota.total) * 100);
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
function resolveResetIso(resetAt) {
|
|
246
|
+
if (resetAt) {
|
|
247
|
+
const parsed = Date.parse(resetAt);
|
|
248
|
+
if (Number.isFinite(parsed)) {
|
|
249
|
+
return new Date(parsed).toISOString();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return new Date(0).toISOString();
|
|
253
|
+
}
|
|
254
|
+
function clampPercent(value) {
|
|
255
|
+
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0;
|
|
256
|
+
}
|
|
257
|
+
function dedupeWarnings(warnings) {
|
|
258
|
+
return [...new Set(warnings)];
|
|
259
|
+
}
|