@stage5/lumine 0.3.54 → 0.3.55
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/lib/admin-spending.js +472 -0
- package/lib/admin.js +16 -1
- package/lib/commands.js +12 -0
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +55 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const round = (value) => {
|
|
6
|
+
if (!Number.isFinite(value))
|
|
7
|
+
throw new Error("Invalid computed spending amount.");
|
|
8
|
+
return Number(value.toFixed(8));
|
|
9
|
+
};
|
|
10
|
+
const USAGE =
|
|
11
|
+
"lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--json]";
|
|
12
|
+
|
|
13
|
+
function invalid(message) {
|
|
14
|
+
return Object.assign(new Error(message), {
|
|
15
|
+
code: "CLI_ADMIN_CLI_VALIDATION",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildSpendingOperation(options) {
|
|
20
|
+
if (
|
|
21
|
+
(options.positional || []).length !== 1 ||
|
|
22
|
+
options.adminDays ||
|
|
23
|
+
options.adminDate ||
|
|
24
|
+
options.adminAll ||
|
|
25
|
+
options.adminResume
|
|
26
|
+
) {
|
|
27
|
+
throw invalid(`Usage: ${USAGE}.`);
|
|
28
|
+
}
|
|
29
|
+
const query = new URLSearchParams();
|
|
30
|
+
const values = {
|
|
31
|
+
month: options.adminSpendingMonth,
|
|
32
|
+
from: options.adminSpendingFrom,
|
|
33
|
+
to: options.adminSpendingTo,
|
|
34
|
+
};
|
|
35
|
+
for (const [key, value] of Object.entries(values)) {
|
|
36
|
+
if (value === undefined) continue;
|
|
37
|
+
if (typeof value !== "string" || !/^20\d{2}-(0[1-9]|1[0-2])$/.test(value))
|
|
38
|
+
throw invalid(`--${key} must use YYYY-MM format.`);
|
|
39
|
+
query.set(key, value);
|
|
40
|
+
}
|
|
41
|
+
if (query.has("month") && (query.has("from") || query.has("to")))
|
|
42
|
+
throw invalid("Use --month or --from/--to, not both.");
|
|
43
|
+
if (query.has("from") !== query.has("to"))
|
|
44
|
+
throw invalid("Provide both --from and --to.");
|
|
45
|
+
if (query.has("from") && query.get("from") > query.get("to"))
|
|
46
|
+
throw invalid("--from must not follow --to.");
|
|
47
|
+
const qs = query.toString();
|
|
48
|
+
return {
|
|
49
|
+
name: "spending.report",
|
|
50
|
+
method: "GET",
|
|
51
|
+
path: `/cli/admin/spending${qs ? `?${qs}` : ""}`,
|
|
52
|
+
mutates: false,
|
|
53
|
+
requiresRun: false,
|
|
54
|
+
timeoutMs: 60000,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runAws(args, { profile, region, timeoutMs }) {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execFileAsync(
|
|
61
|
+
"aws",
|
|
62
|
+
[
|
|
63
|
+
...args,
|
|
64
|
+
"--profile",
|
|
65
|
+
profile,
|
|
66
|
+
"--region",
|
|
67
|
+
region,
|
|
68
|
+
"--output",
|
|
69
|
+
"json",
|
|
70
|
+
"--no-cli-pager",
|
|
71
|
+
],
|
|
72
|
+
{
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
timeout: timeoutMs,
|
|
75
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
76
|
+
env: { ...process.env, AWS_PAGER: "", AWS_CLI_AUTO_PROMPT: "off" },
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
return JSON.parse(stdout);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// Do not expose subprocess stdout, credentials or arbitrary provider error
|
|
82
|
+
// payloads in a saved financial report.
|
|
83
|
+
throw new Error(
|
|
84
|
+
error.code === "ENOENT"
|
|
85
|
+
? "AWS CLI is not installed. Install it and configure the requested profile."
|
|
86
|
+
: `AWS billing could not be read with profile ${profile}; check its authentication and Cost Explorer access.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function amount(metric) {
|
|
92
|
+
if (
|
|
93
|
+
metric?.Unit !== "USD" ||
|
|
94
|
+
metric.Amount === "" ||
|
|
95
|
+
metric.Amount == null ||
|
|
96
|
+
!Number.isFinite(Number(metric.Amount))
|
|
97
|
+
) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"AWS returned an invalid amount or a currency other than USD.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return Number(metric.Amount);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function normalizeAwsSpending(pages, months) {
|
|
106
|
+
const expected = new Map(
|
|
107
|
+
months
|
|
108
|
+
.filter((m) => m.startDayKey < m.endDayKeyExclusive)
|
|
109
|
+
.map((m) => [m.startDayKey, m]),
|
|
110
|
+
);
|
|
111
|
+
const found = new Map();
|
|
112
|
+
for (const page of pages) {
|
|
113
|
+
if (!Array.isArray(page?.ResultsByTime))
|
|
114
|
+
throw new Error("AWS returned no billing periods.");
|
|
115
|
+
for (const period of page.ResultsByTime) {
|
|
116
|
+
const start = period.TimePeriod?.Start;
|
|
117
|
+
const requested = expected.get(start);
|
|
118
|
+
if (
|
|
119
|
+
!requested ||
|
|
120
|
+
period.TimePeriod?.End !== requested.endDayKeyExclusive ||
|
|
121
|
+
typeof period.Estimated !== "boolean"
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"AWS billing periods do not match the requested UTC months.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
let target = found.get(start);
|
|
128
|
+
if (!target) {
|
|
129
|
+
target = {
|
|
130
|
+
status: "available",
|
|
131
|
+
costUsd: 0,
|
|
132
|
+
estimated: period.Estimated,
|
|
133
|
+
services: [],
|
|
134
|
+
startDayKey: start,
|
|
135
|
+
endDayKeyExclusive: period.TimePeriod.End,
|
|
136
|
+
};
|
|
137
|
+
found.set(start, target);
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
target.estimated !== period.Estimated ||
|
|
141
|
+
!Array.isArray(period.Groups)
|
|
142
|
+
)
|
|
143
|
+
throw new Error("Inconsistent AWS billing page.");
|
|
144
|
+
if (
|
|
145
|
+
period.Groups.length === 0 &&
|
|
146
|
+
period.Total?.UnblendedCost &&
|
|
147
|
+
amount(period.Total.UnblendedCost) !== 0
|
|
148
|
+
) {
|
|
149
|
+
throw new Error("AWS omitted the requested service breakdown.");
|
|
150
|
+
}
|
|
151
|
+
for (const group of period.Groups) {
|
|
152
|
+
if (
|
|
153
|
+
!Array.isArray(group.Keys) ||
|
|
154
|
+
group.Keys.length !== 1 ||
|
|
155
|
+
typeof group.Keys[0] !== "string" ||
|
|
156
|
+
target.services.some((s) => s.service === group.Keys[0])
|
|
157
|
+
)
|
|
158
|
+
throw new Error("Duplicate or invalid AWS service row.");
|
|
159
|
+
const costUsd = amount(group.Metrics?.UnblendedCost);
|
|
160
|
+
target.services.push({ service: group.Keys[0], costUsd });
|
|
161
|
+
target.costUsd += costUsd;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (found.size !== expected.size)
|
|
166
|
+
throw new Error("AWS billing is missing a requested month.");
|
|
167
|
+
return months.map((month) => {
|
|
168
|
+
if (month.startDayKey === month.endDayKeyExclusive)
|
|
169
|
+
return {
|
|
170
|
+
status: "no_completed_days",
|
|
171
|
+
costUsd: 0,
|
|
172
|
+
estimated: true,
|
|
173
|
+
services: [],
|
|
174
|
+
startDayKey: month.startDayKey,
|
|
175
|
+
endDayKeyExclusive: month.endDayKeyExclusive,
|
|
176
|
+
};
|
|
177
|
+
const value = found.get(month.startDayKey);
|
|
178
|
+
return {
|
|
179
|
+
...value,
|
|
180
|
+
costUsd: round(value.costUsd),
|
|
181
|
+
services: value.services.sort((a, b) => b.costUsd - a.costUsd),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateReport(report) {
|
|
187
|
+
if (
|
|
188
|
+
report?.schemaVersion !== 1 ||
|
|
189
|
+
report.currency !== "USD" ||
|
|
190
|
+
report.timezone !== "UTC" ||
|
|
191
|
+
!Array.isArray(report.months) ||
|
|
192
|
+
!report.months.length ||
|
|
193
|
+
report.months.length > 12 ||
|
|
194
|
+
!report.sources?.aws ||
|
|
195
|
+
report.range?.includesInProgressDay !== false
|
|
196
|
+
) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"The API did not return a supported spending report; deploy the matching API release.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
let expectedStart = report.range.startDayKey;
|
|
202
|
+
for (const month of report.months) {
|
|
203
|
+
const nextMonth = new Date(
|
|
204
|
+
Date.UTC(
|
|
205
|
+
Number(month.monthKey?.slice(0, 4)),
|
|
206
|
+
Number(month.monthKey?.slice(5, 7)),
|
|
207
|
+
1,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
.toISOString()
|
|
211
|
+
.slice(0, 10);
|
|
212
|
+
if (
|
|
213
|
+
!/^20\d{2}-(0[1-9]|1[0-2])$/.test(month.monthKey) ||
|
|
214
|
+
month.startDayKey !== `${month.monthKey}-01` ||
|
|
215
|
+
month.startDayKey !== expectedStart ||
|
|
216
|
+
!/^20\d{2}-\d{2}-\d{2}$/.test(month.endDayKeyExclusive) ||
|
|
217
|
+
month.endDayKeyExclusive < month.startDayKey ||
|
|
218
|
+
month.endDayKeyExclusive > nextMonth ||
|
|
219
|
+
(month.status === "closed" && month.endDayKeyExclusive !== nextMonth) ||
|
|
220
|
+
(month !== report.months.at(-1) &&
|
|
221
|
+
month.endDayKeyExclusive !== nextMonth) ||
|
|
222
|
+
new Date(`${month.endDayKeyExclusive}T00:00:00Z`)
|
|
223
|
+
.toISOString()
|
|
224
|
+
.slice(0, 10) !== month.endDayKeyExclusive
|
|
225
|
+
) {
|
|
226
|
+
throw new Error("The API spending report has invalid month boundaries.");
|
|
227
|
+
}
|
|
228
|
+
expectedStart = nextMonth;
|
|
229
|
+
// An available source must supply its amounts. In particular, null paid
|
|
230
|
+
// totals must never be coerced to zero when combined with operating costs.
|
|
231
|
+
const required = [
|
|
232
|
+
...(month.ai ? [month.ai.grossUsd] : []),
|
|
233
|
+
...(month.reimbursements
|
|
234
|
+
? [
|
|
235
|
+
month.reimbursements.aiServiceUsd,
|
|
236
|
+
month.reimbursements.infrastructureUsd,
|
|
237
|
+
month.reimbursements.totalUsd,
|
|
238
|
+
]
|
|
239
|
+
: []),
|
|
240
|
+
];
|
|
241
|
+
if (
|
|
242
|
+
required.some(
|
|
243
|
+
(value) => typeof value !== "number" || !Number.isFinite(value),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
throw new Error("The API spending report has incomplete source amounts.");
|
|
247
|
+
for (const value of [
|
|
248
|
+
month.ai?.grossUsd,
|
|
249
|
+
month.ai?.creditUsd,
|
|
250
|
+
month.ai?.estimatedCashUsd,
|
|
251
|
+
month.reimbursements?.aiServiceUsd,
|
|
252
|
+
month.reimbursements?.infrastructureUsd,
|
|
253
|
+
month.reimbursements?.totalUsd,
|
|
254
|
+
]) {
|
|
255
|
+
if (
|
|
256
|
+
value != null &&
|
|
257
|
+
(typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
258
|
+
)
|
|
259
|
+
throw new Error("Invalid spending source amount.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
report.months.at(-1).endDayKeyExclusive !== report.range.endDayKeyExclusive
|
|
264
|
+
)
|
|
265
|
+
throw new Error("The API spending report has incomplete month coverage.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function completeSpendingReport(receipt, options, aws = runAws) {
|
|
269
|
+
if (receipt?.ok !== true) return receipt;
|
|
270
|
+
const report = receipt.data?.spending;
|
|
271
|
+
validateReport(report);
|
|
272
|
+
const warnings = [...(report.warnings || [])];
|
|
273
|
+
const source = report.sources.aws;
|
|
274
|
+
const profile = options.adminAwsProfile || source.defaultProfile;
|
|
275
|
+
if (typeof profile !== "string" || !profile.trim())
|
|
276
|
+
throw invalid("--aws-profile must name an AWS CLI profile.");
|
|
277
|
+
let awsMonths = null;
|
|
278
|
+
let awsError = null;
|
|
279
|
+
try {
|
|
280
|
+
const settings = {
|
|
281
|
+
profile,
|
|
282
|
+
region: source.region,
|
|
283
|
+
timeoutMs: Math.max(
|
|
284
|
+
1000,
|
|
285
|
+
Math.min(60000, Number(options.timeoutMs) || 30000),
|
|
286
|
+
),
|
|
287
|
+
};
|
|
288
|
+
const hasCompletedDays =
|
|
289
|
+
report.range.startDayKey < report.range.endDayKeyExclusive;
|
|
290
|
+
const pages = [];
|
|
291
|
+
if (hasCompletedDays) {
|
|
292
|
+
const identity = await aws(["sts", "get-caller-identity"], settings);
|
|
293
|
+
if (
|
|
294
|
+
identity?.Account !== source.accountId ||
|
|
295
|
+
identity?.Arn !== source.principalArn
|
|
296
|
+
) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"AWS identity does not match the Twinkle billing principal. No Cost Explorer query was sent.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
let token;
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
do {
|
|
304
|
+
if (pages.length >= 100)
|
|
305
|
+
throw new Error("AWS billing exceeded the pagination limit.");
|
|
306
|
+
const page = await aws(
|
|
307
|
+
[
|
|
308
|
+
"ce",
|
|
309
|
+
"get-cost-and-usage",
|
|
310
|
+
"--time-period",
|
|
311
|
+
`Start=${report.range.startDayKey},End=${report.range.endDayKeyExclusive}`,
|
|
312
|
+
"--granularity",
|
|
313
|
+
"MONTHLY",
|
|
314
|
+
"--metrics",
|
|
315
|
+
"UnblendedCost",
|
|
316
|
+
"--group-by",
|
|
317
|
+
"Type=DIMENSION,Key=SERVICE",
|
|
318
|
+
"--no-paginate",
|
|
319
|
+
...(token ? ["--next-page-token", token] : []),
|
|
320
|
+
],
|
|
321
|
+
settings,
|
|
322
|
+
);
|
|
323
|
+
pages.push(page);
|
|
324
|
+
token = page.NextPageToken;
|
|
325
|
+
if (token && (typeof token !== "string" || seen.has(token)))
|
|
326
|
+
throw new Error("AWS billing pagination did not advance.");
|
|
327
|
+
if (token) seen.add(token);
|
|
328
|
+
} while (token);
|
|
329
|
+
}
|
|
330
|
+
awsMonths = normalizeAwsSpending(pages, report.months);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
awsError = error.message;
|
|
333
|
+
warnings.push(awsError);
|
|
334
|
+
}
|
|
335
|
+
const months = report.months.map((month, i) => {
|
|
336
|
+
const billing = awsMonths?.[i] ?? null;
|
|
337
|
+
const overlap = month.ai?.awsOverlapDetected === true;
|
|
338
|
+
if (overlap)
|
|
339
|
+
warnings.push(
|
|
340
|
+
`${month.monthKey}: AWS-billed AI detected; combined totals require overlap reconciliation.`,
|
|
341
|
+
);
|
|
342
|
+
const operating =
|
|
343
|
+
month.ai?.estimatedCashUsd != null && billing && !overlap
|
|
344
|
+
? round(month.ai.estimatedCashUsd + billing.costUsd)
|
|
345
|
+
: null;
|
|
346
|
+
return {
|
|
347
|
+
...month,
|
|
348
|
+
aws: billing,
|
|
349
|
+
providerOperatingCostUsd: operating,
|
|
350
|
+
totalIncludingReimbursementsUsd:
|
|
351
|
+
operating !== null && month.reimbursements
|
|
352
|
+
? round(operating + month.reimbursements.totalUsd)
|
|
353
|
+
: null,
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
const complete = months.every(
|
|
357
|
+
(m) => m.totalIncludingReimbursementsUsd !== null,
|
|
358
|
+
);
|
|
359
|
+
const sum = (select) =>
|
|
360
|
+
months.every((m) => select(m) != null)
|
|
361
|
+
? round(months.reduce((n, m) => n + select(m), 0))
|
|
362
|
+
: null;
|
|
363
|
+
return {
|
|
364
|
+
...receipt,
|
|
365
|
+
status: complete ? "success" : "partial",
|
|
366
|
+
data: {
|
|
367
|
+
...receipt.data,
|
|
368
|
+
spending: {
|
|
369
|
+
...report,
|
|
370
|
+
complete,
|
|
371
|
+
months,
|
|
372
|
+
warnings,
|
|
373
|
+
sources: {
|
|
374
|
+
...report.sources,
|
|
375
|
+
aws: {
|
|
376
|
+
...source,
|
|
377
|
+
profile,
|
|
378
|
+
status: awsMonths ? "available" : "unavailable",
|
|
379
|
+
error: awsError,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
totals: {
|
|
383
|
+
aiEstimatedCashUsd: sum((m) => m.ai?.estimatedCashUsd),
|
|
384
|
+
awsUsd: sum((m) => m.aws?.costUsd),
|
|
385
|
+
reimbursementsUsd: sum((m) => m.reimbursements?.totalUsd),
|
|
386
|
+
totalIncludingReimbursementsUsd: sum(
|
|
387
|
+
(m) => m.totalIncludingReimbursementsUsd,
|
|
388
|
+
),
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function formatSpending(report) {
|
|
396
|
+
const money = (value) =>
|
|
397
|
+
value == null
|
|
398
|
+
? "Unavailable"
|
|
399
|
+
: `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
400
|
+
const lines = [
|
|
401
|
+
`Twinkle spending · USD · UTC · ${report.complete ? "complete sources" : "INCOMPLETE SOURCES"}`,
|
|
402
|
+
];
|
|
403
|
+
const rows = [
|
|
404
|
+
["Category", ...report.months.map((m) => m.monthKey)],
|
|
405
|
+
[
|
|
406
|
+
"AI after credits",
|
|
407
|
+
...report.months.map((m) => money(m.ai?.estimatedCashUsd)),
|
|
408
|
+
],
|
|
409
|
+
["AWS (including tax)", ...report.months.map((m) => money(m.aws?.costUsd))],
|
|
410
|
+
[
|
|
411
|
+
"Reimbursements",
|
|
412
|
+
...report.months.map((m) => money(m.reimbursements?.totalUsd)),
|
|
413
|
+
],
|
|
414
|
+
[
|
|
415
|
+
"TOTAL incl. reimbursements",
|
|
416
|
+
...report.months.map((m) => money(m.totalIncludingReimbursementsUsd)),
|
|
417
|
+
],
|
|
418
|
+
["", ...report.months.map(() => "")],
|
|
419
|
+
["AI before credits", ...report.months.map((m) => money(m.ai?.grossUsd))],
|
|
420
|
+
["Promotional credit", ...report.months.map((m) => money(m.ai?.creditUsd))],
|
|
421
|
+
[
|
|
422
|
+
"Reimbursement: AI service",
|
|
423
|
+
...report.months.map((m) => money(m.reimbursements?.aiServiceUsd)),
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
"Reimbursement: infrastructure",
|
|
427
|
+
...report.months.map((m) => money(m.reimbursements?.infrastructureUsd)),
|
|
428
|
+
],
|
|
429
|
+
];
|
|
430
|
+
const widths = rows[0].map((_, i) =>
|
|
431
|
+
Math.max(...rows.map((row) => row[i].length)),
|
|
432
|
+
);
|
|
433
|
+
lines.push(
|
|
434
|
+
...rows.map((row) =>
|
|
435
|
+
row
|
|
436
|
+
.map((cell, i) =>
|
|
437
|
+
i ? cell.padStart(widths[i]) : cell.padEnd(widths[i]),
|
|
438
|
+
)
|
|
439
|
+
.join(" | "),
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
for (const month of report.months) {
|
|
443
|
+
if (month.status === "in_progress")
|
|
444
|
+
lines.push(
|
|
445
|
+
`${month.monthKey} AI/AWS: ${month.throughDayKey ? `through ${month.throughDayKey} UTC` : "no completed UTC days"}; current usage day excluded.`,
|
|
446
|
+
);
|
|
447
|
+
if (month.aws?.estimated)
|
|
448
|
+
lines.push(
|
|
449
|
+
`${month.monthKey} AWS is provisional and may change with billing updates.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
lines.push(
|
|
453
|
+
`Selected-period total: ${money(report.totals.totalIncludingReimbursementsUsd)}`,
|
|
454
|
+
);
|
|
455
|
+
lines.push(
|
|
456
|
+
"AI is a usage-based estimate after credits. AWS estimated flags and service details are in JSON.",
|
|
457
|
+
);
|
|
458
|
+
lines.push(
|
|
459
|
+
"Reimbursements are gross paid amounts by payment date, before refunds/fees; included once in the total above.",
|
|
460
|
+
);
|
|
461
|
+
if (report.sources.reimbursements?.includesCurrentDay)
|
|
462
|
+
lines.push(
|
|
463
|
+
`Reimbursements include today's settled payments through ${new Date((report.sources.reimbursements.endAtExclusive - 1) * 1000).toISOString()}.`,
|
|
464
|
+
);
|
|
465
|
+
if (report.inProgressDay)
|
|
466
|
+
lines.push(
|
|
467
|
+
`${report.inProgressDay.dayKey} partial AI usage: ${money(report.inProgressDay.ai?.grossUsd)} before credits, excluded.`,
|
|
468
|
+
);
|
|
469
|
+
for (const warning of report.warnings || [])
|
|
470
|
+
lines.push(`Unavailable: ${warning}`);
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
package/lib/admin.js
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
printOwnerTrace,
|
|
45
45
|
} from "./admin-owner-trace.js";
|
|
46
46
|
import { buildRolesOperation, printRoles } from "./admin-roles.js";
|
|
47
|
+
import { buildSpendingOperation, completeSpendingReport, formatSpending } from "./admin-spending.js";
|
|
47
48
|
import { buildCraftOperation, formatCraftResult } from "./admin-craft.js";
|
|
48
49
|
import { buildNonogramOperation, formatNonogramResult } from './admin-nonogram.js';
|
|
49
50
|
import {
|
|
@@ -1545,7 +1546,10 @@ export async function adminCommand(options) {
|
|
|
1545
1546
|
recommendationContentTypes,
|
|
1546
1547
|
viewFilter,
|
|
1547
1548
|
});
|
|
1548
|
-
if (operation.
|
|
1549
|
+
if (operation.name === "spending.report") {
|
|
1550
|
+
result = await completeSpendingReport(await fetchOperation(), options);
|
|
1551
|
+
if (result.status === "partial") process.exitCode = 1;
|
|
1552
|
+
} else if (operation.featuredWorkflow) {
|
|
1549
1553
|
result = await runFeaturedWorkflow({
|
|
1550
1554
|
options,
|
|
1551
1555
|
operation,
|
|
@@ -1624,6 +1628,11 @@ export async function adminCommand(options) {
|
|
|
1624
1628
|
// private evidence is printed, never kept in the receipt file
|
|
1625
1629
|
mutationIntent?.confirm(receiptToKeep(operation, result));
|
|
1626
1630
|
} catch (error) {
|
|
1631
|
+
if (operation.name === "spending.report" && error.status === 404) {
|
|
1632
|
+
error.message = "The spending report API is not deployed yet. Deploy the matching API before using this CLI command.";
|
|
1633
|
+
error.code = "CLI_ADMIN_SPENDING_NOT_DEPLOYED";
|
|
1634
|
+
error.data = { ok: false, status: "unavailable", error: { code: error.code, message: error.message, details: null } };
|
|
1635
|
+
}
|
|
1627
1636
|
if (operation.name === "runtime.evidence" && error.status === 404) {
|
|
1628
1637
|
error.code = "CLI_ADMIN_RUNTIME_EVIDENCE_NOT_DEPLOYED";
|
|
1629
1638
|
error.message =
|
|
@@ -4474,6 +4483,8 @@ export function parseAdminOperation(options) {
|
|
|
4474
4483
|
);
|
|
4475
4484
|
}
|
|
4476
4485
|
|
|
4486
|
+
if (namespace === "spending") return buildSpendingOperation(options);
|
|
4487
|
+
|
|
4477
4488
|
if (namespace === "ai-costs") {
|
|
4478
4489
|
if (action === "monthly" && !target && !extra) {
|
|
4479
4490
|
if (options.adminDays) {
|
|
@@ -7360,6 +7371,10 @@ function printAiBucketRuleResult({ operation, data }) {
|
|
|
7360
7371
|
|
|
7361
7372
|
function printAdminResult({ operation, result }) {
|
|
7362
7373
|
const data = result?.data || {};
|
|
7374
|
+
if (operation.name === "spending.report" && data.spending) {
|
|
7375
|
+
console.log(formatSpending(data.spending));
|
|
7376
|
+
return;
|
|
7377
|
+
}
|
|
7363
7378
|
if (operation.name === "math-lab.queue") {
|
|
7364
7379
|
console.log(`Math Lab: published artifact ${data.publishedArtifactVersionId}, review ${data.reviewId}, day ${data.dayKey} UTC.`);
|
|
7365
7380
|
for (const grade of data.grades || []) console.log(grade.available
|
package/lib/commands.js
CHANGED
|
@@ -2562,6 +2562,13 @@ export function parseArgs(args) {
|
|
|
2562
2562
|
);
|
|
2563
2563
|
}
|
|
2564
2564
|
}
|
|
2565
|
+
if (command === "admin" && positional[0] === "spending") {
|
|
2566
|
+
const allowed = new Set(["help", "month", "from", "to", "awsProfile", "output", "json",
|
|
2567
|
+
"apiUrl", "siteUrl", "authFile", "authToken", "clientName", "timeoutMs", "noUpdateCheck"]);
|
|
2568
|
+
for (const key of Object.keys(raw)) {
|
|
2569
|
+
if (!allowed.has(key)) throw new Error(`Unsupported spending option --${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}.`);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2565
2572
|
const suggestionInboxActions = new Set([
|
|
2566
2573
|
"list",
|
|
2567
2574
|
"merge",
|
|
@@ -2805,6 +2812,10 @@ export function parseArgs(args) {
|
|
|
2805
2812
|
adminActions: raw.actions ? String(raw.actions) : "",
|
|
2806
2813
|
adminDate: raw.date ? String(raw.date) : "",
|
|
2807
2814
|
adminDays: raw.days ? String(raw.days) : "",
|
|
2815
|
+
adminSpendingMonth: raw.month,
|
|
2816
|
+
adminSpendingFrom: raw.from,
|
|
2817
|
+
adminSpendingTo: raw.to,
|
|
2818
|
+
adminAwsProfile: raw.awsProfile,
|
|
2808
2819
|
adminMaxStep: raw.maxStep ? String(raw.maxStep) : "",
|
|
2809
2820
|
adminBuild: raw.build ? String(raw.build) : "",
|
|
2810
2821
|
adminEditionId: raw.editionId ? String(raw.editionId) : "",
|
|
@@ -3325,6 +3336,7 @@ export function printHelp() {
|
|
|
3325
3336
|
(with both a comment session and a daily run active, comment draft/reply/post/edit need --via session|run)
|
|
3326
3337
|
lumine admin comment edit <comment-id> --file <comment.md> [--review-receipt <review.json> | --reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--json]
|
|
3327
3338
|
lumine admin brief [--days <1..30>] [--json]
|
|
3339
|
+
lumine admin spending [--month YYYY-MM | --from YYYY-MM --to YYYY-MM] [--aws-profile <profile>] [--output <file.json>] [--json] (owner spending: AI, AWS, reimbursements; no active run required)
|
|
3328
3340
|
lumine admin ai-costs monthly [--output <file.json>] [--json] (no active run required)
|
|
3329
3341
|
lumine admin ai-costs day <YYYY-MM-DD> [--json]
|
|
3330
3342
|
lumine admin media-costs monthly [--json]
|
package/package.json
CHANGED
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -3886,6 +3886,61 @@ input snapshots as described below.
|
|
|
3886
3886
|
See `twinkle-api/JEV_PILOT.md` for configuration,
|
|
3887
3887
|
the synthetic evaluation step and release checks.
|
|
3888
3888
|
|
|
3889
|
+
### Spending by calendar month
|
|
3890
|
+
|
|
3891
|
+
```bash
|
|
3892
|
+
lumine admin spending --from 2026-08 --to 2026-10
|
|
3893
|
+
lumine admin spending --month 2026-09 --json --output work/spending-september.json
|
|
3894
|
+
lumine admin spending --aws-profile <profile> --json
|
|
3895
|
+
```
|
|
3896
|
+
|
|
3897
|
+
This owner-only read needs no delegated run. With no dates, it reports the
|
|
3898
|
+
previous and current UTC calendar months. Use `--month` for one month, or both
|
|
3899
|
+
`--from` and `--to` for an inclusive range of up to 12 months. Future months
|
|
3900
|
+
are refused. Current-month AI and AWS usage ends at today's UTC midnight;
|
|
3901
|
+
today's partial AI usage is returned separately and excluded from totals.
|
|
3902
|
+
Paid reimbursements include today's settled payments through the report time.
|
|
3903
|
+
Their separate cutoff is explicit in the table and JSON.
|
|
3904
|
+
|
|
3905
|
+
The default output is a table separating AI costs after promotional credits,
|
|
3906
|
+
AWS infrastructure including tax, and reimbursements, with a combined total.
|
|
3907
|
+
It also shows gross AI usage, credits, and AI-service versus infrastructure
|
|
3908
|
+
reimbursements. JSON includes provider and AWS-service details and the paid
|
|
3909
|
+
invoice references and dates. AI is a usage-based estimate. Credits follow
|
|
3910
|
+
the server's private billing policy and account for usage earlier in the same
|
|
3911
|
+
credit cycle, even when that usage predates the requested calendar month.
|
|
3912
|
+
|
|
3913
|
+
The API supplies the canonical AI ledger and paid reimbursement history.
|
|
3914
|
+
Reimbursements use settlement dates, including old invoices paid later, and
|
|
3915
|
+
include all known subscription histories and invoice pages. They are gross
|
|
3916
|
+
paid amounts, not Stripe fee/refund or bank-payout reconciliation. Failed,
|
|
3917
|
+
expired and pending attempts are excluded; no payment or subscription action
|
|
3918
|
+
is performed. Reimbursements are a separate category included in the total;
|
|
3919
|
+
`providerOperatingCostUsd` also provides AI plus AWS without reimbursements.
|
|
3920
|
+
|
|
3921
|
+
AWS reads use the installed AWS CLI and the existing billing profile, or
|
|
3922
|
+
`--aws-profile`. The command checks the required account and principal before
|
|
3923
|
+
querying Cost Explorer, paginates service groups, validates exact UTC periods
|
|
3924
|
+
and USD currency, and preserves provisional billing status. It never changes
|
|
3925
|
+
AWS credentials or infrastructure. Cost Explorer's ordinary query fees apply.
|
|
3926
|
+
AWS authentication stays local; it is not uploaded to Twinkle.
|
|
3927
|
+
|
|
3928
|
+
`data.spending` is versioned with `schemaVersion: 1`. `months` contains each
|
|
3929
|
+
month's `ai`, `aws`, `reimbursements`, `providerOperatingCostUsd`, and
|
|
3930
|
+
`totalIncludingReimbursementsUsd`; `totals` sums the selected period. Missing
|
|
3931
|
+
sources are `null`, never zero. A partial report preserves available data,
|
|
3932
|
+
sets `complete: false` and receipt `status: "partial"`, saves with `--output`,
|
|
3933
|
+
and exits nonzero. Unknown provider attribution or AWS-billed AI can make a
|
|
3934
|
+
combined total unavailable until the credit/overlap is reconciled. Zero usage
|
|
3935
|
+
on the first UTC day means no completed days, not a full-month forecast;
|
|
3936
|
+
reimbursements paid on that day still contribute to the total.
|
|
3937
|
+
|
|
3938
|
+
This report does not replace the existing AI run-rate projections or AWS
|
|
3939
|
+
remaining-period forecast required for a full daily-management review.
|
|
3940
|
+
Release dependency: deploy the owner-authorized `/cli/admin/spending` API
|
|
3941
|
+
before publishing the CLI that calls it. No new database migration, npm
|
|
3942
|
+
dependency or server-side AWS permission is required.
|
|
3943
|
+
|
|
3889
3944
|
### Application AI calendar-month cost (standing duty, every full daily review)
|
|
3890
3945
|
|
|
3891
3946
|
Run `lumine admin ai-costs monthly --json` during every full daily management
|