@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.
- package/README.md +2 -2
- package/dist/agentDraftToken.d.ts +80 -0
- package/dist/agentDraftToken.js +188 -0
- package/dist/agentLoopContract.d.ts +27 -0
- package/dist/agentLoopContract.js +36 -0
- package/dist/guidedAnswer.d.ts +51 -0
- package/dist/guidedAnswer.js +352 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/providerConnectors.d.ts +183 -0
- package/dist/providerConnectors.js +677 -12
- package/dist/receiptShare.d.ts +185 -0
- package/dist/receiptShare.js +118 -0
- package/dist/runtimeCommands.d.ts +15 -0
- package/dist/runtimeCommands.js +23 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +9 -1
- package/dist/sourceRegistry.js +10 -3
- package/package.json +2 -1
|
@@ -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
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1588
|
-
"
|
|
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
|
|
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)
|