@agent-finops/core 0.9.0 → 0.9.2

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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { createProviderConnectorStub, slugifySourceId } from "./sourceRegistry.js";
2
3
  import { redactSecrets } from "./discovery.js";
3
4
  /**
@@ -68,6 +69,80 @@ function invalidCursorReconciliationExpectationReason(expectation) {
68
69
  }
69
70
  return undefined;
70
71
  }
72
+ /** Environment variables the GitHub Copilot connector reads for a reconciliation run. */
73
+ export const gitHubCopilotReconciliationEnvVars = {
74
+ expectedUsd: "AI_SPEND_COPILOT_RECONCILE_EXPECTED_USD",
75
+ billingMonth: "AI_SPEND_COPILOT_RECONCILE_MONTH",
76
+ toleranceUsd: "AI_SPEND_COPILOT_RECONCILE_TOLERANCE_USD",
77
+ /**
78
+ * Optional binding of the anchor to ONE account (post-hoc QA C3): a bare
79
+ * slug or `org:<slug>` / `enterprise:<slug>`. When set, a sync of any
80
+ * other account fails the reconciliation closed instead of stamping a
81
+ * coincidence-equal total verified with leftover shell env.
82
+ */
83
+ account: "AI_SPEND_COPILOT_RECONCILE_ACCOUNT"
84
+ };
85
+ /**
86
+ * Read an operator-declared GitHub Copilot reconciliation anchor from the
87
+ * local environment. Absent variables mean "no reconciliation requested";
88
+ * present but invalid variables fail closed with a reason (records stay
89
+ * estimated) instead of throwing, so a typo can never abort or silently
90
+ * verify a sync. Raw variable values are never echoed into the reason.
91
+ */
92
+ export function parseGitHubCopilotReconciliationEnv(env = process.env) {
93
+ const rawExpected = env[gitHubCopilotReconciliationEnvVars.expectedUsd];
94
+ const rawMonth = env[gitHubCopilotReconciliationEnvVars.billingMonth];
95
+ const rawTolerance = env[gitHubCopilotReconciliationEnvVars.toleranceUsd];
96
+ const rawAccount = env[gitHubCopilotReconciliationEnvVars.account];
97
+ // A dangling ACCOUNT binding alone requests nothing — it only constrains a
98
+ // reconciliation that the expected/month pair actually requested.
99
+ if (rawExpected === undefined && rawMonth === undefined && rawTolerance === undefined) {
100
+ return {};
101
+ }
102
+ if (rawExpected === undefined || rawMonth === undefined) {
103
+ return {
104
+ invalidReason: `both ${gitHubCopilotReconciliationEnvVars.expectedUsd} and ${gitHubCopilotReconciliationEnvVars.billingMonth} are required to request a reconciliation`
105
+ };
106
+ }
107
+ const expectedNetUsd = Number(rawExpected.trim());
108
+ const toleranceUsd = rawTolerance === undefined ? undefined : Number(rawTolerance.trim());
109
+ const account = rawAccount?.trim();
110
+ const expectation = {
111
+ expectedNetUsd,
112
+ expectedBillingMonth: rawMonth.trim(),
113
+ ...(toleranceUsd === undefined ? {} : { toleranceUsd }),
114
+ ...(account ? { account } : {})
115
+ };
116
+ const invalidReason = invalidGitHubCopilotReconciliationExpectationReason(expectation);
117
+ return invalidReason ? { invalidReason } : { expectation };
118
+ }
119
+ function invalidGitHubCopilotReconciliationExpectationReason(expectation) {
120
+ if (!Number.isFinite(expectation.expectedNetUsd) || expectation.expectedNetUsd <= 0) {
121
+ return `${gitHubCopilotReconciliationEnvVars.expectedUsd} must be a positive USD amount read off the GitHub billing usage page`;
122
+ }
123
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(expectation.expectedBillingMonth)) {
124
+ return `${gitHubCopilotReconciliationEnvVars.billingMonth} must be the billing month shown on the usage page, formatted YYYY-MM`;
125
+ }
126
+ if (expectation.toleranceUsd !== undefined &&
127
+ (!Number.isFinite(expectation.toleranceUsd) || expectation.toleranceUsd < 0)) {
128
+ return `${gitHubCopilotReconciliationEnvVars.toleranceUsd} must be a non-negative USD amount when set`;
129
+ }
130
+ if (expectation.account !== undefined &&
131
+ !/^(?:(?:org|enterprise):)?[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(expectation.account)) {
132
+ // Never echo the raw value — env parse reasons are printed verbatim.
133
+ return `${gitHubCopilotReconciliationEnvVars.account} must be an org/enterprise slug, optionally prefixed org: or enterprise:`;
134
+ }
135
+ return undefined;
136
+ }
137
+ /**
138
+ * Shared reconciliation tolerance rule: default $0.01 (billing pages round to
139
+ * cents) and clamp to at most 1% of the expected figure so an oversized
140
+ * tolerance can never manufacture a match.
141
+ */
142
+ function clampReconciliationToleranceUsd(expectedUsd, requestedToleranceUsd) {
143
+ const requested = Math.max(requestedToleranceUsd ?? 0.01, 0.01);
144
+ return Math.min(requested, Math.max(0.01, expectedUsd * 0.01));
145
+ }
71
146
  export function normalizeOpenAiCostResponse(response, options) {
72
147
  const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
73
148
  const records = [];
@@ -448,6 +523,71 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
448
523
  }
449
524
  return records;
450
525
  }
526
+ /**
527
+ * Normalize one GitHub AI-credit usage report (the 2026 usage-based billing
528
+ * model: GET /organizations/{org}/settings/billing/ai_credit/usage) into
529
+ * billed-dollar evidence records. Rows are NET provider-reported dollars, but
530
+ * this connector is fixture-verified, so — exactly like the Cursor connector —
531
+ * the records stay "estimated" until an in-run reconciliation proves the
532
+ * connector total against the operator-read billing page figure for the same
533
+ * billing month. Legacy premium-request units are deliberately not fetched or
534
+ * blended here (provider-contract exclusion).
535
+ */
536
+ export function normalizeGitHubCopilotAiCreditUsageResponse(response, options, reconciliation) {
537
+ const items = extractArray(response, "usageItems");
538
+ const timePeriod = isRecord(response) && isRecord(response.timePeriod) ? response.timePeriod : undefined;
539
+ const year = nonNegativeIntegerValue(timePeriod?.year);
540
+ const month = nonNegativeIntegerValue(timePeriod?.month);
541
+ const day = nonNegativeIntegerValue(timePeriod?.day);
542
+ const monthKey = typeof year === "number" && typeof month === "number" && month >= 1 && month <= 12
543
+ ? `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`
544
+ : undefined;
545
+ const timestampDate = monthKey && typeof day === "number" && day >= 1 && day <= 31
546
+ ? `${monthKey}-${String(day).padStart(2, "0")}`
547
+ : monthKey
548
+ ? `${monthKey}-01`
549
+ : undefined;
550
+ const timestamp = timestampDate && Number.isFinite(Date.parse(`${timestampDate}T00:00:00Z`))
551
+ ? new Date(`${timestampDate}T00:00:00Z`).toISOString()
552
+ : new Date().toISOString();
553
+ // Fail-closed stamping mirror of the Cursor connector: only an in-run
554
+ // reconciliation match may flip these records to "verified"; a mismatched or
555
+ // unprovable reconciliation keeps them estimated.
556
+ const reconciled = reconciliation?.status === "verified";
557
+ const confidence = reconciled ? "verified" : "estimated";
558
+ return items.flatMap((item, index) => {
559
+ if (!isRecord(item))
560
+ return [];
561
+ const product = stringValue(item.product);
562
+ const sku = stringValue(item.sku);
563
+ const netAmountUsd = parseDollarUsd(item.netAmount);
564
+ if (!product || !sku || typeof netAmountUsd !== "number")
565
+ return [];
566
+ const model = stringValue(item.model);
567
+ const grossAmountUsd = parseDollarUsd(item.grossAmount);
568
+ const discountAmountUsd = parseDollarUsd(item.discountAmount);
569
+ const netQuantity = nonNegativeNumberValue(item.netQuantity);
570
+ const amounts = typeof grossAmountUsd === "number" && typeof discountAmountUsd === "number"
571
+ ? `gross $${grossAmountUsd.toFixed(2)} - discounts $${discountAmountUsd.toFixed(2)} = net $${netAmountUsd.toFixed(2)}`
572
+ : `net $${netAmountUsd.toFixed(2)}`;
573
+ const baseOperation = `GitHub AI-credit billed usage${monthKey ? ` for ${monthKey}` : ""} (${sku}; ${amounts}; metered spend, excludes seat license fees)`;
574
+ return [{
575
+ id: slugifySourceId(["github-copilot-ai-credit", monthKey ?? "unknown-month", options.accountId, product, sku, model, String(index)].filter(Boolean).join("-")),
576
+ timestamp,
577
+ source: { id: options.sourceId, name: "GitHub AI-credit usage report", provider: "github-copilot", confidence, observedFrom: options.observedFrom },
578
+ model: model ?? sku,
579
+ inputTokens: 0,
580
+ outputTokens: 0,
581
+ amountUsd: netAmountUsd,
582
+ costConfidence: confidence,
583
+ projectId: options.accountId,
584
+ providerCostType: "copilot_ai_credit_billing",
585
+ usageGranularity: "billing_bucket",
586
+ ...(netQuantity === undefined ? {} : { quantity: netQuantity }),
587
+ operation: reconciled ? `${baseOperation}; ${reconciliation.note}` : baseOperation
588
+ }];
589
+ });
590
+ }
451
591
  export function normalizeCursorSpendResponse(response, options, reconciliation) {
452
592
  const users = extractArray(response, "teamMemberSpend");
453
593
  const cycleStart = isRecord(response) ? numberValue(response.subscriptionCycleStart) : undefined;
@@ -621,9 +761,247 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
621
761
  const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
622
762
  if (seatFetch)
623
763
  assessGitHubCopilotSeatCompleteness(seatFetch);
624
- const metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
625
- const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
626
- return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
764
+ const requested = input.copilotReconciliation
765
+ ? { expectation: input.copilotReconciliation, invalidReason: invalidGitHubCopilotReconciliationExpectationReason(input.copilotReconciliation) }
766
+ : parseGitHubCopilotReconciliationEnv();
767
+ const aiCredit = await fetchGitHubCopilotAiCreditUsage(fetcher, input, request, requested.expectation);
768
+ const syncedAccount = input.org
769
+ ? { scope: "org", slug: input.org }
770
+ : { scope: "enterprise", slug: input.enterprise };
771
+ const reconciliation = assessGitHubCopilotReconciliation(aiCredit, requested.expectation, requested.invalidReason, syncedAccount);
772
+ // Dedupe every record family by stable id BEFORE the QA summary so a
773
+ // GitHub page-shift during churn (the same seat or day row returned on
774
+ // two pages) can never double-count estimated dollars or usage (QA M2).
775
+ const metricsRecords = dedupeGitHubCopilotRecordsById(metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId })), metricsFetch);
776
+ const seatRecords = seatFetch
777
+ ? dedupeGitHubCopilotRecordsById(seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })), seatFetch)
778
+ : [];
779
+ const aiCreditRecords = aiCredit.fetch
780
+ ? dedupeGitHubCopilotRecordsById(aiCredit.fetch.pages.flatMap((page) => normalizeGitHubCopilotAiCreditUsageResponse(page, { sourceId, observedFrom: "GitHub AI-credit usage report", accountId }, reconciliation)), aiCredit.fetch)
781
+ : [];
782
+ const qa = qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : []), ...(aiCredit.fetch ? [aiCredit.fetch] : [])]);
783
+ if (aiCredit.unavailableReason) {
784
+ // A token scoped to Copilot metrics/seats alone still yields honest usage
785
+ // evidence, so a missing billing permission degrades to estimated records
786
+ // with an explicit diagnostic instead of failing the whole sync.
787
+ qa.instructions = [...qa.instructions, aiCredit.unavailableReason];
788
+ qa.responseDrift.push({
789
+ label: "GitHub Copilot AI-credit usage report",
790
+ field: "usageItems",
791
+ issue: aiCredit.unavailableReason
792
+ });
793
+ }
794
+ if (reconciliation) {
795
+ // The outcome must survive the persisted-QA round trip, so it rides in
796
+ // instructions (kept verbatim) and, on failure, in responseDrift.
797
+ qa.instructions = [...qa.instructions, `Reconciliation ${reconciliation.status}: ${reconciliation.note}`];
798
+ if (reconciliation.status !== "verified") {
799
+ qa.responseDrift.push({
800
+ label: "GitHub Copilot AI-credit usage report",
801
+ field: "usageItems[].netAmount (billing month total)",
802
+ issue: reconciliation.note
803
+ });
804
+ }
805
+ }
806
+ return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords, ...aiCreditRecords], qa);
807
+ }
808
+ function resolveGitHubCopilotBillingMonth(expectation) {
809
+ if (expectation && /^\d{4}-(0[1-9]|1[0-2])$/.test(expectation.expectedBillingMonth)) {
810
+ const [year, month] = expectation.expectedBillingMonth.split("-").map(Number);
811
+ return { year: year, month: month };
812
+ }
813
+ const now = new Date();
814
+ return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 };
815
+ }
816
+ /**
817
+ * Read the org/enterprise AI-credit usage report for one billing month (the
818
+ * declared reconciliation month, else the current UTC month). The endpoint
819
+ * needs the fine-grained "Administration: read" organization permission
820
+ * (enterprise reads: billing read); a 401/403/404 therefore degrades with an
821
+ * honest note instead of failing metrics/seat evidence, while transport and
822
+ * schema failures still fail closed.
823
+ */
824
+ async function fetchGitHubCopilotAiCreditUsage(fetcher, input, request, expectation) {
825
+ const label = "GitHub Copilot AI-credit usage report";
826
+ const billingMonth = resolveGitHubCopilotBillingMonth(expectation);
827
+ try {
828
+ const response = await fetchJsonOrThrow(fetcher, buildGitHubCopilotAiCreditUsageUrl(input, billingMonth), request, "github-copilot", label);
829
+ const fetchResult = {
830
+ pages: [response.payload],
831
+ pagination: { label, pagesFetched: 1, stoppedBecause: "complete", maxPages: 1 },
832
+ rateLimits: response.rateLimit ? [response.rateLimit] : [],
833
+ responseDrift: detectResponseDrift(response.payload, "github-copilot", label)
834
+ };
835
+ markMalformedGitHubCopilotAiCreditRows(fetchResult, label);
836
+ return { fetch: fetchResult, billingMonth };
837
+ }
838
+ catch (error) {
839
+ const status = error instanceof ProviderConnectorError ? error.status : undefined;
840
+ if (error instanceof ProviderConnectorError && (error.code === "authentication_error" || status === 404)) {
841
+ return {
842
+ billingMonth,
843
+ unavailableReason: `GitHub AI-credit usage report was unavailable (HTTP ${status ?? "error"}); billed dollars were skipped. Grant the fine-grained "Administration: read" organization permission (enterprise: billing read) to include them.`
844
+ };
845
+ }
846
+ throw error;
847
+ }
848
+ }
849
+ function buildGitHubCopilotAiCreditUsageUrl(input, billingMonth) {
850
+ const base = input.enterprise
851
+ ? `https://api.github.com/enterprises/${encodeURIComponent(input.enterprise)}/settings/billing/ai_credit/usage`
852
+ : input.org
853
+ ? `https://api.github.com/organizations/${encodeURIComponent(input.org)}/settings/billing/ai_credit/usage`
854
+ : undefined;
855
+ if (!base)
856
+ throw new Error("GitHub Copilot connector requires --org or --enterprise.");
857
+ const url = new URL(base);
858
+ url.searchParams.set("year", String(billingMonth.year));
859
+ url.searchParams.set("month", String(billingMonth.month));
860
+ return url.toString();
861
+ }
862
+ function markMalformedGitHubCopilotAiCreditRows(fetchResult, label) {
863
+ const issues = [];
864
+ const report = (field, issue) => issues.push({ label, field, issue });
865
+ for (const page of fetchResult.pages) {
866
+ if (!isRecord(page) || !Array.isArray(page.usageItems)) {
867
+ report("usageItems", "AI-credit usage response omitted the canonical usageItems array; completeness cannot be proven");
868
+ continue;
869
+ }
870
+ if (!isRecord(page.timePeriod) || typeof nonNegativeIntegerValue(page.timePeriod.year) !== "number") {
871
+ report("timePeriod", "AI-credit usage response omitted the documented timePeriod object; the billing month cannot be proven");
872
+ }
873
+ for (const [index, item] of page.usageItems.entries()) {
874
+ const itemPath = `usageItems[${index}]`;
875
+ if (!isRecord(item)) {
876
+ report(itemPath, "AI-credit usage response contained a non-object usage row; the row was excluded");
877
+ continue;
878
+ }
879
+ if (!stringValue(item.product) || !stringValue(item.sku)) {
880
+ report(`${itemPath}.product/sku`, "AI-credit usage row is missing its product or sku label; the row was excluded");
881
+ }
882
+ if (typeof parseDollarUsd(item.netAmount) !== "number") {
883
+ report(`${itemPath}.netAmount`, "AI-credit usage row requires a non-negative USD netAmount; the row was excluded");
884
+ }
885
+ for (const field of ["grossAmount", "discountAmount", "pricePerUnit", "grossQuantity", "discountQuantity", "netQuantity"]) {
886
+ if (item[field] !== undefined && typeof nonNegativeNumberValue(item[field]) !== "number") {
887
+ report(`${itemPath}.${field}`, "AI-credit usage amounts and quantities must be non-negative numbers; the invalid value was excluded");
888
+ }
889
+ }
890
+ }
891
+ }
892
+ if (issues.length === 0)
893
+ return;
894
+ fetchResult.coverageIncomplete = true;
895
+ fetchResult.responseDrift.push(...issues);
896
+ }
897
+ /** Case-insensitive account-binding match; a bare slug matches either scope. */
898
+ function gitHubCopilotAccountBindingMatches(declared, synced) {
899
+ if (!synced)
900
+ return false;
901
+ const normalized = declared.trim().toLowerCase();
902
+ const scoped = normalized.match(/^(org|enterprise):(.+)$/);
903
+ if (scoped) {
904
+ return scoped[1] === synced.scope && scoped[2] === synced.slug.toLowerCase();
905
+ }
906
+ return normalized === synced.slug.toLowerCase();
907
+ }
908
+ /**
909
+ * Compare the connector's summed AI-credit net total for one billing month
910
+ * against the operator-read billing page figure. Every exit that is not a
911
+ * window-proven match inside the clamped tolerance fails closed: the records
912
+ * stay estimated and the note says exactly why. Returns undefined when no
913
+ * reconciliation was requested.
914
+ */
915
+ function assessGitHubCopilotReconciliation(aiCredit, expectation, invalidReason, syncedAccount) {
916
+ if (!expectation && !invalidReason)
917
+ return undefined;
918
+ if (invalidReason || !expectation) {
919
+ return {
920
+ status: "not_provable",
921
+ note: `GitHub Copilot reconciliation input was rejected (${invalidReason ?? "missing expectation"}); records remain estimated.`
922
+ };
923
+ }
924
+ const billingMonth = expectation.expectedBillingMonth;
925
+ if (expectation.account !== undefined &&
926
+ !gitHubCopilotAccountBindingMatches(expectation.account, syncedAccount)) {
927
+ // QA C3: leftover shell env from one account's runbook pass must never
928
+ // stamp a different account verified on a coincidence-equal total.
929
+ return {
930
+ status: "not_provable",
931
+ billingMonth,
932
+ note: `${gitHubCopilotReconciliationEnvVars.account} is bound to a different org/enterprise than this sync; the billing figure was not applied. Reconcile one account per shell, or update the binding. Records remain estimated.`
933
+ };
934
+ }
935
+ if (!aiCredit.fetch) {
936
+ return {
937
+ status: "not_provable",
938
+ billingMonth,
939
+ note: `GitHub Copilot reconciliation requires the AI-credit usage report; ${aiCredit.unavailableReason ?? "the report could not be read."} Records remain estimated.`
940
+ };
941
+ }
942
+ if (aiCredit.fetch.pagination.stoppedBecause !== "complete" || aiCredit.fetch.coverageIncomplete === true) {
943
+ return {
944
+ status: "not_provable",
945
+ billingMonth,
946
+ note: "GitHub Copilot reconciliation requires a complete AI-credit report; the response was incomplete or partially malformed, so billed dollars cannot be verified. Records remain estimated."
947
+ };
948
+ }
949
+ for (const page of aiCredit.fetch.pages) {
950
+ const timePeriod = isRecord(page) && isRecord(page.timePeriod) ? page.timePeriod : undefined;
951
+ const year = nonNegativeIntegerValue(timePeriod?.year);
952
+ const month = nonNegativeIntegerValue(timePeriod?.month);
953
+ const monthKey = typeof year === "number" && typeof month === "number"
954
+ ? `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}`
955
+ : undefined;
956
+ if (monthKey !== billingMonth) {
957
+ return {
958
+ status: "not_provable",
959
+ billingMonth,
960
+ note: `The declared billing month ${billingMonth} does not match the provider-reported time period${monthKey ? ` ${monthKey}` : ""}; the billing page figure and the connector read different windows. Records remain estimated.`
961
+ };
962
+ }
963
+ if (timePeriod?.day !== undefined) {
964
+ // Fail closed on any day field (null included), and say WHY without
965
+ // the self-contradictory month-vs-same-month wording (QA A4): a
966
+ // day-scoped report cannot anchor a whole-month billing figure.
967
+ return {
968
+ status: "not_provable",
969
+ billingMonth,
970
+ note: `GitHub Copilot reconciliation requires a whole-month AI-credit report; the provider-reported time period carries a day field, so the monthly billing page figure cannot be compared against it. Records remain estimated.`
971
+ };
972
+ }
973
+ }
974
+ const connectorTotalUsd = aiCredit.fetch.pages.reduce((sum, page) => sum + extractArray(page, "usageItems").reduce((pageSum, item) => pageSum + (isRecord(item) ? parseDollarUsd(item.netAmount) ?? 0 : 0), 0), 0);
975
+ if (!(connectorTotalUsd > 0)) {
976
+ return {
977
+ status: "not_provable",
978
+ billingMonth,
979
+ connectorTotalUsd,
980
+ expectedNetUsd: expectation.expectedNetUsd,
981
+ note: "GitHub Copilot reconciliation needs a non-zero connector total; matching $0.00 against a billing page figure proves nothing (a fully promo-discounted month cannot verify). Records remain estimated."
982
+ };
983
+ }
984
+ const toleranceUsd = clampReconciliationToleranceUsd(expectation.expectedNetUsd, expectation.toleranceUsd);
985
+ const differenceUsd = Math.abs(connectorTotalUsd - expectation.expectedNetUsd);
986
+ const shared = {
987
+ connectorTotalUsd,
988
+ expectedNetUsd: expectation.expectedNetUsd,
989
+ differenceUsd,
990
+ toleranceUsd,
991
+ billingMonth
992
+ };
993
+ if (differenceUsd <= toleranceUsd + 1e-9) {
994
+ return {
995
+ status: "verified",
996
+ ...shared,
997
+ note: `reconciled to the operator-read GitHub billing usage AI-credit net total $${expectation.expectedNetUsd.toFixed(2)} for the billing month ${billingMonth}: connector total $${connectorTotalUsd.toFixed(2)}, difference $${differenceUsd.toFixed(2)} within tolerance $${toleranceUsd.toFixed(2)}`
998
+ };
999
+ }
1000
+ return {
1001
+ status: "mismatch",
1002
+ ...shared,
1003
+ note: `GitHub Copilot reconciliation mismatch: connector AI-credit net total $${connectorTotalUsd.toFixed(2)} vs operator-read $${expectation.expectedNetUsd.toFixed(2)} for the billing month ${billingMonth}; difference $${differenceUsd.toFixed(2)} exceeds tolerance $${toleranceUsd.toFixed(2)}. Records remain estimated until the totals agree.`
1004
+ };
627
1005
  }
628
1006
  async function fetchCursor(input, token, fetcher, sourceId) {
629
1007
  const accountId = input.accountId ?? input.org ?? "cursor-team";
@@ -700,10 +1078,7 @@ function assessCursorReconciliation(spendFetch, expectation, invalidReason) {
700
1078
  note: "Cursor reconciliation needs a non-zero connector total; matching $0.00 against a dashboard figure proves nothing. Records remain estimated."
701
1079
  };
702
1080
  }
703
- // Default $0.01 (dashboards round to cents); clamp to at most 1% of the
704
- // expected figure so an oversized tolerance cannot manufacture a match.
705
- const requestedTolerance = Math.max(expectation.toleranceUsd ?? 0.01, 0.01);
706
- const toleranceUsd = Math.min(requestedTolerance, Math.max(0.01, expectation.expectedOnDemandUsd * 0.01));
1081
+ const toleranceUsd = clampReconciliationToleranceUsd(expectation.expectedOnDemandUsd, expectation.toleranceUsd);
707
1082
  const differenceUsd = Math.abs(connectorTotalUsd - expectation.expectedOnDemandUsd);
708
1083
  const shared = {
709
1084
  connectorTotalUsd,
@@ -917,7 +1292,27 @@ function assessGitHubCopilotSeatCompleteness(fetchResult) {
917
1292
  const expected = fetchResult.pages
918
1293
  .map((page) => isRecord(page) ? numberValue(page.total_seats) : undefined)
919
1294
  .find((value) => typeof value === "number");
920
- const actual = fetchResult.pages.reduce((sum, page) => sum + extractArray(page, "seats").length, 0);
1295
+ // Count UNIQUE seat identities (same derivation as the normalizer), not raw
1296
+ // rows: a GitHub page-shift during churn can return the same assignee on
1297
+ // two pages, which would otherwise satisfy total_seats while a real seat
1298
+ // went unfetched (QA M2). Rows without any identity still count singly.
1299
+ const identities = new Set();
1300
+ let unidentifiedRows = 0;
1301
+ for (const page of fetchResult.pages) {
1302
+ for (const seat of extractArray(page, "seats")) {
1303
+ if (!isRecord(seat)) {
1304
+ unidentifiedRows += 1;
1305
+ continue;
1306
+ }
1307
+ const assignee = isRecord(seat.assignee) ? seat.assignee : {};
1308
+ const identity = stringValue(assignee.login) ?? stringValue(assignee.email) ?? stringValue(seat.login) ?? stringValue(seat.id);
1309
+ if (identity)
1310
+ identities.add(identity);
1311
+ else
1312
+ unidentifiedRows += 1;
1313
+ }
1314
+ }
1315
+ const actual = identities.size + unidentifiedRows;
921
1316
  if (typeof expected !== "number") {
922
1317
  if (fetchResult.pagination.stoppedBecause === "complete")
923
1318
  fetchResult.pagination.stoppedBecause = "missing_cursor";
@@ -926,10 +1321,42 @@ function assessGitHubCopilotSeatCompleteness(fetchResult) {
926
1321
  }
927
1322
  else if (fetchResult.pagination.stoppedBecause === "complete" && actual !== expected) {
928
1323
  fetchResult.pagination.stoppedBecause = "missing_cursor";
929
- fetchResult.pagination.note = `GitHub reported ${expected} seats but returned ${actual}; completeness cannot be proven.`;
1324
+ fetchResult.pagination.note = `GitHub reported ${expected} seats but returned ${actual} unique seat identit${actual === 1 ? "y" : "ies"}; completeness cannot be proven.`;
930
1325
  fetchResult.responseDrift.push({ label: "GitHub Copilot seats", field: "total_seats", issue: fetchResult.pagination.note });
931
1326
  }
932
1327
  }
1328
+ /**
1329
+ * Dedupe one normalized GitHub Copilot record family by stable id, mirroring
1330
+ * the OpenAI dedupe semantics: an identical duplicate is excluded with a
1331
+ * drift note; conflicting rows sharing one id are ALL excluded (evidence
1332
+ * that cannot be told apart must not be counted). Any duplicate marks the
1333
+ * fetch's coverage incomplete so the sync reports partial instead of
1334
+ * silently inflating estimated dollars (QA M2).
1335
+ */
1336
+ function dedupeGitHubCopilotRecordsById(records, fetchResult) {
1337
+ const byId = new Map();
1338
+ const conflicted = new Set();
1339
+ for (const record of records) {
1340
+ const existing = byId.get(record.id);
1341
+ if (!existing && !conflicted.has(record.id)) {
1342
+ byId.set(record.id, record);
1343
+ continue;
1344
+ }
1345
+ fetchResult.coverageIncomplete = true;
1346
+ fetchResult.responseDrift.push({
1347
+ label: fetchResult.pagination.label,
1348
+ field: "normalized records[].id",
1349
+ issue: existing && JSON.stringify(existing) === JSON.stringify(record)
1350
+ ? "duplicate provider record was excluded"
1351
+ : "conflicting provider records shared one stable identity; all conflicting rows were excluded"
1352
+ });
1353
+ if (existing && JSON.stringify(existing) !== JSON.stringify(record)) {
1354
+ byId.delete(record.id);
1355
+ conflicted.add(record.id);
1356
+ }
1357
+ }
1358
+ return Array.from(byId.values());
1359
+ }
933
1360
  async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
934
1361
  const pages = [];
935
1362
  const rateLimits = [];
@@ -1539,6 +1966,11 @@ function knownProviderFields(provider, label) {
1539
1966
  if (provider === "github-copilot" && label.toLowerCase().includes("metrics")) {
1540
1967
  return new Set([...common, "download_links", "download_links[]", "day_totals", "day_totals[]", "day_totals[].day", "day_totals[].daily_active_users", "day_totals[].totals_by_model_feature", "day_totals[].totals_by_model_feature[]", "day_totals[].totals_by_model_feature[].model", "day_totals[].totals_by_model_feature[].feature", "day_totals[].totals_by_model_feature[].engaged_users", "day_totals[].totals_by_model_feature[].total_requests", "day_totals[].totals_by_model_feature[].user_initiated_interaction_count", "day_totals[].totals_by_cli", "day_totals[].totals_by_cli.request_count", "day_totals[].totals_by_cli.prompt_count", "day_totals[].totals_by_cli.session_count", "day_totals[].totals_by_cli.token_usage", "day_totals[].totals_by_cli.token_usage.prompt_tokens_sum", "day_totals[].totals_by_cli.token_usage.output_tokens_sum", "day_totals[].totals_by_cli.token_usage.avg_tokens_per_request", "day_totals[].totals_by_cli.engaged_users", "day_totals[].totals_by_cli.total_requests", "report_start_day", "report_end_day", "created_at", "generated_at", "etl_id", "day_partition", "entity_id_partition", "enterprise_id", "organization_id"]);
1541
1968
  }
1969
+ if (provider === "github-copilot" && label.toLowerCase().includes("ai-credit")) {
1970
+ // Documented 2026 enhanced-billing usage report shape: a timePeriod echo
1971
+ // plus usageItems rows (product/sku/model with gross/discount/net money).
1972
+ return new Set([...common, "timePeriod", "timePeriod.year", "timePeriod.month", "timePeriod.day", "usageItems", "usageItems[]", "usageItems[].date", "usageItems[].product", "usageItems[].sku", "usageItems[].model", "usageItems[].unitType", "usageItems[].pricePerUnit", "usageItems[].grossQuantity", "usageItems[].grossAmount", "usageItems[].discountQuantity", "usageItems[].discountAmount", "usageItems[].netQuantity", "usageItems[].netAmount", "usageItems[].organizationName", "usageItems[].repositoryName"]);
1973
+ }
1542
1974
  if (provider === "github-copilot" && label.toLowerCase().includes("seats")) {
1543
1975
  return new Set([...common, "total_seats", "seats", "seats[]", "seats[].created_at", "seats[].updated_at", "seats[].pending_cancellation_date", "seats[].last_activity_at", "seats[].last_activity_editor", "seats[].last_authenticated_at", "seats[].plan_type", "seats[].login", "seats[].id", "seats[].assignee", "seats[].assignee.login", "seats[].assignee.email", "seats[].assignee.id", "seats[].assignee.node_id", "seats[].assignee.avatar_url", "seats[].assignee.html_url", "seats[].assignee.type", "seats[].assignee.site_admin", "seats[].assigning_team", "seats[].organization"]);
1544
1976
  }
@@ -1584,8 +2016,11 @@ function providerInstructions(provider) {
1584
2016
  }
1585
2017
  if (provider === "github-copilot") {
1586
2018
  return [
1587
- "Use a GitHub token reference with org or enterprise Copilot metrics and billing seats read access.",
1588
- "Seat records estimate monthly commitment; metrics records are usage evidence without direct spend allocation."
2019
+ "Use a GitHub token reference with org or enterprise Copilot metrics, billing seats, and billing usage read access.",
2020
+ "Fine-grained token permissions (2026): 'Organization Copilot metrics: read' for usage metrics, 'GitHub Copilot Business: read' for seats, and 'Administration: read' for the AI-credit usage report (classic PATs: read:org and/or manage_billing:copilot; enterprise reads accept read:enterprise). The org's Copilot 'usage metrics' policy must be enabled.",
2021
+ "Seat records estimate monthly plan commitment at list price ($19 Business / $39 Enterprise); metrics records are usage evidence without direct spend allocation.",
2022
+ "AI-credit records carry GitHub-billed net dollars (usage-based billing for every Copilot Business/Enterprise account since 2026-06-01; 1 AI credit bills as $0.01) and stay estimated until an in-sync reconciliation matches the billing page figure; set AI_SPEND_COPILOT_RECONCILE_EXPECTED_USD and AI_SPEND_COPILOT_RECONCILE_MONTH to run one, and optionally AI_SPEND_COPILOT_RECONCILE_ACCOUNT (org:<slug> or enterprise:<slug>) to bind the anchor to one account when several are synced from one shell. Legacy premium-request billing is not fetched or blended.",
2023
+ "Individual Copilot (Free/Pro/Pro+) exposes no org billing or metrics API; the personal-plan endpoints under /users/{username}/settings/billing need that user's own token with 'Plan: read' and are not implemented."
1589
2024
  ];
1590
2025
  }
1591
2026
  if (provider === "cursor") {
@@ -1609,7 +2044,7 @@ function providerPermissionPrompt(provider, label, response, payload) {
1609
2044
  return `Missing Anthropic Admin read scopes for ${label} (${status}). Reconnect with organization cost report and Claude Code usage report read access. ${rawMessage}`;
1610
2045
  }
1611
2046
  if (provider === "github-copilot") {
1612
- return `Missing GitHub Copilot org or enterprise read scopes for ${label} (${status}). Reconnect with Copilot metrics and billing seats read access. ${rawMessage}`;
2047
+ return `Missing GitHub Copilot org or enterprise read scopes for ${label} (${status}). Reconnect with Copilot metrics, billing seats, and billing usage (Administration: read) access. ${rawMessage}`;
1613
2048
  }
1614
2049
  if (provider === "cursor") {
1615
2050
  return `Missing Cursor team admin read scopes for ${label} (${status}). Use Cursor Admin API access or fall back to Browser Account UI/manual export. ${rawMessage}`;
@@ -1697,6 +2132,236 @@ export function selectProviderFinancialHeadlineRecords(records) {
1697
2132
  : providerRecords;
1698
2133
  });
1699
2134
  }
2135
+ /**
2136
+ * Stable identity for one provider account (an OpenAI/Anthropic organization,
2137
+ * a Cursor team, a GitHub org/enterprise). Admin credentials are account-
2138
+ * scoped and multi-account setups are common, so records from different
2139
+ * accounts of one provider must coexist instead of replacing each other.
2140
+ *
2141
+ * The key prefers the explicit account flag the connector already requires
2142
+ * (--org/--enterprise/--account-id, which can share one credential); it
2143
+ * otherwise falls back to the user-chosen credential REFERENCE NAME
2144
+ * (e.g. "env:OPENAI_ADMIN_KEY_ORG2") — stable, printable, and never derived
2145
+ * from secret material. A provider-reported organization id would be
2146
+ * preferable, but the cost APIs aibill calls do not reliably return one
2147
+ * (the OpenAI costs request groups by project/line-item/api-key only), and a
2148
+ * sometimes-present key would split one account into two slices.
2149
+ */
2150
+ export function providerAccountKey(input) {
2151
+ if (input.org)
2152
+ return `org:${input.org}`;
2153
+ if (input.enterprise)
2154
+ return `enterprise:${input.enterprise}`;
2155
+ if (input.accountId)
2156
+ return `account:${input.accountId}`;
2157
+ return input.authReference;
2158
+ }
2159
+ /**
2160
+ * Deterministic short digest of the RAW account key. The slug alone is not
2161
+ * injective — cursor `--account-id "team a"` and `--account-id "team-a"`
2162
+ * both slug to `team-a` — so the record-id prefix carries this digest of the
2163
+ * raw identity: distinct account keys can never share a record-id namespace,
2164
+ * while the same key always regenerates the same digest (idempotent
2165
+ * re-sync). Never derived from secret material: account keys are reference
2166
+ * names and explicit account flags by construction.
2167
+ */
2168
+ function providerAccountKeyDigest(accountKey) {
2169
+ return createHash("sha256").update(accountKey, "utf8").digest("hex").slice(0, 8);
2170
+ }
2171
+ /** The deterministic record-id prefix for one account slice. */
2172
+ export function providerAccountRecordIdPrefix(accountKey) {
2173
+ return `${slugifySourceId(accountKey)}-${providerAccountKeyDigest(accountKey)}`;
2174
+ }
2175
+ /**
2176
+ * Stamp one sync's records with their account slice. The record id gains a
2177
+ * deterministic account prefix (slug + raw-key digest) so identical usage
2178
+ * buckets from two accounts of the same provider can never collide into one
2179
+ * row id — even for slug-equivalent account spellings — and re-syncing the
2180
+ * same account regenerates the same ids (idempotent replace).
2181
+ *
2182
+ * Migration note: slices tagged by the short-lived pre-digest format
2183
+ * (slug-only prefix) are superseded on their next re-sync — same-account
2184
+ * replacement keys on `source.account`, never on id shape — and any
2185
+ * colliding pre-digest rows already persisted are excluded fail-closed by
2186
+ * the id-conflict guard in {@link retainProviderRecordsForNewSync}.
2187
+ */
2188
+ export function tagProviderAccountRecords(records, accountKey) {
2189
+ const prefix = providerAccountRecordIdPrefix(accountKey);
2190
+ return records.map((record) => ({
2191
+ ...record,
2192
+ id: `${prefix}-${record.id}`,
2193
+ source: { ...record.source, account: accountKey }
2194
+ }));
2195
+ }
2196
+ /**
2197
+ * Records from a prior trusted snapshot that must survive a new sync of
2198
+ * `provider` + `accountKey`: every other provider's records, plus this
2199
+ * provider's records that belong to a DIFFERENT named account slice.
2200
+ * Re-syncing the same account replaces its own slice. Records with no account
2201
+ * label (synced before multi-account support) are replaced too — fail-closed:
2202
+ * they cannot be proven to come from a different account, and keeping them
2203
+ * could double-count the same organization.
2204
+ *
2205
+ * Id-conflict guard: a retained record may never share an id with a newly
2206
+ * synced record, nor with another retained record. Colliding ids describe
2207
+ * the same underlying row (possible only in state written by the pre-digest
2208
+ * prefix format, where slug-equivalent account spellings collided) — keeping
2209
+ * both would double-count, so the copy that is not part of the fresh sync is
2210
+ * dropped fail-closed.
2211
+ */
2212
+ export function retainProviderRecordsForNewSync(priorRecords, provider, accountKey, syncedRecords) {
2213
+ const syncedIds = new Set(syncedRecords.map((record) => record.id));
2214
+ const seenIds = new Set();
2215
+ return priorRecords.filter((record) => {
2216
+ const replacedSlice = record.source.provider === provider &&
2217
+ !(typeof record.source.account === "string" && record.source.account !== accountKey);
2218
+ if (replacedSlice)
2219
+ return false;
2220
+ if (syncedIds.has(record.id) || seenIds.has(record.id))
2221
+ return false;
2222
+ seenIds.add(record.id);
2223
+ return true;
2224
+ });
2225
+ }
2226
+ /** Group one provider's records into per-account slices for honest display. */
2227
+ export function providerAccountSlices(records, provider) {
2228
+ const slices = new Map();
2229
+ for (const record of records) {
2230
+ if (record.source.provider !== provider)
2231
+ continue;
2232
+ const key = record.source.account ?? null;
2233
+ const slice = slices.get(key) ?? { recordCount: 0, billedUsd: null };
2234
+ slice.recordCount += 1;
2235
+ if (record.costConfidence === "verified" && typeof record.amountUsd === "number") {
2236
+ slice.billedUsd = (slice.billedUsd ?? 0) + record.amountUsd;
2237
+ }
2238
+ slices.set(key, slice);
2239
+ }
2240
+ return [...slices.entries()]
2241
+ .map(([account, slice]) => ({ account, ...slice }))
2242
+ .sort((left, right) => (left.account ?? "").localeCompare(right.account ?? ""));
2243
+ }
2244
+ /**
2245
+ * A slice's record ids with their account prefix stripped — the provider-side
2246
+ * bucket identity. Understands the current slug+digest prefix and the
2247
+ * short-lived pre-digest slug-only prefix; unprefixed ids pass through.
2248
+ */
2249
+ function sliceInnerRecordId(id, accountKey) {
2250
+ const digestPrefix = `${providerAccountRecordIdPrefix(accountKey)}-`;
2251
+ if (id.startsWith(digestPrefix))
2252
+ return id.slice(digestPrefix.length);
2253
+ const slugPrefix = `${slugifySourceId(accountKey)}-`;
2254
+ if (id.startsWith(slugPrefix))
2255
+ return id.slice(slugPrefix.length);
2256
+ return id;
2257
+ }
2258
+ /**
2259
+ * Detect the same organization synced under two different references: when
2260
+ * two named slices of one provider hold IDENTICAL inner record ids (the ids
2261
+ * modulo their account prefixes), the provider almost certainly returned the
2262
+ * same data twice and the combined total double-counts. This is an honest
2263
+ * diagnostic, not a silent fix — the user chose both identities, so the user
2264
+ * removes one.
2265
+ */
2266
+ export function duplicateProviderAccountSliceWarnings(records, provider) {
2267
+ const innerIdsByAccount = new Map();
2268
+ for (const record of records) {
2269
+ if (record.source.provider !== provider)
2270
+ continue;
2271
+ const account = record.source.account;
2272
+ if (typeof account !== "string")
2273
+ continue;
2274
+ const inner = innerIdsByAccount.get(account) ?? new Set();
2275
+ inner.add(sliceInnerRecordId(record.id, account));
2276
+ innerIdsByAccount.set(account, inner);
2277
+ }
2278
+ const accounts = [...innerIdsByAccount.keys()].sort();
2279
+ const warnings = [];
2280
+ for (let leftIndex = 0; leftIndex < accounts.length; leftIndex += 1) {
2281
+ for (let rightIndex = leftIndex + 1; rightIndex < accounts.length; rightIndex += 1) {
2282
+ const left = innerIdsByAccount.get(accounts[leftIndex]);
2283
+ const right = innerIdsByAccount.get(accounts[rightIndex]);
2284
+ if (left.size === 0 || left.size !== right.size)
2285
+ continue;
2286
+ if (![...left].every((id) => right.has(id)))
2287
+ continue;
2288
+ warnings.push(`${provider} slices ${accounts[leftIndex]} and ${accounts[rightIndex]} contain identical records — ` +
2289
+ "likely the same organization under two references; the combined total counts it twice. " +
2290
+ `Remove one: npx aibill drop-slice --provider ${provider} --account "${accounts[rightIndex]}"`);
2291
+ }
2292
+ }
2293
+ return warnings;
2294
+ }
2295
+ /**
2296
+ * Honest notices for prior records a sync removed. Replacement is fail-closed
2297
+ * by design (unlabeled legacy rows, same-slice re-sync, id-collision guard),
2298
+ * but billed dollars must never disappear without a word: each dropped slice
2299
+ * is named with its record count and billed sum. A routine same-slice re-sync
2300
+ * that returns the same or more billed evidence stays quiet.
2301
+ */
2302
+ export function providerSliceReplacementNotices(input) {
2303
+ const retained = new Set(input.retainedRecords);
2304
+ const dropped = input.priorRecords.filter((record) => !retained.has(record));
2305
+ if (dropped.length === 0)
2306
+ return [];
2307
+ const notices = [];
2308
+ for (const slice of providerAccountSlices(dropped, input.provider)) {
2309
+ const billed = slice.billedUsd === null
2310
+ ? "no billed evidence"
2311
+ : `${formatProviderUsd(slice.billedUsd)} billed`;
2312
+ const rows = `${slice.recordCount} record${slice.recordCount === 1 ? "" : "s"}`;
2313
+ if (slice.account === null) {
2314
+ notices.push(`replaced prior unlabeled slice: ${billed} from ${rows} superseded`);
2315
+ continue;
2316
+ }
2317
+ if (slice.account === input.accountKey) {
2318
+ const reducesBilledEvidence = slice.billedUsd !== null &&
2319
+ (input.syncedBilledUsd === null || input.syncedBilledUsd + 0.005 < slice.billedUsd);
2320
+ if (!reducesBilledEvidence)
2321
+ continue;
2322
+ const newBilled = input.syncedBilledUsd === null
2323
+ ? "no billed evidence"
2324
+ : `billed ${formatProviderUsd(input.syncedBilledUsd)}`;
2325
+ notices.push(`replaced prior slice ${slice.account}: ${billed} from ${rows} superseded ` +
2326
+ `(this sync returned ${input.syncedRecordCount} record${input.syncedRecordCount === 1 ? "" : "s"}, ${newBilled})`);
2327
+ continue;
2328
+ }
2329
+ notices.push(`replaced prior slice ${slice.account}: ${billed} from ${rows} superseded (record ids collided with newer state)`);
2330
+ }
2331
+ return notices;
2332
+ }
2333
+ /**
2334
+ * Intersection of two claimed coverage windows — the interval every account
2335
+ * slice of a provider actually covers. Returns undefined when either window
2336
+ * is absent/malformed or the windows do not overlap (fail-closed: no window
2337
+ * is claimed rather than an overstated one).
2338
+ */
2339
+ export function intersectProviderCoverageIntervals(left, right) {
2340
+ if (!left || !right)
2341
+ return undefined;
2342
+ if (typeof left.coverageStart !== "string" || typeof left.coverageEnd !== "string" ||
2343
+ typeof right.coverageStart !== "string" || typeof right.coverageEnd !== "string") {
2344
+ return undefined;
2345
+ }
2346
+ const coverageStart = left.coverageStart > right.coverageStart
2347
+ ? left.coverageStart
2348
+ : right.coverageStart;
2349
+ const coverageEnd = left.coverageEnd < right.coverageEnd
2350
+ ? left.coverageEnd
2351
+ : right.coverageEnd;
2352
+ return coverageStart <= coverageEnd ? { coverageStart, coverageEnd } : undefined;
2353
+ }
2354
+ /**
2355
+ * Printable slice list, e.g.
2356
+ * `env:OPENAI_ADMIN_KEY (6 records, billed $0.81) + env:OPENAI_ADMIN_KEY_ORG2 (18 records, billed $8.66)`.
2357
+ */
2358
+ export function formatProviderAccountSlices(slices) {
2359
+ return slices.map((slice) => {
2360
+ const label = slice.account ?? "earlier sync (unlabeled account)";
2361
+ const billed = slice.billedUsd === null ? "" : `, billed ${formatProviderUsd(slice.billedUsd)}`;
2362
+ return `${label} (${slice.recordCount} record${slice.recordCount === 1 ? "" : "s"}${billed})`;
2363
+ }).join(" + ");
2364
+ }
1700
2365
  function sumAmounts(records) {
1701
2366
  const amounts = records
1702
2367
  .map((record) => record.amountUsd)