@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.
@@ -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.featuredWorkflow) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.54",
3
+ "version": "0.3.55",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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