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.
@@ -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
+ }