letmecode 0.1.20 → 0.1.22
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 +176 -183
- 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/providers/pricing.js +5 -0
- package/ink-app/dist/reporting.js +11 -1
- package/package.json +11 -13
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { asRecord } from "../limits.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a GitHub token and fetch the Copilot quota/plan. The token is never
|
|
5
|
+
* echoed into the result or warnings. Token resolution and the HTTP fetch are
|
|
6
|
+
* injectable so tests run fully offline. A missing token is not an error — it
|
|
7
|
+
* yields a warning and no quota, leaving local OTEL usage unaffected.
|
|
8
|
+
*/
|
|
9
|
+
export async function getCopilotUserInfo(options) {
|
|
10
|
+
const env = options?.env ?? process.env;
|
|
11
|
+
const resolveToken = options?.resolveToken ?? resolveGitHubToken;
|
|
12
|
+
const fetchUser = options?.fetchUser ?? getCopilotUser;
|
|
13
|
+
const token = await resolveToken(env);
|
|
14
|
+
if (!token) {
|
|
15
|
+
return {
|
|
16
|
+
warnings: [
|
|
17
|
+
"Copilot plan and quota are unavailable: no GitHub token found. " +
|
|
18
|
+
"Set GH_TOKEN or GITHUB_TOKEN, or install GitHub CLI and run `gh auth login`."
|
|
19
|
+
]
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const result = await fetchUser(token);
|
|
23
|
+
if (!result.ok) {
|
|
24
|
+
return { warnings: [result.warning] };
|
|
25
|
+
}
|
|
26
|
+
return { quotaInfo: parseCopilotQuota(result.data), warnings: [] };
|
|
27
|
+
}
|
|
28
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Token resolution: GH_TOKEN → GITHUB_TOKEN → `gh auth token`
|
|
30
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
const GH_TIMEOUT_MS = 2000;
|
|
32
|
+
async function resolveGitHubToken(env) {
|
|
33
|
+
const fromEnv = nonEmpty(env.GH_TOKEN) ?? nonEmpty(env.GITHUB_TOKEN);
|
|
34
|
+
if (fromEnv) {
|
|
35
|
+
return fromEnv;
|
|
36
|
+
}
|
|
37
|
+
return ghAuthToken();
|
|
38
|
+
}
|
|
39
|
+
function ghAuthToken() {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
execFile("gh", ["auth", "token"], { timeout: GH_TIMEOUT_MS }, (error, stdout) => {
|
|
42
|
+
resolve(error ? null : nonEmpty(stdout));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function nonEmpty(value) {
|
|
47
|
+
const trimmed = value?.trim();
|
|
48
|
+
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
49
|
+
}
|
|
50
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
// HTTP transport (Node built-in fetch)
|
|
52
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
const COPILOT_USER_URL = "https://api.github.com/copilot_internal/user";
|
|
54
|
+
const REQUEST_TIMEOUT_MS = 10000;
|
|
55
|
+
// Header values mirror a real Copilot Chat client; the endpoint ignores requests
|
|
56
|
+
// with implausible editor/plugin versions.
|
|
57
|
+
const HEADERS = {
|
|
58
|
+
Accept: "application/json",
|
|
59
|
+
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
60
|
+
"Editor-Version": "vscode/1.96.2",
|
|
61
|
+
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
|
62
|
+
"X-GitHub-Api-Version": "2025-04-01"
|
|
63
|
+
};
|
|
64
|
+
async function getCopilotUser(token) {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(COPILOT_USER_URL, {
|
|
69
|
+
headers: { ...HEADERS, Authorization: `token ${token}` },
|
|
70
|
+
signal: controller.signal
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
return { ok: false, warning: warningForStatus(response.status) };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return { ok: true, data: await response.json() };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { ok: false, warning: "Copilot quota API returned invalid JSON." };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Aborts (timeout) and network failures land here; the response body, if any,
|
|
84
|
+
// is never read or logged.
|
|
85
|
+
return { ok: false, warning: "Copilot quota API request failed." };
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function warningForStatus(status) {
|
|
92
|
+
switch (status) {
|
|
93
|
+
case 401:
|
|
94
|
+
return "Copilot quota API returned 401; run `gh auth login` again.";
|
|
95
|
+
case 403:
|
|
96
|
+
return "Copilot quota API returned 403; the token may lack Copilot access.";
|
|
97
|
+
case 404:
|
|
98
|
+
return "Copilot quota API returned 404; the Copilot user endpoint is unavailable.";
|
|
99
|
+
case 429:
|
|
100
|
+
return "Copilot quota API is rate limited; try again later.";
|
|
101
|
+
default:
|
|
102
|
+
return `Copilot quota API returned ${status}.`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// Quota parsing (two known response shapes)
|
|
107
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
const KNOWN_LABELS = {
|
|
109
|
+
premium_interactions: "Premium",
|
|
110
|
+
chat: "Chat",
|
|
111
|
+
completions: "Completions"
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Parse the raw `/copilot_internal/user` JSON into {@link CopilotQuotaInfo}.
|
|
115
|
+
* Tolerates both the paid (`quota_snapshots`) and free
|
|
116
|
+
* (`monthly_quotas`/`limited_user_quotas`) shapes, never throws, derives
|
|
117
|
+
* percentages only from valid data, clamps them to 0..100, and leaves an
|
|
118
|
+
* unknown percentage undefined (never a false 0%). If the paid form yields no
|
|
119
|
+
* usable buckets, the free form is used as a fallback.
|
|
120
|
+
*/
|
|
121
|
+
export function parseCopilotQuota(raw) {
|
|
122
|
+
const root = asRecord(raw);
|
|
123
|
+
if (!root) {
|
|
124
|
+
return { quotas: [] };
|
|
125
|
+
}
|
|
126
|
+
const snapshots = asRecord(root.quota_snapshots);
|
|
127
|
+
const paid = snapshots ? parsePaidQuotas(snapshots) : [];
|
|
128
|
+
const usePaid = paid.length > 0;
|
|
129
|
+
const quotas = usePaid ? paid : parseFreeQuotas(root);
|
|
130
|
+
const info = {
|
|
131
|
+
quotas: quotas.sort((a, b) => a.id.localeCompare(b.id))
|
|
132
|
+
};
|
|
133
|
+
const plan = asString(root.copilot_plan);
|
|
134
|
+
if (plan !== undefined) {
|
|
135
|
+
info.plan = plan;
|
|
136
|
+
}
|
|
137
|
+
if (root.token_based_billing === true) {
|
|
138
|
+
info.tokenBasedBilling = true;
|
|
139
|
+
}
|
|
140
|
+
// Prefer the precise UTC reset timestamp; fall back to the date-only field.
|
|
141
|
+
const resetAt = usePaid
|
|
142
|
+
? asString(root.quota_reset_date_utc) ?? asString(root.quota_reset_date)
|
|
143
|
+
: asString(root.limited_user_reset_date);
|
|
144
|
+
if (resetAt !== undefined) {
|
|
145
|
+
info.resetAt = resetAt;
|
|
146
|
+
}
|
|
147
|
+
return info;
|
|
148
|
+
}
|
|
149
|
+
function parsePaidQuotas(snapshots) {
|
|
150
|
+
const quotas = [];
|
|
151
|
+
for (const [key, value] of Object.entries(snapshots)) {
|
|
152
|
+
const snapshot = asRecord(value);
|
|
153
|
+
const id = (snapshot && asString(snapshot.quota_id)) || key;
|
|
154
|
+
const quota = { id, label: labelForKey(key) };
|
|
155
|
+
if (!snapshot) {
|
|
156
|
+
quotas.push(quota);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (snapshot.unlimited === true) {
|
|
160
|
+
quota.unlimited = true;
|
|
161
|
+
}
|
|
162
|
+
const total = finiteNonNegative(snapshot.entitlement);
|
|
163
|
+
if (total !== undefined) {
|
|
164
|
+
quota.total = total;
|
|
165
|
+
}
|
|
166
|
+
const percentRemaining = finiteNonNegative(snapshot.percent_remaining);
|
|
167
|
+
// `quota_remaining` carries the precise (often fractional) balance; `remaining`
|
|
168
|
+
// is a rounded integer. Prefer the precise one for credit math.
|
|
169
|
+
const rawRemaining = finiteNonNegative(snapshot.quota_remaining) ?? finiteNonNegative(snapshot.remaining);
|
|
170
|
+
if (percentRemaining !== undefined) {
|
|
171
|
+
quota.remainingPercent = clampPercent(percentRemaining);
|
|
172
|
+
quota.usedPercent = clampPercent(100 - quota.remainingPercent);
|
|
173
|
+
}
|
|
174
|
+
else if (rawRemaining !== undefined && total !== undefined && total > 0) {
|
|
175
|
+
quota.remainingPercent = clampPercent((rawRemaining / total) * 100);
|
|
176
|
+
quota.usedPercent = clampPercent(100 - quota.remainingPercent);
|
|
177
|
+
}
|
|
178
|
+
if (rawRemaining !== undefined) {
|
|
179
|
+
const remaining = total !== undefined ? Math.min(rawRemaining, total) : rawRemaining;
|
|
180
|
+
quota.remaining = remaining;
|
|
181
|
+
if (total !== undefined) {
|
|
182
|
+
quota.used = Math.max(0, total - remaining);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
quotas.push(quota);
|
|
186
|
+
}
|
|
187
|
+
return quotas;
|
|
188
|
+
}
|
|
189
|
+
function parseFreeQuotas(root) {
|
|
190
|
+
const monthly = asRecord(root.monthly_quotas) ?? {};
|
|
191
|
+
const limited = asRecord(root.limited_user_quotas) ?? {};
|
|
192
|
+
const keys = new Set([...Object.keys(monthly), ...Object.keys(limited)]);
|
|
193
|
+
const quotas = [];
|
|
194
|
+
for (const key of keys) {
|
|
195
|
+
const quota = { id: key, label: labelForKey(key) };
|
|
196
|
+
const total = finiteNonNegative(monthly[key]);
|
|
197
|
+
const rawRemaining = finiteNonNegative(limited[key]);
|
|
198
|
+
if (total !== undefined) {
|
|
199
|
+
quota.total = total;
|
|
200
|
+
}
|
|
201
|
+
if (rawRemaining !== undefined) {
|
|
202
|
+
const remaining = total !== undefined ? Math.min(rawRemaining, total) : rawRemaining;
|
|
203
|
+
quota.remaining = remaining;
|
|
204
|
+
if (total !== undefined) {
|
|
205
|
+
quota.used = Math.max(0, total - remaining);
|
|
206
|
+
if (total > 0) {
|
|
207
|
+
quota.usedPercent = clampPercent((quota.used / total) * 100);
|
|
208
|
+
quota.remainingPercent = clampPercent(100 - quota.usedPercent);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
quotas.push(quota);
|
|
213
|
+
}
|
|
214
|
+
return quotas;
|
|
215
|
+
}
|
|
216
|
+
function labelForKey(key) {
|
|
217
|
+
if (KNOWN_LABELS[key] !== undefined) {
|
|
218
|
+
return KNOWN_LABELS[key];
|
|
219
|
+
}
|
|
220
|
+
const words = key.replace(/_/g, " ").trim().split(/\s+/);
|
|
221
|
+
return words
|
|
222
|
+
.map((word) => (word.length === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)))
|
|
223
|
+
.join(" ") || key;
|
|
224
|
+
}
|
|
225
|
+
function asString(value) {
|
|
226
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
227
|
+
}
|
|
228
|
+
/** Coerce a number-or-numeric-string into a finite, non-negative number. */
|
|
229
|
+
function finiteNonNegative(value) {
|
|
230
|
+
const n = typeof value === "number"
|
|
231
|
+
? value
|
|
232
|
+
: typeof value === "string" && value.trim().length > 0
|
|
233
|
+
? Number(value)
|
|
234
|
+
: undefined;
|
|
235
|
+
return n !== undefined && Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
236
|
+
}
|
|
237
|
+
function clampPercent(value) {
|
|
238
|
+
if (!Number.isFinite(value)) {
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
return Math.min(100, Math.max(0, value));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Subtract one calendar month from a date in UTC, clamping the day so that, e.g.
|
|
245
|
+
* 2026-03-31 → 2026-02-28 and 2024-03-31 → 2024-02-29. A reset on the first of a
|
|
246
|
+
* month maps to the first of the previous month. Used to derive the start of the
|
|
247
|
+
* current monthly billing window from its reset (end) date.
|
|
248
|
+
*/
|
|
249
|
+
export function subtractOneUtcCalendarMonth(value) {
|
|
250
|
+
const result = new Date(value);
|
|
251
|
+
const originalDay = result.getUTCDate();
|
|
252
|
+
result.setUTCDate(1);
|
|
253
|
+
result.setUTCMonth(result.getUTCMonth() - 1);
|
|
254
|
+
const daysInTargetMonth = new Date(Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)).getUTCDate();
|
|
255
|
+
result.setUTCDate(Math.min(originalDay, daysInTargetMonth));
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { addUsageTotals, sumUsageTotals } from "../../contract.js";
|
|
2
|
+
import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "../../daily.js";
|
|
3
|
+
import { isNonBillableCopilotModel, normalizeCopilotModelId, rateForCopilotModel } from "../models.js";
|
|
4
|
+
/**
|
|
5
|
+
* Select events whose timestamp falls in the half-open interval
|
|
6
|
+
* `[startTimeMs, endTimeMs)`. An event exactly at `endTimeMs` belongs to the
|
|
7
|
+
* next window and is excluded. Used to scope OTEL usage to a billing window
|
|
8
|
+
* without re-parsing — the same events feed all-time and per-window rollups.
|
|
9
|
+
*/
|
|
10
|
+
export function filterCopilotUsageEvents(events, startTimeMs, endTimeMs) {
|
|
11
|
+
return events.filter((event) => event.timestampMs >= startTimeMs && event.timestampMs < endTimeMs);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Aggregate normalized Copilot usage events into per-model and per-day rollups
|
|
15
|
+
* plus summary totals, applying the corrected cache accounting model where the
|
|
16
|
+
* reported input already INCLUDES cache-read tokens but NOT cache-write tokens.
|
|
17
|
+
* Pure and deterministic: independent of input ordering.
|
|
18
|
+
*/
|
|
19
|
+
export function aggregateCopilotUsage(events) {
|
|
20
|
+
const byModel = new Map();
|
|
21
|
+
const byDay = createDailyUsageAggregates();
|
|
22
|
+
for (const event of events) {
|
|
23
|
+
const modelId = normalizeCopilotModelId(event.modelId);
|
|
24
|
+
const hasCacheInfo = event.cacheReadStatus === "known" || event.cacheWriteStatus === "known";
|
|
25
|
+
// The reported input already INCLUDES cache-read but NOT cache-write. The
|
|
26
|
+
// cache-read bucket is preserved IN FULL; only the portion that overlaps the
|
|
27
|
+
// reported input is subtracted to derive the uncached input. Capping cacheRead
|
|
28
|
+
// at inputTokens would silently lose cache-only events (input 0, cacheRead N).
|
|
29
|
+
const reportedInput = Math.max(0, event.inputTokens);
|
|
30
|
+
const cacheRead = hasCacheInfo ? Math.max(0, event.cacheReadInputTokens) : 0;
|
|
31
|
+
const uncachedInput = hasCacheInfo
|
|
32
|
+
? Math.max(0, reportedInput - cacheRead)
|
|
33
|
+
: reportedInput;
|
|
34
|
+
const cacheWrite = hasCacheInfo ? Math.max(0, event.cacheWriteInputTokens) : 0;
|
|
35
|
+
const output = event.outputTokens;
|
|
36
|
+
const reasoning = Math.min(event.reasoningOutputTokens, output);
|
|
37
|
+
const nonBillable = isNonBillableCopilotModel(modelId);
|
|
38
|
+
const rate = nonBillable ? undefined : rateForCopilotModel(modelId, event.inputTokens);
|
|
39
|
+
const creditsKnown = nonBillable || (hasCacheInfo && rate !== undefined);
|
|
40
|
+
const estimatedCreditsStatus = creditsKnown ? "known" : "unavailable";
|
|
41
|
+
const estimatedCredits = rate !== undefined && hasCacheInfo
|
|
42
|
+
? (uncachedInput / 1000000) * rate.input +
|
|
43
|
+
(cacheRead / 1000000) * rate.cacheRead +
|
|
44
|
+
(cacheWrite / 1000000) * rate.cacheWrite +
|
|
45
|
+
(output / 1000000) * rate.output
|
|
46
|
+
: 0;
|
|
47
|
+
const totals = {
|
|
48
|
+
inputTokens: uncachedInput,
|
|
49
|
+
outputTokens: output,
|
|
50
|
+
cacheReadInputTokens: cacheRead,
|
|
51
|
+
cacheWriteInputTokens: cacheWrite,
|
|
52
|
+
cacheWrite5mInputTokens: 0,
|
|
53
|
+
cacheWrite1hInputTokens: 0,
|
|
54
|
+
reasoningOutputTokens: reasoning,
|
|
55
|
+
totalTokens: uncachedInput + cacheRead + cacheWrite + output,
|
|
56
|
+
estimatedCredits,
|
|
57
|
+
eventCount: 1,
|
|
58
|
+
cacheReadStatus: event.cacheReadStatus,
|
|
59
|
+
cacheWriteStatus: event.cacheWriteStatus,
|
|
60
|
+
estimatedCreditsStatus
|
|
61
|
+
};
|
|
62
|
+
const existing = byModel.get(modelId);
|
|
63
|
+
if (existing) {
|
|
64
|
+
addUsageTotals(existing, totals);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
byModel.set(modelId, { ...totals });
|
|
68
|
+
}
|
|
69
|
+
addDailyUsage(byDay, event.timestampMs, modelId, undefined, totals);
|
|
70
|
+
}
|
|
71
|
+
const modelUsage = [...byModel.entries()]
|
|
72
|
+
.map(([modelId, totals]) => ({ modelId, totals }))
|
|
73
|
+
.sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
|
|
74
|
+
const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
|
|
75
|
+
const distinctModels = modelUsage.map((row) => row.modelId);
|
|
76
|
+
const dayUsage = buildDailyUsageRows(byDay);
|
|
77
|
+
return {
|
|
78
|
+
modelUsage,
|
|
79
|
+
dayUsage,
|
|
80
|
+
summaryTotals,
|
|
81
|
+
distinctModels,
|
|
82
|
+
tokenEvents: events.length
|
|
83
|
+
};
|
|
84
|
+
}
|