ai-spend-agent 0.5.9 → 0.6.0
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 +8 -0
- package/dist/index.js +791 -78
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -11,6 +11,14 @@ npx aibill
|
|
|
11
11
|
It reads local Claude Code and Codex metadata, labels API-equivalent estimates,
|
|
12
12
|
and can optionally add official OpenAI or Anthropic provider-reported cost
|
|
13
13
|
through an environment-variable reference. No product telemetry is sent.
|
|
14
|
+
aibill never sits in the inference path and never stores, prints, or proxies provider credentials.
|
|
15
|
+
|
|
16
|
+
Connector validation (`live_verified`, `fixture_verified`, `untested`, or
|
|
17
|
+
`failed`) and each number's financial evidence (`verified`, `estimated`,
|
|
18
|
+
`detected_unverified`, or `missing`) are separate status axes. Run `npx aibill
|
|
19
|
+
doctor --sources` to see both with freshness and the last sanitized error.
|
|
20
|
+
The source registry also records read-boundary approval separately; an approved
|
|
21
|
+
local folder is permission to scan, not verified financial evidence.
|
|
14
22
|
|
|
15
23
|
See the repository
|
|
16
24
|
[README](https://github.com/futurastudio/ai-spend-agent#readme) for commands,
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { realpathSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
|
|
6
|
+
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses } from "@agent-finops/core";
|
|
7
7
|
import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
|
|
8
8
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
9
9
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
@@ -85,7 +85,7 @@ async function quickstartCommand(args) {
|
|
|
85
85
|
const sinceDays = args.sinceDays ?? 30;
|
|
86
86
|
if (!validSinceDays(sinceDays))
|
|
87
87
|
return invalidSinceDaysResult();
|
|
88
|
-
const { records, mode, warnings, codexInvocationFiles } = await loadInstantReadData(args);
|
|
88
|
+
const { records, mode, warnings, providerCoverage, codexInvocationFiles } = await loadInstantReadData(args);
|
|
89
89
|
const summaryRecords = mode === "connected"
|
|
90
90
|
? selectProviderFinancialHeadlineRecords(records)
|
|
91
91
|
: records;
|
|
@@ -163,6 +163,7 @@ async function quickstartCommand(args) {
|
|
|
163
163
|
groupBy,
|
|
164
164
|
color,
|
|
165
165
|
mode,
|
|
166
|
+
...(providerCoverage ? { providerCoverage } : {}),
|
|
166
167
|
nextSteps,
|
|
167
168
|
deadContext,
|
|
168
169
|
detectedPlans,
|
|
@@ -315,7 +316,9 @@ function quickstartNextSteps(mode, detected) {
|
|
|
315
316
|
steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
|
|
316
317
|
steps.push(`npx aibill connect ${detected[0].provider} add official provider-reported cost (ADMIN/owner key)`);
|
|
317
318
|
}
|
|
318
|
-
steps.push(
|
|
319
|
+
steps.push(mode === "demo"
|
|
320
|
+
? "npx aibill report --sample write a clearly labeled demo Markdown + HTML report"
|
|
321
|
+
: "npx aibill report write a shareable Markdown + HTML report");
|
|
319
322
|
steps.push("npx aibill --group-by project see which project has the most observed activity");
|
|
320
323
|
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://ai-spend-agent.vercel.app");
|
|
321
324
|
return steps;
|
|
@@ -323,13 +326,59 @@ function quickstartNextSteps(mode, detected) {
|
|
|
323
326
|
async function readPersistedSpend(rootPath) {
|
|
324
327
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
325
328
|
try {
|
|
326
|
-
const
|
|
327
|
-
|
|
329
|
+
const exactSpendContents = await readSafeStateText(stateDir, "spend.json");
|
|
330
|
+
const spend = JSON.parse(exactSpendContents);
|
|
331
|
+
if (!isPlainObject(spend) || !Array.isArray(spend.records)) {
|
|
332
|
+
throw new Error("persisted spend state must contain a records array");
|
|
333
|
+
}
|
|
334
|
+
const parsedRecords = spend.records.map((record) => parseUsageRecord(record));
|
|
335
|
+
const storedMode = isPersistedDataMode(spend.mode) ? spend.mode : undefined;
|
|
336
|
+
// The bundled fixture fingerprint is authoritative over a conflicting mode
|
|
337
|
+
// tag. A copied/tampered sample must never become connected billing merely
|
|
338
|
+
// because `mode` was changed in JSON.
|
|
339
|
+
const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
|
|
340
|
+
const records = mode === "sample" || mode === undefined
|
|
341
|
+
? downgradeSampleUsageEvidence(parsedRecords)
|
|
342
|
+
: parsedRecords;
|
|
343
|
+
const providerCoverage = persistedProviderCoverage(spend.accounting);
|
|
344
|
+
const connectedTrust = mode === "connected_provider"
|
|
345
|
+
? await verifyConnectedSpendTrustReceipt(rootPath, exactSpendContents)
|
|
346
|
+
: undefined;
|
|
347
|
+
return {
|
|
348
|
+
mode,
|
|
349
|
+
records,
|
|
350
|
+
...(providerCoverage ? { providerCoverage } : {}),
|
|
351
|
+
...(isPlainObject(spend.accounting) ? { accounting: spend.accounting } : {}),
|
|
352
|
+
...(connectedTrust ? { connectedTrust } : {})
|
|
353
|
+
};
|
|
328
354
|
}
|
|
329
355
|
catch {
|
|
330
356
|
return undefined;
|
|
331
357
|
}
|
|
332
358
|
}
|
|
359
|
+
function persistedProviderCoverage(accounting) {
|
|
360
|
+
if (accounting === undefined)
|
|
361
|
+
return undefined;
|
|
362
|
+
if (!isPlainObject(accounting)) {
|
|
363
|
+
throw new Error("persisted accounting state has an invalid shape");
|
|
364
|
+
}
|
|
365
|
+
if (accounting.coverageByProvider === undefined)
|
|
366
|
+
return undefined;
|
|
367
|
+
if (!isPlainObject(accounting.coverageByProvider)) {
|
|
368
|
+
throw new Error("persisted provider coverage has an invalid shape");
|
|
369
|
+
}
|
|
370
|
+
const coverage = Object.values(accounting.coverageByProvider);
|
|
371
|
+
if (coverage.some((value) => value !== "complete" && value !== "partial")) {
|
|
372
|
+
throw new Error("persisted provider coverage contains an invalid status");
|
|
373
|
+
}
|
|
374
|
+
if (coverage.length === 0)
|
|
375
|
+
return undefined;
|
|
376
|
+
return coverage.includes("partial") ? "partial" : "complete";
|
|
377
|
+
}
|
|
378
|
+
function trustedAccountingMap(accounting, field) {
|
|
379
|
+
const value = accounting?.[field];
|
|
380
|
+
return isPlainObject(value) ? value : {};
|
|
381
|
+
}
|
|
333
382
|
async function loadInstantReadData(args) {
|
|
334
383
|
const warnings = [];
|
|
335
384
|
if (args.sample) {
|
|
@@ -342,8 +391,20 @@ async function loadInstantReadData(args) {
|
|
|
342
391
|
// came from a provider — local-log records mislabeled connected (a past bug)
|
|
343
392
|
// must never be served as billing data.
|
|
344
393
|
const looksConnected = (persisted?.records ?? []).some((record) => record.providerCostType !== "local_agent_logs");
|
|
345
|
-
if (persisted &&
|
|
346
|
-
|
|
394
|
+
if (persisted &&
|
|
395
|
+
persisted.records.length > 0 &&
|
|
396
|
+
persisted.mode === "connected_provider" &&
|
|
397
|
+
looksConnected &&
|
|
398
|
+
persisted.connectedTrust?.trusted === true) {
|
|
399
|
+
return {
|
|
400
|
+
records: persisted.records,
|
|
401
|
+
mode: "connected",
|
|
402
|
+
warnings,
|
|
403
|
+
...(persisted.providerCoverage ? { providerCoverage: persisted.providerCoverage } : {})
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === false) {
|
|
407
|
+
warnings.push(`${persisted.connectedTrust.message} CLI: run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command. The repository-provided connected totals were ignored.`);
|
|
347
408
|
}
|
|
348
409
|
// Real local agent logs (Claude Code / Codex) beat any sample/legacy state.
|
|
349
410
|
const logs = await loadLocalAgentUsage({
|
|
@@ -358,7 +419,7 @@ async function loadInstantReadData(args) {
|
|
|
358
419
|
// same data source we just re-read — superseding it silently is correct,
|
|
359
420
|
// not worth a scary "sample/legacy" warning.
|
|
360
421
|
if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
|
|
361
|
-
warnings.push("Ignored persisted sample/legacy state in .ai-spend-agent/spend.json — showing your real local agent logs. Run `
|
|
422
|
+
warnings.push("Ignored persisted sample/legacy state in .ai-spend-agent/spend.json — showing your real local agent logs. Run `npx aibill reset` to clear it, or pass --ignore-state.");
|
|
362
423
|
}
|
|
363
424
|
return {
|
|
364
425
|
records: logs.records,
|
|
@@ -369,12 +430,15 @@ async function loadInstantReadData(args) {
|
|
|
369
430
|
}
|
|
370
431
|
// No real logs. Persisted sample/legacy state may still be shown, but only as
|
|
371
432
|
// DEMO (never as connected), with a warning when its origin is unknown.
|
|
372
|
-
if (persisted && persisted.records.length > 0) {
|
|
433
|
+
if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
|
|
373
434
|
if (persisted.mode === undefined) {
|
|
374
|
-
warnings.push("Persisted state in .ai-spend-agent/spend.json is from an older format with no data-mode tag — treating it as demo. Run `
|
|
435
|
+
warnings.push("Persisted state in .ai-spend-agent/spend.json is from an older format with no data-mode tag — treating it as demo. Run `npx aibill reset`, then re-scan to refresh.");
|
|
375
436
|
}
|
|
376
437
|
return { records: persisted.records, mode: "demo", warnings };
|
|
377
438
|
}
|
|
439
|
+
if (persisted?.mode === "local_logs") {
|
|
440
|
+
warnings.push("Ignored persisted local-log cache because no current Claude Code/Codex source records were found. Re-run the local agent activity first; repository state alone cannot authorize an Apply action.");
|
|
441
|
+
}
|
|
378
442
|
// loadSampleUsageData resolves the bundled CSVs relative to the installed
|
|
379
443
|
// package, so this works from ANY directory (true zero-config).
|
|
380
444
|
return { records: await loadSampleUsageData(), mode: "demo", warnings };
|
|
@@ -388,10 +452,18 @@ function dataModeBanner(mode) {
|
|
|
388
452
|
return "DATA MODE: demo sample (illustrative — not your real spend)";
|
|
389
453
|
}
|
|
390
454
|
async function doctorCommand(args) {
|
|
455
|
+
if (args.sources) {
|
|
456
|
+
return doctorSourcesCommand(args);
|
|
457
|
+
}
|
|
391
458
|
const rootPath = resolve(args.path);
|
|
392
459
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
393
460
|
const persisted = await readPersistedSpend(rootPath);
|
|
394
|
-
const
|
|
461
|
+
const connectedStateTrusted = persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true;
|
|
462
|
+
const stateMode = persisted
|
|
463
|
+
? persisted.mode === "connected_provider" && !connectedStateTrusted
|
|
464
|
+
? "connected_provider (UNTRUSTED — ignored)"
|
|
465
|
+
: (persisted.mode ?? "unknown legacy")
|
|
466
|
+
: "no state";
|
|
395
467
|
const logs = await loadLocalAgentUsage({
|
|
396
468
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
397
469
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
@@ -414,14 +486,17 @@ async function doctorCommand(args) {
|
|
|
414
486
|
: "none detected (use --plan to declare one)";
|
|
415
487
|
const warnings = [];
|
|
416
488
|
if (stateMode === "sample")
|
|
417
|
-
warnings.push("sample state present — it will be shown as DEMO and cannot mask real logs; run `
|
|
489
|
+
warnings.push("sample state present — it will be shown as DEMO and cannot mask real logs; run `npx aibill reset` to clear it");
|
|
418
490
|
if (stateMode === "unknown legacy")
|
|
419
|
-
warnings.push("legacy state with no data-mode tag — run `
|
|
491
|
+
warnings.push("legacy state with no data-mode tag — run `npx aibill reset`, then re-scan");
|
|
492
|
+
if (persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === false) {
|
|
493
|
+
warnings.push(`${persisted.connectedTrust.message} Run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command.`);
|
|
494
|
+
}
|
|
420
495
|
if (!hasLogs)
|
|
421
496
|
warnings.push("no real Claude Code / Codex logs found — a first run here will show DEMO sample data");
|
|
422
497
|
if (providerRefs.length === 0)
|
|
423
498
|
warnings.push("no provider admin keys detected — connect OpenAI/Anthropic to add official provider-reported cost (local logs stay API-equivalent estimates)");
|
|
424
|
-
const predictedMode =
|
|
499
|
+
const predictedMode = connectedStateTrusted
|
|
425
500
|
? "connected provider billing"
|
|
426
501
|
: hasLogs
|
|
427
502
|
? "your local agent logs (estimated at API-equivalent rates)"
|
|
@@ -445,6 +520,428 @@ async function doctorCommand(args) {
|
|
|
445
520
|
];
|
|
446
521
|
return ok(lines.join("\n"));
|
|
447
522
|
}
|
|
523
|
+
const providerStatusIds = ["openai", "anthropic", "cursor", "github-copilot"];
|
|
524
|
+
/**
|
|
525
|
+
* Show connector validation maturity and this machine's financial evidence as
|
|
526
|
+
* separate axes. This command reads local state only; it never contacts a
|
|
527
|
+
* provider or treats a connector capability claim as current financial data.
|
|
528
|
+
*/
|
|
529
|
+
async function doctorSourcesCommand(args) {
|
|
530
|
+
const rootPath = resolve(args.path);
|
|
531
|
+
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
532
|
+
const now = new Date();
|
|
533
|
+
const observations = [];
|
|
534
|
+
let localLogs;
|
|
535
|
+
let localError;
|
|
536
|
+
try {
|
|
537
|
+
localLogs = await loadLocalAgentUsage({
|
|
538
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
539
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
localError = sanitizeSecretishError(error instanceof Error ? error.message : String(error));
|
|
544
|
+
}
|
|
545
|
+
const checkedAt = now.toISOString();
|
|
546
|
+
for (const id of ["claude-code", "codex"]) {
|
|
547
|
+
const records = (localLogs?.records ?? []).filter((record) => record.agentId === id);
|
|
548
|
+
const evidence = financialEvidenceForRecords(records);
|
|
549
|
+
const scan = localLogs?.sourceScans.find((entry) => entry.agent === id);
|
|
550
|
+
const diagnostics = (localLogs?.diagnostics ?? []).filter((entry) => entry.agent === id);
|
|
551
|
+
const diagnosticError = localAgentDiagnosticSummary(diagnostics);
|
|
552
|
+
const validationFailed = Boolean(localError) || localDiagnosticsRequireFailure(records, scan, diagnostics);
|
|
553
|
+
const lastError = localError ?? diagnosticError;
|
|
554
|
+
observations.push({
|
|
555
|
+
id,
|
|
556
|
+
financialEvidence: evidence,
|
|
557
|
+
financialEvidenceNote: localFinancialEvidenceNote(records, evidence, scan),
|
|
558
|
+
checkedAt,
|
|
559
|
+
latestEvidenceAt: latestRecordTimestamp(records),
|
|
560
|
+
...(lastError ? { lastError } : {}),
|
|
561
|
+
...(validationFailed ? { validationCoverage: "failed" } : {})
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
let providerState = {};
|
|
565
|
+
let providerStateError;
|
|
566
|
+
const persistedSpend = await readPersistedSpend(rootPath);
|
|
567
|
+
if (persistedSpend?.mode === "connected_provider" && persistedSpend.connectedTrust?.trusted === true) {
|
|
568
|
+
providerState = {
|
|
569
|
+
records: persistedSpend.records,
|
|
570
|
+
qaByProvider: trustedAccountingMap(persistedSpend.accounting, "qaByProvider")
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
else if (persistedSpend?.mode === "connected_provider" && persistedSpend.connectedTrust?.trusted === false) {
|
|
574
|
+
providerStateError = `${persistedSpend.connectedTrust.message} Provider financial evidence was ignored.`;
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
try {
|
|
578
|
+
await readSafeStateText(stateDir, "provider-records.json");
|
|
579
|
+
providerStateError = "provider records have no matching trusted connected spend receipt; financial evidence was ignored";
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
583
|
+
providerStateError = "provider state could not be safely read; financial evidence was ignored";
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
588
|
+
let attemptState = { version: 1, providers: {} };
|
|
589
|
+
let attemptStateError;
|
|
590
|
+
try {
|
|
591
|
+
const parsed = parsePersistedSourceAttemptState(await readJson(join(stateDir, "source-status.json")));
|
|
592
|
+
attemptState = parsed.state;
|
|
593
|
+
attemptStateError = parsed.error;
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
if (!isNodeError(error, "ENOENT")) {
|
|
597
|
+
attemptStateError = "source attempt state could not be parsed; recorded freshness and errors were ignored";
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const providerRecords = Array.isArray(providerState.records) ? providerState.records : [];
|
|
601
|
+
for (const id of providerStatusIds) {
|
|
602
|
+
const records = providerRecords.filter((record) => record.source?.provider === id);
|
|
603
|
+
const evidence = financialEvidenceForRecords(records);
|
|
604
|
+
const qa = providerState.qaByProvider?.[id]
|
|
605
|
+
?? (providerState.provider === id ? providerState.qa : undefined);
|
|
606
|
+
const attempt = attemptState.providers?.[id];
|
|
607
|
+
const lastError = providerStateError
|
|
608
|
+
?? attemptStateError
|
|
609
|
+
?? sanitizePersistedStatusText(attempt?.lastError ?? undefined)
|
|
610
|
+
?? providerQaLastError(qa);
|
|
611
|
+
const registeredSource = (Array.isArray(registry.approvedSources) ? registry.approvedSources : [])
|
|
612
|
+
.filter((source) => source && source.provider === id && typeof source.approvedAt === "string")
|
|
613
|
+
.sort((left, right) => right.approvedAt.localeCompare(left.approvedAt))[0];
|
|
614
|
+
const stateCheckedAt = attempt?.checkedAt
|
|
615
|
+
?? (providerState.provider === id ? providerState.fetchedAt : undefined);
|
|
616
|
+
observations.push({
|
|
617
|
+
id,
|
|
618
|
+
financialEvidence: evidence,
|
|
619
|
+
financialEvidenceNote: providerFinancialEvidenceNote(records, evidence),
|
|
620
|
+
// A connector stub is only configuration, not a source check. Older
|
|
621
|
+
// successful syncs predate source-status.json, so their non-missing
|
|
622
|
+
// registry approval time is the conservative migration fallback.
|
|
623
|
+
checkedAt: stateCheckedAt ?? (registeredSource?.financialEvidence !== "missing" ? registeredSource?.approvedAt : undefined),
|
|
624
|
+
latestEvidenceAt: latestRecordTimestamp(records),
|
|
625
|
+
...(lastError ? { lastError, validationCoverage: "failed" } : {})
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const statuses = buildSourceStatuses(observations, now);
|
|
629
|
+
return ok([
|
|
630
|
+
"aibill doctor --sources",
|
|
631
|
+
"local status only: no provider was contacted",
|
|
632
|
+
"status axes (never interchangeable):",
|
|
633
|
+
" validation coverage: live_verified | fixture_verified | untested | failed",
|
|
634
|
+
" financial evidence: verified | estimated | detected_unverified | missing",
|
|
635
|
+
"",
|
|
636
|
+
formatSourceStatuses(statuses)
|
|
637
|
+
].join("\n"));
|
|
638
|
+
}
|
|
639
|
+
function localFinancialEvidenceNote(records, evidence, scan) {
|
|
640
|
+
if (records.length === 0) {
|
|
641
|
+
if (!scan)
|
|
642
|
+
return "The local transcript scan did not complete.";
|
|
643
|
+
if (scan.directoryStatus === "missing") {
|
|
644
|
+
return "No local transcript directory was found for this agent; no usage evidence was available.";
|
|
645
|
+
}
|
|
646
|
+
if (scan.directoryStatus === "unreadable") {
|
|
647
|
+
return "The local transcript path could not be read; absence of usage cannot be confirmed.";
|
|
648
|
+
}
|
|
649
|
+
if (scan.filesDiscovered === 0) {
|
|
650
|
+
return "The local transcript directory was readable, but no JSONL files were found.";
|
|
651
|
+
}
|
|
652
|
+
if (scan.unreadableFiles > 0) {
|
|
653
|
+
return `${scan.filesDiscovered} transcript file(s) were found, but ${scan.unreadableFiles} could not be read; absence of usage cannot be confirmed.`;
|
|
654
|
+
}
|
|
655
|
+
if (scan.malformedLines > 0) {
|
|
656
|
+
return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed JSONL line(s) were skipped.`;
|
|
657
|
+
}
|
|
658
|
+
return `${scan.filesDiscovered} transcript file(s) were found, but no supported usage rows were observed.`;
|
|
659
|
+
}
|
|
660
|
+
if (evidence === "estimated") {
|
|
661
|
+
const estimatedRows = records.filter((record) => (record.costConfidence === "estimated" && typeof record.amountUsd === "number")).length;
|
|
662
|
+
const missingRows = records.length - estimatedRows;
|
|
663
|
+
if (missingRows > 0 || (scan?.unsupportedUsageSnapshots ?? 0) > 0) {
|
|
664
|
+
const unsupportedCount = scan?.unsupportedUsageSnapshots ?? 0;
|
|
665
|
+
const unsupported = unsupportedCount > 0
|
|
666
|
+
? `; ${unsupportedCount} token snapshot(s) lacked input/output components and were not priced`
|
|
667
|
+
: "";
|
|
668
|
+
const otherMissing = Math.max(0, missingRows - unsupportedCount);
|
|
669
|
+
const unpricedModels = otherMissing > 0
|
|
670
|
+
? `; ${otherMissing} other row(s) lacked a supported model price`
|
|
671
|
+
: "";
|
|
672
|
+
return `${estimatedRows} of ${records.length} local aggregate row(s) were priced at published API rates${unsupported}${unpricedModels}; missing rows are excluded, and estimates are not billed subscription spend.`;
|
|
673
|
+
}
|
|
674
|
+
return `${records.length} local aggregate row(s) priced at published API rates; this is not billed subscription spend.`;
|
|
675
|
+
}
|
|
676
|
+
if (evidence === "missing") {
|
|
677
|
+
if ((scan?.unsupportedUsageSnapshots ?? 0) > 0) {
|
|
678
|
+
return `${records.length} local aggregate row(s) were observed, but ${scan.unsupportedUsageSnapshots} token snapshot(s) lacked input/output components required for pricing.`;
|
|
679
|
+
}
|
|
680
|
+
return `${records.length} local aggregate row(s) were observed, but no supported price basis was available.`;
|
|
681
|
+
}
|
|
682
|
+
return `${records.length} local aggregate row(s) were observed with ${evidence} financial evidence.`;
|
|
683
|
+
}
|
|
684
|
+
function localAgentDiagnosticSummary(diagnostics) {
|
|
685
|
+
const relevant = diagnostics.filter((diagnostic) => diagnostic.code !== "directory_missing");
|
|
686
|
+
const unsupported = relevant.filter((diagnostic) => diagnostic.code === "unsupported_token_shape");
|
|
687
|
+
const malformed = relevant.filter((diagnostic) => diagnostic.code === "malformed_jsonl");
|
|
688
|
+
const messages = [...new Set(relevant
|
|
689
|
+
.filter((diagnostic) => !["unsupported_token_shape", "malformed_jsonl"].includes(diagnostic.code))
|
|
690
|
+
.map((diagnostic) => diagnostic.message))];
|
|
691
|
+
const unsupportedCount = unsupported
|
|
692
|
+
.reduce((total, diagnostic) => total + diagnostic.count, 0);
|
|
693
|
+
if (unsupportedCount > 0) {
|
|
694
|
+
messages.push(`${unsupportedCount} ${unsupported[0].agent === "codex" ? "Codex" : "Claude Code"} token snapshot(s) lacked the input/output components required for pricing.`);
|
|
695
|
+
}
|
|
696
|
+
const malformedCount = malformed
|
|
697
|
+
.reduce((total, diagnostic) => total + diagnostic.count, 0);
|
|
698
|
+
if (malformedCount > 0) {
|
|
699
|
+
messages.push(`${malformedCount} malformed JSONL line(s) were skipped in ${malformed[0].agent === "codex" ? "Codex" : "Claude Code"} transcripts.`);
|
|
700
|
+
}
|
|
701
|
+
return messages.length > 0 ? messages.join(" ") : undefined;
|
|
702
|
+
}
|
|
703
|
+
function localDiagnosticsRequireFailure(records, scan, diagnostics) {
|
|
704
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error"))
|
|
705
|
+
return true;
|
|
706
|
+
// A partially malformed active JSONL can still yield supported evidence.
|
|
707
|
+
// If nothing valid survived, however, an empty result is not trustworthy.
|
|
708
|
+
return records.length === 0 && (scan?.malformedLines ?? 0) > 0;
|
|
709
|
+
}
|
|
710
|
+
function providerFinancialEvidenceNote(records, evidence) {
|
|
711
|
+
if (records.length === 0)
|
|
712
|
+
return "No provider financial evidence is present in local aibill state.";
|
|
713
|
+
const verifiedRows = records.filter((record) => (record.costConfidence === "verified" && typeof record.amountUsd === "number")).length;
|
|
714
|
+
const estimatedRows = records.filter((record) => (record.costConfidence === "estimated" && typeof record.amountUsd === "number")).length;
|
|
715
|
+
const detectedRows = records.filter((record) => (record.costConfidence === "detected_unverified" && typeof record.amountUsd === "number")).length;
|
|
716
|
+
const missingRows = records.length - verifiedRows - estimatedRows - detectedRows;
|
|
717
|
+
// Keep the concise homogeneous messages, but never let a single verified
|
|
718
|
+
// row promote every mixed provider row to official billed cost.
|
|
719
|
+
if (evidence === "verified" && verifiedRows === records.length) {
|
|
720
|
+
return `${records.length} provider row(s) include official provider-reported cost.`;
|
|
721
|
+
}
|
|
722
|
+
if (evidence === "estimated" && estimatedRows === records.length) {
|
|
723
|
+
return `${records.length} provider row(s) include estimated cost; reconcile before treating it as billed spend.`;
|
|
724
|
+
}
|
|
725
|
+
if (evidence === "detected_unverified" && detectedRows === records.length) {
|
|
726
|
+
return `${records.length} provider row(s) were detected with partial or unreconciled financial coverage.`;
|
|
727
|
+
}
|
|
728
|
+
if (evidence === "missing" && missingRows === records.length) {
|
|
729
|
+
return `${records.length} provider row(s) were observed without a supported cost basis.`;
|
|
730
|
+
}
|
|
731
|
+
const parts = [];
|
|
732
|
+
if (verifiedRows > 0) {
|
|
733
|
+
parts.push(`${verifiedRows} of ${records.length} provider row(s) include official provider-reported cost`);
|
|
734
|
+
}
|
|
735
|
+
if (estimatedRows > 0) {
|
|
736
|
+
parts.push(`${estimatedRows} provider row(s) include estimated cost`);
|
|
737
|
+
}
|
|
738
|
+
if (detectedRows > 0) {
|
|
739
|
+
parts.push(`${detectedRows} provider row(s) have partial or unreconciled financial coverage`);
|
|
740
|
+
}
|
|
741
|
+
if (missingRows > 0) {
|
|
742
|
+
parts.push(`${missingRows} provider row(s) have no supported cost basis`);
|
|
743
|
+
}
|
|
744
|
+
return `${parts.join("; ")}. Row-level financial evidence remains separate.`;
|
|
745
|
+
}
|
|
746
|
+
function latestRecordTimestamp(records) {
|
|
747
|
+
return records
|
|
748
|
+
.map((record) => record.timestamp)
|
|
749
|
+
.filter((timestamp) => Number.isFinite(Date.parse(timestamp)))
|
|
750
|
+
.sort((left, right) => right.localeCompare(left))[0];
|
|
751
|
+
}
|
|
752
|
+
function providerQaLastError(qa) {
|
|
753
|
+
const incompletePage = qa?.pagination.find((entry) => entry.stoppedBecause !== "complete");
|
|
754
|
+
if (incompletePage) {
|
|
755
|
+
const fallback = incompletePage.stoppedBecause === "fetch_error"
|
|
756
|
+
? "provider fetch failed"
|
|
757
|
+
: incompletePage.stoppedBecause === "max_pages"
|
|
758
|
+
? "pagination stopped at the connector page safety cap"
|
|
759
|
+
: incompletePage.stoppedBecause === "max_range_days"
|
|
760
|
+
? "requested range exceeded the connector coverage cap"
|
|
761
|
+
: incompletePage.stoppedBecause === "unsafe_next_link"
|
|
762
|
+
? "an unsafe pagination link was rejected"
|
|
763
|
+
: "pagination ended before the provider marked it complete";
|
|
764
|
+
return sanitizePersistedStatusText(incompletePage.note
|
|
765
|
+
? `${incompletePage.label}: ${incompletePage.note}`
|
|
766
|
+
: `${incompletePage.label}: ${fallback}`);
|
|
767
|
+
}
|
|
768
|
+
const drift = qa?.responseDrift[0];
|
|
769
|
+
if (drift) {
|
|
770
|
+
return sanitizePersistedStatusText(`${drift.label}: ${drift.field} ${drift.issue}`);
|
|
771
|
+
}
|
|
772
|
+
if (qa?.coverage === "partial") {
|
|
773
|
+
return sanitizePersistedStatusText(`${qa.provider}: provider returned partial coverage`);
|
|
774
|
+
}
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
function parsePersistedProviderStatusState(value) {
|
|
778
|
+
if (!isPlainObject(value)) {
|
|
779
|
+
return { state: {}, error: "provider state has an invalid shape; financial evidence was ignored" };
|
|
780
|
+
}
|
|
781
|
+
if (value.provider !== undefined && typeof value.provider !== "string") {
|
|
782
|
+
return { state: {}, error: "provider state has an invalid provider id; financial evidence was ignored" };
|
|
783
|
+
}
|
|
784
|
+
if (value.fetchedAt !== undefined && !validIsoString(value.fetchedAt)) {
|
|
785
|
+
return { state: {}, error: "provider state has an invalid freshness timestamp; financial evidence was ignored" };
|
|
786
|
+
}
|
|
787
|
+
if (value.records !== undefined && !Array.isArray(value.records)) {
|
|
788
|
+
return { state: {}, error: "provider state has invalid financial records; financial evidence was ignored" };
|
|
789
|
+
}
|
|
790
|
+
let records = [];
|
|
791
|
+
try {
|
|
792
|
+
records = (value.records ?? []).map((record) => parseUsageRecord(record));
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return { state: {}, error: "provider state has invalid financial records; financial evidence was ignored" };
|
|
796
|
+
}
|
|
797
|
+
const qa = value.qa === undefined ? undefined : parsePersistedProviderQa(value.qa);
|
|
798
|
+
if (value.qa !== undefined && !qa) {
|
|
799
|
+
return { state: {}, error: "provider state has invalid QA metadata; financial evidence was ignored" };
|
|
800
|
+
}
|
|
801
|
+
const qaByProvider = {};
|
|
802
|
+
if (value.qaByProvider !== undefined) {
|
|
803
|
+
if (!isPlainObject(value.qaByProvider)) {
|
|
804
|
+
return { state: {}, error: "provider state has invalid QA metadata; financial evidence was ignored" };
|
|
805
|
+
}
|
|
806
|
+
for (const [provider, rawQa] of Object.entries(value.qaByProvider)) {
|
|
807
|
+
// Unknown providers are forward-compatible but irrelevant to this
|
|
808
|
+
// four-provider doctor view. Known providers must pass the full shape.
|
|
809
|
+
if (!isProviderStatusId(provider))
|
|
810
|
+
continue;
|
|
811
|
+
const parsedQa = parsePersistedProviderQa(rawQa);
|
|
812
|
+
if (!parsedQa) {
|
|
813
|
+
return { state: {}, error: "provider state has invalid QA metadata; financial evidence was ignored" };
|
|
814
|
+
}
|
|
815
|
+
qaByProvider[provider] = parsedQa;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
state: {
|
|
820
|
+
...(typeof value.provider === "string" ? { provider: value.provider } : {}),
|
|
821
|
+
...(typeof value.fetchedAt === "string" ? { fetchedAt: value.fetchedAt } : {}),
|
|
822
|
+
records,
|
|
823
|
+
...(qa ? { qa } : {}),
|
|
824
|
+
...(Object.keys(qaByProvider).length > 0 ? { qaByProvider } : {})
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function parsePersistedSourceAttemptState(value) {
|
|
829
|
+
const empty = { version: 1, providers: {} };
|
|
830
|
+
if (!isPlainObject(value) || value.version !== 1 || !isPlainObject(value.providers)) {
|
|
831
|
+
return { state: empty, error: "source attempt state has an invalid shape; recorded freshness and errors were ignored" };
|
|
832
|
+
}
|
|
833
|
+
const providers = {};
|
|
834
|
+
for (const [provider, rawAttempt] of Object.entries(value.providers)) {
|
|
835
|
+
if (!isProviderStatusId(provider) || !isPlainObject(rawAttempt) || !validIsoString(rawAttempt.checkedAt)) {
|
|
836
|
+
return { state: empty, error: "source attempt state has an invalid provider or timestamp; recorded freshness and errors were ignored" };
|
|
837
|
+
}
|
|
838
|
+
if (rawAttempt.lastError !== null && typeof rawAttempt.lastError !== "string") {
|
|
839
|
+
return { state: empty, error: "source attempt state has an invalid error field; recorded freshness and errors were ignored" };
|
|
840
|
+
}
|
|
841
|
+
providers[provider] = {
|
|
842
|
+
checkedAt: rawAttempt.checkedAt,
|
|
843
|
+
lastError: rawAttempt.lastError === null
|
|
844
|
+
? null
|
|
845
|
+
: (sanitizePersistedStatusText(rawAttempt.lastError) ?? "invalid empty provider error")
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
return { state: { version: 1, providers } };
|
|
849
|
+
}
|
|
850
|
+
function parsePersistedProviderQa(value) {
|
|
851
|
+
if (!isPlainObject(value) || typeof value.provider !== "string")
|
|
852
|
+
return undefined;
|
|
853
|
+
if (value.coverage !== undefined && value.coverage !== "complete" && value.coverage !== "partial")
|
|
854
|
+
return undefined;
|
|
855
|
+
if (!isStringArray(value.requestedEndpoints) || !Array.isArray(value.pagination) ||
|
|
856
|
+
!Array.isArray(value.rateLimits) || !Array.isArray(value.responseDrift) ||
|
|
857
|
+
!isStringArray(value.instructions)) {
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
const pagination = [];
|
|
861
|
+
for (const entry of value.pagination) {
|
|
862
|
+
if (!isPlainObject(entry) || typeof entry.label !== "string" ||
|
|
863
|
+
!isFiniteNumber(entry.pagesFetched) || !isFiniteNumber(entry.maxPages) ||
|
|
864
|
+
!isProviderPaginationStop(entry.stoppedBecause) ||
|
|
865
|
+
(entry.limitPerPage !== undefined && !isFiniteNumber(entry.limitPerPage)) ||
|
|
866
|
+
(entry.note !== undefined && typeof entry.note !== "string")) {
|
|
867
|
+
return undefined;
|
|
868
|
+
}
|
|
869
|
+
pagination.push({
|
|
870
|
+
label: entry.label,
|
|
871
|
+
pagesFetched: entry.pagesFetched,
|
|
872
|
+
stoppedBecause: entry.stoppedBecause,
|
|
873
|
+
maxPages: entry.maxPages,
|
|
874
|
+
...(typeof entry.limitPerPage === "number" ? { limitPerPage: entry.limitPerPage } : {}),
|
|
875
|
+
...(typeof entry.note === "string" ? { note: sanitizePersistedStatusText(entry.note) ?? "empty provider error" } : {})
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
const rateLimits = [];
|
|
879
|
+
for (const entry of value.rateLimits) {
|
|
880
|
+
if (!isPlainObject(entry) || typeof entry.label !== "string" ||
|
|
881
|
+
(entry.remainingRequests !== undefined && !isFiniteNumber(entry.remainingRequests)) ||
|
|
882
|
+
(entry.retryAfterSeconds !== undefined && !isFiniteNumber(entry.retryAfterSeconds))) {
|
|
883
|
+
return undefined;
|
|
884
|
+
}
|
|
885
|
+
rateLimits.push({
|
|
886
|
+
label: entry.label,
|
|
887
|
+
...(typeof entry.remainingRequests === "number" ? { remainingRequests: entry.remainingRequests } : {}),
|
|
888
|
+
...(typeof entry.retryAfterSeconds === "number" ? { retryAfterSeconds: entry.retryAfterSeconds } : {})
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
const responseDrift = [];
|
|
892
|
+
for (const entry of value.responseDrift) {
|
|
893
|
+
if (!isPlainObject(entry) || typeof entry.label !== "string" ||
|
|
894
|
+
typeof entry.field !== "string" || typeof entry.issue !== "string") {
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
responseDrift.push({
|
|
898
|
+
label: entry.label,
|
|
899
|
+
field: entry.field,
|
|
900
|
+
issue: entry.issue
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
provider: value.provider,
|
|
905
|
+
...(value.coverage === "complete" || value.coverage === "partial" ? { coverage: value.coverage } : {}),
|
|
906
|
+
requestedEndpoints: value.requestedEndpoints,
|
|
907
|
+
pagination,
|
|
908
|
+
rateLimits,
|
|
909
|
+
responseDrift,
|
|
910
|
+
instructions: value.instructions
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function sanitizePersistedStatusText(value) {
|
|
914
|
+
if (!value)
|
|
915
|
+
return undefined;
|
|
916
|
+
const sanitized = sanitizeSecretishError(value)
|
|
917
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
918
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
919
|
+
.replace(/\s+/g, " ")
|
|
920
|
+
.trim()
|
|
921
|
+
.slice(0, 500);
|
|
922
|
+
return sanitized || undefined;
|
|
923
|
+
}
|
|
924
|
+
function isProviderStatusId(value) {
|
|
925
|
+
return providerStatusIds.includes(value);
|
|
926
|
+
}
|
|
927
|
+
function isProviderPaginationStop(value) {
|
|
928
|
+
return value === "complete" || value === "missing_cursor" || value === "max_pages" ||
|
|
929
|
+
value === "max_range_days" || value === "fetch_error" || value === "unsafe_next_link";
|
|
930
|
+
}
|
|
931
|
+
function validIsoString(value) {
|
|
932
|
+
return typeof value === "string" &&
|
|
933
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) &&
|
|
934
|
+
Number.isFinite(Date.parse(value));
|
|
935
|
+
}
|
|
936
|
+
function isStringArray(value) {
|
|
937
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
938
|
+
}
|
|
939
|
+
function isFiniteNumber(value) {
|
|
940
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
941
|
+
}
|
|
942
|
+
function isPlainObject(value) {
|
|
943
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
944
|
+
}
|
|
448
945
|
async function cliVersion() {
|
|
449
946
|
try {
|
|
450
947
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -457,6 +954,9 @@ async function cliVersion() {
|
|
|
457
954
|
}
|
|
458
955
|
async function resetCommand(args) {
|
|
459
956
|
const rootPath = await resolveSafeScanRoot(args.path);
|
|
957
|
+
// The trust receipt is deliberately outside the repository. Reset must
|
|
958
|
+
// clear it too so restoring an old spend.json cannot replay prior trust.
|
|
959
|
+
await invalidateConnectedSpendTrustReceipt(rootPath);
|
|
460
960
|
let stateDir;
|
|
461
961
|
try {
|
|
462
962
|
stateDir = await resolveSafeStateDirectory(rootPath);
|
|
@@ -473,7 +973,7 @@ async function resetCommand(args) {
|
|
|
473
973
|
}
|
|
474
974
|
// Clear derived spend state so a prior `scan --sample` (or stale provider
|
|
475
975
|
// sync) can never mask the next real local-log read. Leaves sources/audit.
|
|
476
|
-
const targets = ["spend.json", "mappings.json", "provider-records.json", "watch-latest.json", "watch-history.json"];
|
|
976
|
+
const targets = ["spend.json", "mappings.json", "provider-records.json", "source-status.json", "watch-latest.json", "watch-history.json"];
|
|
477
977
|
const removed = [];
|
|
478
978
|
for (const file of targets) {
|
|
479
979
|
try {
|
|
@@ -505,9 +1005,9 @@ async function initCommand(args) {
|
|
|
505
1005
|
sourceRegistry: "sources.json",
|
|
506
1006
|
auditLog: "audit-log.json",
|
|
507
1007
|
nextCommands: [
|
|
508
|
-
"
|
|
509
|
-
`
|
|
510
|
-
`
|
|
1008
|
+
"npx aibill doctor",
|
|
1009
|
+
`npx aibill scan --sample --path ${rootPath}`,
|
|
1010
|
+
`npx aibill report --sample --out ai-spend-report --path ${rootPath}`
|
|
511
1011
|
]
|
|
512
1012
|
});
|
|
513
1013
|
await writeJson(join(stateDir, "sources.json"), registry);
|
|
@@ -527,7 +1027,7 @@ async function initCommand(args) {
|
|
|
527
1027
|
"cloud upload: disabled",
|
|
528
1028
|
"cron jobs: disabled in V0 demo",
|
|
529
1029
|
`state directory: ${stateDir}`,
|
|
530
|
-
`next:
|
|
1030
|
+
`next: npx aibill scan --sample --path ${rootPath}`
|
|
531
1031
|
].join("\n"));
|
|
532
1032
|
}
|
|
533
1033
|
async function scanCommand(args) {
|
|
@@ -695,9 +1195,14 @@ async function runWatchCycle(stateDir, args) {
|
|
|
695
1195
|
mode = "sample";
|
|
696
1196
|
}
|
|
697
1197
|
else {
|
|
698
|
-
const
|
|
699
|
-
if (
|
|
700
|
-
|
|
1198
|
+
const persisted = await readPersistedSpend(dirname(stateDir));
|
|
1199
|
+
if (persisted?.mode === "connected_provider" &&
|
|
1200
|
+
persisted.connectedTrust?.trusted === true &&
|
|
1201
|
+
persisted.records.length > 0) {
|
|
1202
|
+
// Watch may observe an already trusted provider snapshot, but it may not
|
|
1203
|
+
// mint trust from repository-authored provider-records.json or rewrite
|
|
1204
|
+
// connected state. Only an explicit provider sync can do that.
|
|
1205
|
+
records = persisted.records;
|
|
701
1206
|
mode = "connected_provider";
|
|
702
1207
|
}
|
|
703
1208
|
else {
|
|
@@ -722,7 +1227,9 @@ async function runWatchCycle(stateDir, args) {
|
|
|
722
1227
|
: records;
|
|
723
1228
|
const summary = analyzeSpend(headlineRecords);
|
|
724
1229
|
const mappings = attributeUsageRecords(records);
|
|
725
|
-
|
|
1230
|
+
if (mode !== "connected_provider") {
|
|
1231
|
+
await writeLocalSpendState(stateDir, records, summary, mappings, mode);
|
|
1232
|
+
}
|
|
726
1233
|
const snapshot = {
|
|
727
1234
|
capturedAt: new Date().toISOString(),
|
|
728
1235
|
totalUsd: summary.totalUsd,
|
|
@@ -798,6 +1305,7 @@ async function addSourceCommand(args) {
|
|
|
798
1305
|
path: sourcePath,
|
|
799
1306
|
provider: args.provider
|
|
800
1307
|
});
|
|
1308
|
+
const addedSource = nextRegistry.approvedSources.find((source) => source.id === id);
|
|
801
1309
|
await writeJson(join(stateDir, "sources.json"), nextRegistry);
|
|
802
1310
|
await appendAuditEvent(stateDir, {
|
|
803
1311
|
timestamp: nextRegistry.updatedAt,
|
|
@@ -812,7 +1320,10 @@ async function addSourceCommand(args) {
|
|
|
812
1320
|
`type: ${args.sourceType}`,
|
|
813
1321
|
`path: ${sourcePath}`,
|
|
814
1322
|
`provider: ${args.provider ?? "unknown"}`,
|
|
815
|
-
"read-only: true"
|
|
1323
|
+
"read-only: true",
|
|
1324
|
+
`boundary approval: ${addedSource.boundaryApproval}`,
|
|
1325
|
+
`validation coverage: ${addedSource.validationCoverage}`,
|
|
1326
|
+
`financial evidence: ${addedSource.financialEvidence}`
|
|
816
1327
|
].join("\n"));
|
|
817
1328
|
}
|
|
818
1329
|
async function listSourcesCommand(args) {
|
|
@@ -824,7 +1335,7 @@ async function listSourcesCommand(args) {
|
|
|
824
1335
|
`approved sources: ${registry.approvedSources.length}`
|
|
825
1336
|
];
|
|
826
1337
|
for (const source of registry.approvedSources) {
|
|
827
|
-
lines.push(`- ${source.id} | ${source.type} | ${source.label} | ${source.provider ?? "unknown"} | ${source.path ?? "no path"}`);
|
|
1338
|
+
lines.push(`- ${source.id} | ${source.type} | ${source.label} | ${source.provider ?? "unknown"} | ${source.path ?? "no path"}`, ` boundary approval: ${source.boundaryApproval}`, ` validation coverage: ${source.validationCoverage}`, ` financial evidence: ${source.financialEvidence}`);
|
|
828
1339
|
}
|
|
829
1340
|
return ok(lines.join("\n"));
|
|
830
1341
|
}
|
|
@@ -854,11 +1365,11 @@ async function connectCommand(args) {
|
|
|
854
1365
|
stdout: "",
|
|
855
1366
|
stderr: [
|
|
856
1367
|
"connect requires a provider. Start with one you can self-serve in ~2 min:",
|
|
857
|
-
"
|
|
858
|
-
"
|
|
1368
|
+
" npx aibill connect openai (org-owner Admin credential reference)",
|
|
1369
|
+
" npx aibill connect anthropic (Admin credential reference)",
|
|
859
1370
|
"Team/billing-admin upgrades:",
|
|
860
|
-
"
|
|
861
|
-
"
|
|
1371
|
+
" npx aibill connect cursor (Cursor team-admin credential reference)",
|
|
1372
|
+
" npx aibill connect github-copilot (GitHub billing-admin credential reference)"
|
|
862
1373
|
].join("\n")
|
|
863
1374
|
};
|
|
864
1375
|
}
|
|
@@ -886,7 +1397,9 @@ async function connectCommand(args) {
|
|
|
886
1397
|
`provider: ${provider}`,
|
|
887
1398
|
`type: ${type}`,
|
|
888
1399
|
`access method: ${source.accessMethod}`,
|
|
889
|
-
`
|
|
1400
|
+
`boundary approval: ${source.boundaryApproval}`,
|
|
1401
|
+
`validation coverage: ${source.validationCoverage}`,
|
|
1402
|
+
`financial evidence: ${source.financialEvidence}`,
|
|
890
1403
|
"secrets: no raw secrets stored; we only reference a local env var such as env:OPENAI_ADMIN_KEY"
|
|
891
1404
|
];
|
|
892
1405
|
if (selfServeProviders.has(provider)) {
|
|
@@ -901,19 +1414,19 @@ async function connectCommand(args) {
|
|
|
901
1414
|
lines.push(`auto-detected: a ${provider} key in ${detected.reference} (${detected.hint}) from ${describeOrigin(detected)}`);
|
|
902
1415
|
if (detected.isLikelyAdminKey) {
|
|
903
1416
|
const adminRef = providerAdminEnvHint[provider] ?? detected.reference;
|
|
904
|
-
lines.push(`next:
|
|
1417
|
+
lines.push(`next: npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
|
|
905
1418
|
}
|
|
906
1419
|
else {
|
|
907
1420
|
const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
|
|
908
1421
|
lines.push(`this looks like a regular key — for COST data set an admin key in ${adminRef}, then:`);
|
|
909
|
-
lines.push(`
|
|
1422
|
+
lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
|
|
910
1423
|
}
|
|
911
1424
|
}
|
|
912
1425
|
else {
|
|
913
1426
|
const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
|
|
914
1427
|
lines.push("");
|
|
915
1428
|
lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
|
|
916
|
-
lines.push(`
|
|
1429
|
+
lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
|
|
917
1430
|
}
|
|
918
1431
|
lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
|
|
919
1432
|
return ok(lines.join("\n"));
|
|
@@ -954,6 +1467,15 @@ async function syncProviderCommand(args) {
|
|
|
954
1467
|
};
|
|
955
1468
|
}
|
|
956
1469
|
try {
|
|
1470
|
+
// A provider sync may merge only a prior connected snapshot whose exact
|
|
1471
|
+
// repository bytes have a matching external machine receipt. A cloned
|
|
1472
|
+
// provider-records.json is never allowed to launder fake rows into a new
|
|
1473
|
+
// trusted multi-provider snapshot.
|
|
1474
|
+
const priorSpend = await readPersistedSpend(rootPath);
|
|
1475
|
+
const trustedPrior = priorSpend?.mode === "connected_provider" &&
|
|
1476
|
+
priorSpend.connectedTrust?.trusted === true
|
|
1477
|
+
? priorSpend
|
|
1478
|
+
: undefined;
|
|
957
1479
|
const result = await fetchProviderUsageRecords({
|
|
958
1480
|
provider: args.provider,
|
|
959
1481
|
sourceId: `${args.provider}-provider-api`,
|
|
@@ -964,9 +1486,8 @@ async function syncProviderCommand(args) {
|
|
|
964
1486
|
enterprise: args.enterprise,
|
|
965
1487
|
accountId: args.accountId
|
|
966
1488
|
});
|
|
967
|
-
const priorProviderState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
|
|
968
1489
|
const records = [
|
|
969
|
-
...
|
|
1490
|
+
...(trustedPrior?.records ?? []).filter((record) => record.source.provider !== result.provider),
|
|
970
1491
|
...result.records
|
|
971
1492
|
].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
972
1493
|
const registry = await readSourceRegistry(stateDir, rootPath);
|
|
@@ -975,17 +1496,21 @@ async function syncProviderCommand(args) {
|
|
|
975
1496
|
const summary = analyzeSpend(headlineRecords);
|
|
976
1497
|
const mappings = attributeUsageRecords(records);
|
|
977
1498
|
const qaByProvider = {
|
|
978
|
-
...(
|
|
1499
|
+
...trustedAccountingMap(trustedPrior?.accounting, "qaByProvider"),
|
|
979
1500
|
[result.provider]: result.qa
|
|
980
1501
|
};
|
|
981
1502
|
const coverageByProvider = {
|
|
982
|
-
...(
|
|
1503
|
+
...trustedAccountingMap(trustedPrior?.accounting, "coverageByProvider"),
|
|
983
1504
|
[result.provider]: result.coverage
|
|
984
1505
|
};
|
|
985
1506
|
const financialsByProvider = {
|
|
986
|
-
...(
|
|
1507
|
+
...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
|
|
987
1508
|
[result.provider]: result.financials
|
|
988
1509
|
};
|
|
1510
|
+
// Invalidate any earlier receipt before the first mutation. If a later
|
|
1511
|
+
// local write fails, the partially updated repository state stays
|
|
1512
|
+
// untrusted rather than inheriting the previous sync's authority.
|
|
1513
|
+
await invalidateConnectedSpendTrustReceipt(rootPath);
|
|
989
1514
|
await mkdir(stateDir, { recursive: true });
|
|
990
1515
|
await writeJson(join(stateDir, "sources.json"), nextRegistry);
|
|
991
1516
|
await writeJson(join(stateDir, "provider-records.json"), {
|
|
@@ -1001,10 +1526,14 @@ async function syncProviderCommand(args) {
|
|
|
1001
1526
|
coverageByProvider,
|
|
1002
1527
|
financialsByProvider
|
|
1003
1528
|
});
|
|
1529
|
+
await recordProviderSourceAttempt(stateDir, result.provider, result.fetchedAt, result.coverage === "partial"
|
|
1530
|
+
? (providerQaLastError(result.qa) ?? `${result.provider}: provider returned partial coverage`)
|
|
1531
|
+
: null);
|
|
1004
1532
|
await writeLocalSpendState(stateDir, records, summary, mappings, "connected_provider", {
|
|
1005
1533
|
policy: "provider_reported_billed_cost_preferred",
|
|
1006
1534
|
note: "Official provider-reported billed costs are the spend headline. API-equivalent estimates remain separate evidence and are not added to that total.",
|
|
1007
1535
|
coverageByProvider,
|
|
1536
|
+
qaByProvider,
|
|
1008
1537
|
financialsByProvider
|
|
1009
1538
|
});
|
|
1010
1539
|
await appendAuditEvent(stateDir, {
|
|
@@ -1013,16 +1542,19 @@ async function syncProviderCommand(args) {
|
|
|
1013
1542
|
sourceId: result.source.id,
|
|
1014
1543
|
detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
|
|
1015
1544
|
});
|
|
1545
|
+
await writeConnectedSpendTrustReceipt(rootPath, await readSafeStateText(stateDir, "spend.json"), { sourceRegistryContents: await readSafeStateText(stateDir, "sources.json") });
|
|
1016
1546
|
return ok([
|
|
1017
1547
|
"aibill sync-provider",
|
|
1018
1548
|
`provider: ${result.provider}`,
|
|
1019
1549
|
`source: ${result.source.id}`,
|
|
1020
|
-
`
|
|
1550
|
+
`boundary approval: ${result.source.boundaryApproval}`,
|
|
1551
|
+
`validation coverage: ${result.source.validationCoverage}`,
|
|
1552
|
+
`financial evidence: ${result.source.financialEvidence}`,
|
|
1021
1553
|
`coverage: ${result.coverage}`,
|
|
1022
1554
|
`records fetched: ${result.records.length}`,
|
|
1023
1555
|
`headline basis: ${result.financials.headlineBasis}`,
|
|
1024
|
-
`synced provider headline:
|
|
1025
|
-
`combined headline spend:
|
|
1556
|
+
`synced provider headline: ${formatOptionalUsd(result.financials.headlineUsd)}`,
|
|
1557
|
+
`combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
|
|
1026
1558
|
...(result.financials.apiEquivalentEstimatedUsd !== null
|
|
1027
1559
|
? [`API-equivalent estimate (kept separate): $${result.financials.apiEquivalentEstimatedUsd.toFixed(2)}`]
|
|
1028
1560
|
: []),
|
|
@@ -1030,13 +1562,51 @@ async function syncProviderCommand(args) {
|
|
|
1030
1562
|
].join("\n"));
|
|
1031
1563
|
}
|
|
1032
1564
|
catch (error) {
|
|
1565
|
+
const sanitizedError = sanitizeSecretishError(error instanceof Error ? error.message : String(error), args.authReference);
|
|
1566
|
+
await recordProviderSourceAttempt(stateDir, args.provider, new Date().toISOString(), sanitizedError).catch(() => {
|
|
1567
|
+
// Source-status state is diagnostic only. Do not hide the provider's
|
|
1568
|
+
// real error if derived-state persistence is unavailable.
|
|
1569
|
+
});
|
|
1033
1570
|
return {
|
|
1034
1571
|
exitCode: 1,
|
|
1035
1572
|
stdout: "",
|
|
1036
|
-
stderr:
|
|
1573
|
+
stderr: sanitizedError
|
|
1037
1574
|
};
|
|
1038
1575
|
}
|
|
1039
1576
|
}
|
|
1577
|
+
function formatOptionalUsd(value) {
|
|
1578
|
+
if (value === null)
|
|
1579
|
+
return "unavailable";
|
|
1580
|
+
if (value > 0 && value < 0.01)
|
|
1581
|
+
return "<$0.01";
|
|
1582
|
+
return `$${value.toFixed(2)}`;
|
|
1583
|
+
}
|
|
1584
|
+
async function recordProviderSourceAttempt(stateDir, provider, checkedAt, lastError) {
|
|
1585
|
+
if (!isProviderStatusId(provider) || !validIsoString(checkedAt)) {
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
let prior = { version: 1, providers: {} };
|
|
1589
|
+
try {
|
|
1590
|
+
prior = parsePersistedSourceAttemptState(await readJson(join(stateDir, "source-status.json"))).state;
|
|
1591
|
+
}
|
|
1592
|
+
catch {
|
|
1593
|
+
// Missing/corrupt derived status state is safe to replace. Provider
|
|
1594
|
+
// financial records live in a separate file and are never touched here.
|
|
1595
|
+
}
|
|
1596
|
+
await mkdir(stateDir, { recursive: true });
|
|
1597
|
+
await writeJson(join(stateDir, "source-status.json"), {
|
|
1598
|
+
version: 1,
|
|
1599
|
+
providers: {
|
|
1600
|
+
...(prior.providers ?? {}),
|
|
1601
|
+
[provider]: {
|
|
1602
|
+
checkedAt,
|
|
1603
|
+
lastError: lastError === null
|
|
1604
|
+
? null
|
|
1605
|
+
: (sanitizePersistedStatusText(lastError) ?? "empty provider error")
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1040
1610
|
async function confirmMappingCommand(args) {
|
|
1041
1611
|
const rootPath = resolve(args.path);
|
|
1042
1612
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
@@ -1080,10 +1650,15 @@ async function reportCommand(args) {
|
|
|
1080
1650
|
const sinceDays = args.sinceDays ?? 30;
|
|
1081
1651
|
if (!validSinceDays(sinceDays))
|
|
1082
1652
|
return invalidSinceDaysResult();
|
|
1083
|
-
|
|
1653
|
+
// Like Apply, an explicit sample report is a strict privacy boundary. It
|
|
1654
|
+
// must not inspect local transcripts, account metadata, or persisted state.
|
|
1655
|
+
const reportInput = args.sample
|
|
1656
|
+
? await buildExplicitSampleReportInput(rootPath)
|
|
1657
|
+
: await buildReportInput(stateDir, rootPath, sinceDays);
|
|
1084
1658
|
const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
|
|
1085
1659
|
const markdownPath = `${outBase}.md`;
|
|
1086
1660
|
const htmlPath = `${outBase}.html`;
|
|
1661
|
+
await mkdir(stateDir, { recursive: true });
|
|
1087
1662
|
await writeLocalReportFile(markdownPath, generateMarkdownReport(reportInput), stateDir);
|
|
1088
1663
|
await writeLocalReportFile(htmlPath, generateHtmlReport(reportInput), stateDir);
|
|
1089
1664
|
const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
|
|
@@ -1097,13 +1672,17 @@ async function reportCommand(args) {
|
|
|
1097
1672
|
`policy/config draft: ${artifactPaths.policyConfigDraft}`,
|
|
1098
1673
|
`verification plan: ${artifactPaths.verificationPlan}`,
|
|
1099
1674
|
`demo package: ${artifactPaths.demoPackage}`,
|
|
1100
|
-
|
|
1675
|
+
reportInput.dataMode === "sample"
|
|
1676
|
+
? `DEMO SAMPLE · illustrative cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)} · not user data`
|
|
1677
|
+
: `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
|
|
1101
1678
|
"privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
|
|
1102
1679
|
"",
|
|
1103
1680
|
"next:",
|
|
1104
1681
|
` open ${htmlPath} view the full report in your browser`,
|
|
1105
1682
|
` less ${markdownPath} read it in the terminal`,
|
|
1106
|
-
|
|
1683
|
+
reportInput.dataMode === "sample"
|
|
1684
|
+
? " npx aibill apply --sample print the non-executable demo boundary"
|
|
1685
|
+
: " npx aibill apply print the paste-ready coding-agent prompt"
|
|
1107
1686
|
].join("\n"));
|
|
1108
1687
|
}
|
|
1109
1688
|
catch (error) {
|
|
@@ -1130,17 +1709,27 @@ async function resolveReceiptPath(rootPath, out) {
|
|
|
1130
1709
|
}
|
|
1131
1710
|
async function reportCardCommand(args) {
|
|
1132
1711
|
try {
|
|
1133
|
-
|
|
1134
|
-
|
|
1712
|
+
// Explicit sample mode reads no workspace data, so a broad-root scan guard
|
|
1713
|
+
// would reject a harmless receipt written from the user's home directory.
|
|
1714
|
+
// Output still goes through the safe-write/symlink checks below.
|
|
1715
|
+
const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
|
|
1716
|
+
const { records, mode, providerCoverage } = await loadInstantReadData(args);
|
|
1135
1717
|
const headlineRecords = mode === "connected"
|
|
1136
1718
|
? selectProviderFinancialHeadlineRecords(records)
|
|
1137
1719
|
: records;
|
|
1138
1720
|
const summary = analyzeSpend(headlineRecords);
|
|
1139
1721
|
const outPath = await resolveReceiptPath(rootPath, args.out);
|
|
1140
1722
|
await mkdir(dirname(outPath), { recursive: true });
|
|
1141
|
-
await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({
|
|
1723
|
+
await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({
|
|
1724
|
+
summary,
|
|
1725
|
+
records: headlineRecords,
|
|
1726
|
+
mode,
|
|
1727
|
+
...(providerCoverage ? { providerCoverage } : {})
|
|
1728
|
+
}));
|
|
1142
1729
|
const dataLine = mode === "demo"
|
|
1143
|
-
?
|
|
1730
|
+
? args.sample
|
|
1731
|
+
? "data: DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read."
|
|
1732
|
+
: "data: DEMO sample data — no supported local Claude Code/Codex evidence was found; use --sample to reproduce this demo explicitly."
|
|
1144
1733
|
: mode === "local-logs"
|
|
1145
1734
|
? "data: local Claude Code/Codex logs priced at API-equivalent rates."
|
|
1146
1735
|
: "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
|
|
@@ -1150,7 +1739,12 @@ async function reportCardCommand(args) {
|
|
|
1150
1739
|
dataLine,
|
|
1151
1740
|
"",
|
|
1152
1741
|
"Caption to share:",
|
|
1153
|
-
generateReportCardCaption({
|
|
1742
|
+
generateReportCardCaption({
|
|
1743
|
+
summary,
|
|
1744
|
+
records: headlineRecords,
|
|
1745
|
+
mode,
|
|
1746
|
+
...(providerCoverage ? { providerCoverage } : {})
|
|
1747
|
+
}),
|
|
1154
1748
|
"",
|
|
1155
1749
|
"privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
|
|
1156
1750
|
].join("\n"));
|
|
@@ -1242,23 +1836,59 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
|
1242
1836
|
const sinceIso = sinceIsoForDays(sinceDays, generatedAt);
|
|
1243
1837
|
let freshLocalCalls;
|
|
1244
1838
|
let freshCodexInvocationFiles;
|
|
1245
|
-
let
|
|
1839
|
+
let exactSpendContents;
|
|
1840
|
+
try {
|
|
1841
|
+
exactSpendContents = await readSafeStateText(stateDir, "spend.json");
|
|
1842
|
+
}
|
|
1843
|
+
catch (error) {
|
|
1844
|
+
if (!isNodeError(error, "ENOENT"))
|
|
1845
|
+
throw error;
|
|
1846
|
+
}
|
|
1847
|
+
let spendState = exactSpendContents === undefined
|
|
1848
|
+
? undefined
|
|
1849
|
+
: JSON.parse(exactSpendContents);
|
|
1850
|
+
let untrustedConnectedStateMessage;
|
|
1851
|
+
let unavailablePersistedLocalLogs = false;
|
|
1246
1852
|
let mappings = await readOptionalJson(join(stateDir, "mappings.json"), undefined);
|
|
1247
1853
|
// Never trust a persisted summary or an absent mode. Re-parse the records,
|
|
1248
1854
|
// recover the narrowly identifiable bundled sample written by older
|
|
1249
1855
|
// releases, and recompute decision output under the current evidence rules.
|
|
1250
1856
|
// Any other unlabeled state remains unlabeled and therefore non-executable.
|
|
1251
1857
|
if (spendState?.records && spendState.records.length > 0) {
|
|
1252
|
-
const
|
|
1253
|
-
const
|
|
1858
|
+
const parsedRecords = spendState.records.map((record) => parseUsageRecord(record));
|
|
1859
|
+
const storedMode = isPersistedDataMode(spendState.mode) ? spendState.mode : undefined;
|
|
1860
|
+
// A bundled sample remains sample even if a conflicting mode was written.
|
|
1861
|
+
// This guards report and Apply separately from the quickstart read path.
|
|
1862
|
+
const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
|
|
1863
|
+
const records = mode === "sample" || mode === undefined
|
|
1864
|
+
? downgradeSampleUsageEvidence(parsedRecords)
|
|
1865
|
+
: parsedRecords;
|
|
1866
|
+
unavailablePersistedLocalLogs = mode === "local_logs";
|
|
1254
1867
|
const headlineRecords = mode === "connected_provider"
|
|
1255
1868
|
? selectProviderFinancialHeadlineRecords(records)
|
|
1256
1869
|
: records;
|
|
1257
1870
|
spendState = {
|
|
1258
1871
|
records,
|
|
1259
1872
|
mode,
|
|
1260
|
-
summary: analyzeSpend(headlineRecords)
|
|
1873
|
+
summary: analyzeSpend(headlineRecords),
|
|
1874
|
+
...(spendState.accounting !== undefined ? { accounting: spendState.accounting } : {})
|
|
1261
1875
|
};
|
|
1876
|
+
if (mode === "connected_provider" && exactSpendContents !== undefined) {
|
|
1877
|
+
const trust = await verifyConnectedSpendTrustReceipt(rootPath, exactSpendContents);
|
|
1878
|
+
if (!trust.trusted) {
|
|
1879
|
+
untrustedConnectedStateMessage = [
|
|
1880
|
+
trust.message,
|
|
1881
|
+
"CLI: run `npx aibill connect <provider>` or repeat the prior `npx aibill sync-provider ...` command."
|
|
1882
|
+
].join(" ");
|
|
1883
|
+
spendState = undefined;
|
|
1884
|
+
mappings = undefined;
|
|
1885
|
+
}
|
|
1886
|
+
else {
|
|
1887
|
+
// Derived attribution is rebuilt from the receipt-bound records. A
|
|
1888
|
+
// repository-authored mappings.json cannot steer Apply ownership.
|
|
1889
|
+
mappings = attributeUsageRecords(records);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1262
1892
|
}
|
|
1263
1893
|
// Local-log state is a CACHE, not a source of truth: the quickstart always
|
|
1264
1894
|
// re-reads the logs fresh, so report/apply must too — otherwise yesterday's
|
|
@@ -1293,20 +1923,57 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
|
1293
1923
|
await writeLocalSpendState(stateDir, records, summary, liveMappings, "local_logs");
|
|
1294
1924
|
spendState = { summary, records, mode: "local_logs" };
|
|
1295
1925
|
mappings = liveMappings;
|
|
1926
|
+
unavailablePersistedLocalLogs = false;
|
|
1927
|
+
}
|
|
1928
|
+
else if (unavailablePersistedLocalLogs) {
|
|
1929
|
+
// Persisted local_logs is only a cache. If the source transcripts are no
|
|
1930
|
+
// longer present, repository-authored rows cannot become an executable
|
|
1931
|
+
// Apply artifact merely by claiming local_logs mode.
|
|
1932
|
+
spendState = undefined;
|
|
1933
|
+
mappings = undefined;
|
|
1296
1934
|
}
|
|
1297
|
-
// else: logs vanished — an existing local_logs snapshot (if any) is used below.
|
|
1298
1935
|
}
|
|
1299
1936
|
if (!spendState?.summary || !spendState.records || spendState.records.length === 0) {
|
|
1937
|
+
if (untrustedConnectedStateMessage) {
|
|
1938
|
+
throw new Error(`${untrustedConnectedStateMessage} No connected totals or Apply actions were generated.`);
|
|
1939
|
+
}
|
|
1940
|
+
if (unavailablePersistedLocalLogs) {
|
|
1941
|
+
throw new Error("Persisted local-log state is an untrusted cache and its source Claude Code/Codex records are unavailable. " +
|
|
1942
|
+
"Re-run `npx aibill` while the local transcripts are available; no report or Apply action was generated from repository state alone.");
|
|
1943
|
+
}
|
|
1300
1944
|
throw new Error("no persisted spend state and no local Claude Code/Codex logs found. " +
|
|
1301
1945
|
"Run `npx aibill` first (or `npx aibill scan --sample --path <dir>` for a demo-data report).");
|
|
1302
1946
|
}
|
|
1303
|
-
const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings,
|
|
1947
|
+
const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, persistedProviderRecordsState] = await Promise.all([
|
|
1304
1948
|
readOptionalJson(join(stateDir, "discovery.json"), emptyDiscovery(rootPath)),
|
|
1305
1949
|
readSourceRegistry(stateDir, rootPath),
|
|
1306
1950
|
readOptionalJson(join(stateDir, "missing-sources.json"), []),
|
|
1307
1951
|
readConfirmedMappings(stateDir),
|
|
1308
1952
|
readOptionalJson(join(stateDir, "provider-records.json"), { records: [] })
|
|
1309
1953
|
]);
|
|
1954
|
+
const providerRecordsState = spendState.mode === "connected_provider"
|
|
1955
|
+
? {
|
|
1956
|
+
records: spendState.records,
|
|
1957
|
+
qaByProvider: trustedAccountingMap(isPlainObject(spendState.accounting) ? spendState.accounting : undefined, "qaByProvider"),
|
|
1958
|
+
coverageByProvider: trustedAccountingMap(isPlainObject(spendState.accounting) ? spendState.accounting : undefined, "coverageByProvider")
|
|
1959
|
+
}
|
|
1960
|
+
: persistedProviderRecordsState;
|
|
1961
|
+
const providerQa = providerRecordsState.qaByProvider
|
|
1962
|
+
? Object.values(providerRecordsState.qaByProvider).sort((left, right) => left.provider.localeCompare(right.provider))
|
|
1963
|
+
: providerRecordsState.qa
|
|
1964
|
+
? [providerRecordsState.qa]
|
|
1965
|
+
: [];
|
|
1966
|
+
const spendProviderCoverage = persistedProviderCoverage(spendState.accounting);
|
|
1967
|
+
const coverageStatuses = [
|
|
1968
|
+
...Object.values(providerRecordsState.coverageByProvider ?? {}),
|
|
1969
|
+
...providerQa.map((qa) => qa.coverage).filter((coverage) => coverage === "complete" || coverage === "partial"),
|
|
1970
|
+
...(spendProviderCoverage ? [spendProviderCoverage] : [])
|
|
1971
|
+
];
|
|
1972
|
+
const providerCoverage = coverageStatuses.includes("partial")
|
|
1973
|
+
? "partial"
|
|
1974
|
+
: coverageStatuses.includes("complete")
|
|
1975
|
+
? "complete"
|
|
1976
|
+
: undefined;
|
|
1310
1977
|
// Named dead-context items feed the apply artifact for local-log users —
|
|
1311
1978
|
// the concrete "remove these" list, from the same engine as the readout.
|
|
1312
1979
|
const deadContext = spendState.mode === "local_logs"
|
|
@@ -1364,9 +2031,13 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
|
1364
2031
|
mappings: mappings ?? [],
|
|
1365
2032
|
sourceRegistry,
|
|
1366
2033
|
missingSourcePrompts,
|
|
1367
|
-
|
|
2034
|
+
// Confirmed mappings are mutable repository state with a different user-
|
|
2035
|
+
// approval lifecycle. Until they receive their own receipt, they cannot
|
|
2036
|
+
// be promoted into connected-provider Apply actions.
|
|
2037
|
+
confirmedMappings: spendState.mode === "connected_provider" ? [] : confirmedMappings,
|
|
1368
2038
|
providerRecords: providerRecordsState.records,
|
|
1369
|
-
providerQa
|
|
2039
|
+
providerQa,
|
|
2040
|
+
...(providerCoverage ? { providerCoverage } : {})
|
|
1370
2041
|
};
|
|
1371
2042
|
}
|
|
1372
2043
|
function validSinceDays(value) {
|
|
@@ -1439,6 +2110,10 @@ function parseArgs(argv) {
|
|
|
1439
2110
|
parsed.json = true;
|
|
1440
2111
|
continue;
|
|
1441
2112
|
}
|
|
2113
|
+
if (arg === "--sources") {
|
|
2114
|
+
parsed.sources = true;
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
1442
2117
|
if (arg === "--ignore-state") {
|
|
1443
2118
|
parsed.ignoreState = true;
|
|
1444
2119
|
continue;
|
|
@@ -1674,13 +2349,34 @@ function parseArgs(argv) {
|
|
|
1674
2349
|
function sanitizeSecretishError(message, authReference) {
|
|
1675
2350
|
// Core's redactSecrets covers sk-*/ghp_*/github_pat_*/JWT/AIza/xox/AKIA and
|
|
1676
2351
|
// secret-suffixed env assignments; the sk- fallback keeps short keys covered.
|
|
1677
|
-
|
|
2352
|
+
// Strip terminal controls first so escapes cannot split a secret pattern or
|
|
2353
|
+
// forge extra CLI lines, then exact-redact again after normalization.
|
|
2354
|
+
const withoutAuthReference = authReference && !authReference.startsWith("env:")
|
|
2355
|
+
? message.split(authReference).join("[REDACTED]")
|
|
2356
|
+
: message;
|
|
2357
|
+
let sanitized = redactSecrets(stripTerminalControlSequences(withoutAuthReference))
|
|
2358
|
+
.replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]");
|
|
1678
2359
|
if (authReference && !authReference.startsWith("env:")) {
|
|
1679
2360
|
sanitized = sanitized.split(authReference).join("[REDACTED]");
|
|
1680
2361
|
}
|
|
1681
|
-
return sanitized;
|
|
2362
|
+
return sanitized.trim();
|
|
2363
|
+
}
|
|
2364
|
+
function stripTerminalControlSequences(message) {
|
|
2365
|
+
return message
|
|
2366
|
+
.replace(/(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/gu, "")
|
|
2367
|
+
.replace(/(?:\u001b(?:P|X|\^|_)|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\u001b\\|\u009c|$)/gu, "")
|
|
2368
|
+
.replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu, "")
|
|
2369
|
+
.replace(/\u001b[@-_]/gu, "")
|
|
2370
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
2371
|
+
.replace(/\s+/gu, " ");
|
|
2372
|
+
}
|
|
2373
|
+
function isPersistedDataMode(value) {
|
|
2374
|
+
return value === "sample" || value === "local_logs" || value === "connected_provider";
|
|
1682
2375
|
}
|
|
1683
2376
|
async function writeLocalSpendState(stateDir, records, summary, mappings, mode, accounting) {
|
|
2377
|
+
if (mode !== "connected_provider") {
|
|
2378
|
+
await invalidateConnectedSpendTrustReceipt(dirname(stateDir));
|
|
2379
|
+
}
|
|
1684
2380
|
await writeJson(join(stateDir, "spend.json"), {
|
|
1685
2381
|
mode,
|
|
1686
2382
|
records,
|
|
@@ -1691,7 +2387,23 @@ async function writeLocalSpendState(stateDir, records, summary, mappings, mode,
|
|
|
1691
2387
|
}
|
|
1692
2388
|
async function readSourceRegistry(stateDir, rootPath) {
|
|
1693
2389
|
try {
|
|
1694
|
-
|
|
2390
|
+
const exactSourceRegistryContents = await readSafeStateText(stateDir, "sources.json");
|
|
2391
|
+
const registry = normalizeSourceRegistry(JSON.parse(exactSourceRegistryContents));
|
|
2392
|
+
try {
|
|
2393
|
+
const exactSpendContents = await readSafeStateText(stateDir, "spend.json");
|
|
2394
|
+
const parsedSpend = JSON.parse(exactSpendContents);
|
|
2395
|
+
if (parsedSpend.mode === "connected_provider") {
|
|
2396
|
+
const trust = await verifyConnectedSourceRegistryTrustReceipt(rootPath, exactSpendContents, exactSourceRegistryContents);
|
|
2397
|
+
if (trust.trusted)
|
|
2398
|
+
return registry;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
catch {
|
|
2402
|
+
// A source boundary remains usable as configuration, but its repository-
|
|
2403
|
+
// controlled validation/evidence claims are never promoted without the
|
|
2404
|
+
// matching external provider-sync receipt.
|
|
2405
|
+
}
|
|
2406
|
+
return downgradeUntrustedSourceRegistryClaims(registry);
|
|
1695
2407
|
}
|
|
1696
2408
|
catch {
|
|
1697
2409
|
return createLocalFolderSourceRegistry(rootPath);
|
|
@@ -1756,17 +2468,17 @@ function helpText() {
|
|
|
1756
2468
|
return [
|
|
1757
2469
|
"aibill — your AI cost and usage evidence in one private view",
|
|
1758
2470
|
"",
|
|
1759
|
-
"Run with no command for an instant, zero-key
|
|
1760
|
-
"
|
|
1761
|
-
"
|
|
1762
|
-
"
|
|
2471
|
+
"Run with no command for an instant, zero-key local readout:",
|
|
2472
|
+
" npx aibill Show available AI cost/value evidence (sample or local data)",
|
|
2473
|
+
" npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
|
|
2474
|
+
" npx aibill --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
|
|
1763
2475
|
"",
|
|
1764
2476
|
"Add official provider-reported cost (ADMIN/owner-gated):",
|
|
1765
|
-
"
|
|
1766
|
-
"
|
|
1767
|
-
"
|
|
1768
|
-
"
|
|
1769
|
-
"
|
|
2477
|
+
" npx aibill connect openai Requires an org-owner Admin credential reference",
|
|
2478
|
+
" npx aibill connect anthropic Requires an Admin credential reference",
|
|
2479
|
+
" npx aibill connect cursor Beta: requires a Cursor team-admin credential reference",
|
|
2480
|
+
" npx aibill connect github-copilot Beta: requires a GitHub billing-admin credential reference",
|
|
2481
|
+
" npx aibill sync-provider ... Pull provider cost/usage evidence via a local env: reference (never a raw credential)",
|
|
1770
2482
|
"",
|
|
1771
2483
|
"Watch continuously (deltas + anomalies):",
|
|
1772
2484
|
" watch [--interval N] Re-run analysis on an interval and report deltas/anomalies",
|
|
@@ -1775,14 +2487,14 @@ function helpText() {
|
|
|
1775
2487
|
"Other commands:",
|
|
1776
2488
|
" --version, -v Print the package version without reading local data",
|
|
1777
2489
|
" init [--path <dir>] Initialize local state",
|
|
1778
|
-
" doctor
|
|
2490
|
+
" doctor [--sources] Launch diagnostics; --sources shows validation, evidence, freshness, and errors",
|
|
1779
2491
|
" reset [--path <dir>] Clear persisted spend state (so sample state can't mask real logs)",
|
|
1780
2492
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
1781
2493
|
" scan [--path <dir>] Scan a local workspace for AI usage signals",
|
|
1782
2494
|
" scan --sample Include deterministic sample spend analysis",
|
|
1783
2495
|
" quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
|
|
1784
2496
|
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
|
|
1785
|
-
" report [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
|
|
2497
|
+
" report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
|
|
1786
2498
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
1787
2499
|
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
1788
2500
|
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
@@ -1791,9 +2503,10 @@ function helpText() {
|
|
|
1791
2503
|
" apply-artifact Same as `apply` (long form)",
|
|
1792
2504
|
"",
|
|
1793
2505
|
"Cron (production watch): add a crontab entry such as:",
|
|
1794
|
-
" 0 * * * * cd /path/to/workspace &&
|
|
2506
|
+
" 0 * * * * cd /path/to/workspace && npx --yes aibill watch --interval 3600 --cycles 1 >> aibill-watch.log 2>&1",
|
|
1795
2507
|
"",
|
|
1796
|
-
"Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference
|
|
2508
|
+
"Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
|
|
2509
|
+
"aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
|
|
1797
2510
|
].join("\n");
|
|
1798
2511
|
}
|
|
1799
2512
|
// Main-module check that survives npm's bin SYMLINKS: argv[1] is
|
|
@@ -1821,8 +2534,8 @@ export async function runMain() {
|
|
|
1821
2534
|
// error deep in a dependency. npm warns on engines but never blocks install.
|
|
1822
2535
|
const major = Number(process.versions.node.split(".")[0]);
|
|
1823
2536
|
if (Number.isFinite(major) && major < 22) {
|
|
1824
|
-
console.error(`
|
|
1825
|
-
"Upgrade Node, then run: npx
|
|
2537
|
+
console.error(`aibill needs Node 22 or newer (you have ${process.versions.node}).\n` +
|
|
2538
|
+
"Upgrade Node, then run: npx aibill");
|
|
1826
2539
|
process.exit(1);
|
|
1827
2540
|
}
|
|
1828
2541
|
const argv = process.argv.slice(2);
|
|
@@ -1852,9 +2565,9 @@ export async function runMain() {
|
|
|
1852
2565
|
exitCode: 1,
|
|
1853
2566
|
stdout: "",
|
|
1854
2567
|
stderr: [
|
|
1855
|
-
`
|
|
2568
|
+
`aibill hit an unexpected error: ${message}`,
|
|
1856
2569
|
"Nothing was uploaded; local state is unchanged.",
|
|
1857
|
-
"Try `
|
|
2570
|
+
"Try `npx aibill doctor` for diagnostics, or open an issue: https://github.com/futurastudio/ai-spend-agent/issues"
|
|
1858
2571
|
].join("\n")
|
|
1859
2572
|
};
|
|
1860
2573
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Local-first financial accountability CLI for Claude Code and Codex work, cost evidence, attribution, runway, provenance, and Context Health.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"prepack": "npm run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@agent-finops/core": "0.
|
|
58
|
-
"@agent-finops/report": "0.
|
|
57
|
+
"@agent-finops/core": "0.6.0",
|
|
58
|
+
"@agent-finops/report": "0.6.0",
|
|
59
59
|
"yocto-spinner": "^1.2.0"
|
|
60
60
|
}
|
|
61
61
|
}
|