@agent-finops/core 0.8.1 → 0.9.1
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 +5 -3
- package/dist/actionPlanner.d.ts +140 -0
- package/dist/actionPlanner.js +938 -0
- package/dist/actionVerification.d.ts +1240 -0
- package/dist/actionVerification.js +1028 -0
- package/dist/activitySnapshot.d.ts +142 -50
- package/dist/activitySnapshot.js +145 -6
- package/dist/activitySnapshotCache.d.ts +8 -1
- package/dist/activitySnapshotCache.js +103 -7
- package/dist/agentDraftToken.d.ts +80 -0
- package/dist/agentDraftToken.js +188 -0
- package/dist/agentEconomicsReceipt.d.ts +74 -74
- package/dist/agentLoopContract.d.ts +27 -0
- package/dist/agentLoopContract.js +36 -0
- package/dist/glance.d.ts +27 -1
- package/dist/glance.js +151 -12
- package/dist/guidedAnswer.d.ts +51 -0
- package/dist/guidedAnswer.js +352 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +13 -1
- package/dist/localAgentFormats/gemini.js +2 -2
- package/dist/localAgentFormats/registry.js +6 -2
- package/dist/localAgentFormats/runtimeRegistry.js +5 -2
- package/dist/localAgentFormats/types.d.ts +2 -1
- package/dist/localAgentLogs.d.ts +362 -3
- package/dist/localAgentLogs.js +1964 -165
- package/dist/modelPricing.d.ts +1 -1
- package/dist/modelPricing.js +1 -1
- package/dist/projectEconomics.d.ts +617 -0
- package/dist/projectEconomics.js +620 -0
- package/dist/projectEconomicsBuilder.d.ts +89 -0
- package/dist/projectEconomicsBuilder.js +473 -0
- package/dist/projectIndexStore.d.ts +545 -0
- package/dist/projectIndexStore.js +606 -0
- package/dist/providerConnectors.d.ts +161 -1
- package/dist/providerConnectors.js +406 -11
- package/dist/qualitativeIndexCache.d.ts +494 -0
- package/dist/qualitativeIndexCache.js +930 -0
- package/dist/resultCard.d.ts +350 -0
- package/dist/resultCard.js +604 -0
- package/dist/runtimeCommands.d.ts +36 -0
- package/dist/runtimeCommands.js +50 -0
- package/dist/scanGuard.d.ts +3 -1
- package/dist/scanGuard.js +164 -4
- package/dist/schema.d.ts +33 -31
- package/dist/schema.js +9 -1
- package/dist/sessionVitals.d.ts +145 -0
- package/dist/sessionVitals.js +521 -0
- package/dist/toolInvocations.d.ts +40 -1
- package/dist/toolInvocations.js +101 -20
- package/package.json +1 -1
|
@@ -82,6 +82,64 @@ export type ProviderConnectorInput = {
|
|
|
82
82
|
accountId?: string;
|
|
83
83
|
fetcher?: Fetcher;
|
|
84
84
|
tokenResolver?: TokenResolver;
|
|
85
|
+
/**
|
|
86
|
+
* Operator-declared reconciliation anchor for this sync. Today only the
|
|
87
|
+
* Cursor connector consumes it; when absent, the Cursor connector also
|
|
88
|
+
* accepts the AI_SPEND_CURSOR_RECONCILE_* environment variables so the
|
|
89
|
+
* shipped CLI can run a reconciliation without new flags.
|
|
90
|
+
*/
|
|
91
|
+
reconciliation?: CursorReconciliationExpectation;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* A human-read billing anchor for one Cursor reconciliation run: the
|
|
95
|
+
* on-demand ("usage based pricing") total the operator read off the Cursor
|
|
96
|
+
* team dashboard or invoice for the CURRENT subscription cycle. The connector
|
|
97
|
+
* compares its own summed spendCents against this figure; only an in-run
|
|
98
|
+
* match within tolerance can produce verified financial evidence.
|
|
99
|
+
*/
|
|
100
|
+
export type CursorReconciliationExpectation = {
|
|
101
|
+
/** Dashboard/invoice on-demand total for the current cycle, in USD. */
|
|
102
|
+
expectedOnDemandUsd: number;
|
|
103
|
+
/**
|
|
104
|
+
* The billing-cycle start date shown next to that figure (YYYY-MM-DD).
|
|
105
|
+
* Must land within one calendar day of the UTC date of the API's
|
|
106
|
+
* subscriptionCycleStart, proving both numbers describe the same window.
|
|
107
|
+
*/
|
|
108
|
+
expectedCycleStartDate: string;
|
|
109
|
+
/**
|
|
110
|
+
* Optional absolute comparison tolerance in USD. Defaults to $0.01 (the
|
|
111
|
+
* dashboard rounds to cents). Clamped to at most 1% of the expected total
|
|
112
|
+
* so a huge tolerance can never rubber-stamp a mismatch.
|
|
113
|
+
*/
|
|
114
|
+
toleranceUsd?: number;
|
|
115
|
+
};
|
|
116
|
+
export type CursorReconciliationOutcome = {
|
|
117
|
+
status: "verified" | "mismatch" | "not_provable";
|
|
118
|
+
/** Product-authored, terminal-safe explanation of the outcome. */
|
|
119
|
+
note: string;
|
|
120
|
+
connectorTotalUsd?: number;
|
|
121
|
+
expectedOnDemandUsd?: number;
|
|
122
|
+
differenceUsd?: number;
|
|
123
|
+
toleranceUsd?: number;
|
|
124
|
+
/** Provider-reported cycle start for the reconciled window, ISO-8601. */
|
|
125
|
+
cycleStartIso?: string;
|
|
126
|
+
};
|
|
127
|
+
/** Environment variables the Cursor connector reads for a reconciliation run. */
|
|
128
|
+
export declare const cursorReconciliationEnvVars: {
|
|
129
|
+
readonly expectedUsd: "AI_SPEND_CURSOR_RECONCILE_EXPECTED_USD";
|
|
130
|
+
readonly cycleStart: "AI_SPEND_CURSOR_RECONCILE_CYCLE_START";
|
|
131
|
+
readonly toleranceUsd: "AI_SPEND_CURSOR_RECONCILE_TOLERANCE_USD";
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Read an operator-declared Cursor reconciliation anchor from the local
|
|
135
|
+
* environment. Absent variables mean "no reconciliation requested"; present
|
|
136
|
+
* but invalid variables fail closed with a reason (records stay estimated)
|
|
137
|
+
* instead of throwing, so a typo can never abort or silently verify a sync.
|
|
138
|
+
* Raw variable values are never echoed into the reason.
|
|
139
|
+
*/
|
|
140
|
+
export declare function parseCursorReconciliationEnv(env?: Record<string, string | undefined>): {
|
|
141
|
+
expectation?: CursorReconciliationExpectation;
|
|
142
|
+
invalidReason?: string;
|
|
85
143
|
};
|
|
86
144
|
export type ProviderConnectorResult = {
|
|
87
145
|
provider: string;
|
|
@@ -121,7 +179,7 @@ export declare function normalizeAnthropicClaudeCodeUsageResponse(response: unkn
|
|
|
121
179
|
export declare function normalizeGitHubCopilotSeatResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
122
180
|
export declare function normalizeAnthropicCostResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
123
181
|
export declare function normalizeGitHubCopilotMetricsResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
124
|
-
export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
182
|
+
export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions, reconciliation?: CursorReconciliationOutcome): UsageRecord[];
|
|
125
183
|
export declare function fetchProviderUsageRecords(input: ProviderConnectorInput): Promise<ProviderConnectorResult>;
|
|
126
184
|
export declare function summarizeProviderFinancials(records: UsageRecord[]): ProviderFinancialSummary;
|
|
127
185
|
export declare function providerFinancialCompleteness(records: UsageRecord[], coverage: ProviderCoverageStatus): ProviderConnectorResult["completeness"];
|
|
@@ -131,6 +189,108 @@ export declare function providerFinancialCompleteness(records: UsageRecord[], co
|
|
|
131
189
|
* spend headlines; callers should retain the original records for attribution.
|
|
132
190
|
*/
|
|
133
191
|
export declare function selectProviderFinancialHeadlineRecords(records: UsageRecord[]): UsageRecord[];
|
|
192
|
+
/** Inputs that determine which provider account (org/team) one sync reads. */
|
|
193
|
+
export type ProviderAccountKeyInput = {
|
|
194
|
+
provider: string;
|
|
195
|
+
authReference: string;
|
|
196
|
+
org?: string;
|
|
197
|
+
enterprise?: string;
|
|
198
|
+
accountId?: string;
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Stable identity for one provider account (an OpenAI/Anthropic organization,
|
|
202
|
+
* a Cursor team, a GitHub org/enterprise). Admin credentials are account-
|
|
203
|
+
* scoped and multi-account setups are common, so records from different
|
|
204
|
+
* accounts of one provider must coexist instead of replacing each other.
|
|
205
|
+
*
|
|
206
|
+
* The key prefers the explicit account flag the connector already requires
|
|
207
|
+
* (--org/--enterprise/--account-id, which can share one credential); it
|
|
208
|
+
* otherwise falls back to the user-chosen credential REFERENCE NAME
|
|
209
|
+
* (e.g. "env:OPENAI_ADMIN_KEY_ORG2") — stable, printable, and never derived
|
|
210
|
+
* from secret material. A provider-reported organization id would be
|
|
211
|
+
* preferable, but the cost APIs aibill calls do not reliably return one
|
|
212
|
+
* (the OpenAI costs request groups by project/line-item/api-key only), and a
|
|
213
|
+
* sometimes-present key would split one account into two slices.
|
|
214
|
+
*/
|
|
215
|
+
export declare function providerAccountKey(input: ProviderAccountKeyInput): string;
|
|
216
|
+
/** The deterministic record-id prefix for one account slice. */
|
|
217
|
+
export declare function providerAccountRecordIdPrefix(accountKey: string): string;
|
|
218
|
+
/**
|
|
219
|
+
* Stamp one sync's records with their account slice. The record id gains a
|
|
220
|
+
* deterministic account prefix (slug + raw-key digest) so identical usage
|
|
221
|
+
* buckets from two accounts of the same provider can never collide into one
|
|
222
|
+
* row id — even for slug-equivalent account spellings — and re-syncing the
|
|
223
|
+
* same account regenerates the same ids (idempotent replace).
|
|
224
|
+
*
|
|
225
|
+
* Migration note: slices tagged by the short-lived pre-digest format
|
|
226
|
+
* (slug-only prefix) are superseded on their next re-sync — same-account
|
|
227
|
+
* replacement keys on `source.account`, never on id shape — and any
|
|
228
|
+
* colliding pre-digest rows already persisted are excluded fail-closed by
|
|
229
|
+
* the id-conflict guard in {@link retainProviderRecordsForNewSync}.
|
|
230
|
+
*/
|
|
231
|
+
export declare function tagProviderAccountRecords(records: readonly UsageRecord[], accountKey: string): UsageRecord[];
|
|
232
|
+
/**
|
|
233
|
+
* Records from a prior trusted snapshot that must survive a new sync of
|
|
234
|
+
* `provider` + `accountKey`: every other provider's records, plus this
|
|
235
|
+
* provider's records that belong to a DIFFERENT named account slice.
|
|
236
|
+
* Re-syncing the same account replaces its own slice. Records with no account
|
|
237
|
+
* label (synced before multi-account support) are replaced too — fail-closed:
|
|
238
|
+
* they cannot be proven to come from a different account, and keeping them
|
|
239
|
+
* could double-count the same organization.
|
|
240
|
+
*
|
|
241
|
+
* Id-conflict guard: a retained record may never share an id with a newly
|
|
242
|
+
* synced record, nor with another retained record. Colliding ids describe
|
|
243
|
+
* the same underlying row (possible only in state written by the pre-digest
|
|
244
|
+
* prefix format, where slug-equivalent account spellings collided) — keeping
|
|
245
|
+
* both would double-count, so the copy that is not part of the fresh sync is
|
|
246
|
+
* dropped fail-closed.
|
|
247
|
+
*/
|
|
248
|
+
export declare function retainProviderRecordsForNewSync(priorRecords: readonly UsageRecord[], provider: string, accountKey: string, syncedRecords: readonly UsageRecord[]): UsageRecord[];
|
|
249
|
+
export type ProviderAccountSlice = {
|
|
250
|
+
/** Account key, or null for records synced before multi-account support. */
|
|
251
|
+
account: string | null;
|
|
252
|
+
recordCount: number;
|
|
253
|
+
/** Sum of this slice's verified provider-billed rows; null when none. */
|
|
254
|
+
billedUsd: number | null;
|
|
255
|
+
};
|
|
256
|
+
/** Group one provider's records into per-account slices for honest display. */
|
|
257
|
+
export declare function providerAccountSlices(records: readonly UsageRecord[], provider: string): ProviderAccountSlice[];
|
|
258
|
+
/**
|
|
259
|
+
* Detect the same organization synced under two different references: when
|
|
260
|
+
* two named slices of one provider hold IDENTICAL inner record ids (the ids
|
|
261
|
+
* modulo their account prefixes), the provider almost certainly returned the
|
|
262
|
+
* same data twice and the combined total double-counts. This is an honest
|
|
263
|
+
* diagnostic, not a silent fix — the user chose both identities, so the user
|
|
264
|
+
* removes one.
|
|
265
|
+
*/
|
|
266
|
+
export declare function duplicateProviderAccountSliceWarnings(records: readonly UsageRecord[], provider: string): string[];
|
|
267
|
+
/**
|
|
268
|
+
* Honest notices for prior records a sync removed. Replacement is fail-closed
|
|
269
|
+
* by design (unlabeled legacy rows, same-slice re-sync, id-collision guard),
|
|
270
|
+
* but billed dollars must never disappear without a word: each dropped slice
|
|
271
|
+
* is named with its record count and billed sum. A routine same-slice re-sync
|
|
272
|
+
* that returns the same or more billed evidence stays quiet.
|
|
273
|
+
*/
|
|
274
|
+
export declare function providerSliceReplacementNotices(input: {
|
|
275
|
+
provider: string;
|
|
276
|
+
accountKey: string;
|
|
277
|
+
priorRecords: readonly UsageRecord[];
|
|
278
|
+
retainedRecords: readonly UsageRecord[];
|
|
279
|
+
syncedRecordCount: number;
|
|
280
|
+
syncedBilledUsd: number | null;
|
|
281
|
+
}): string[];
|
|
282
|
+
/**
|
|
283
|
+
* Intersection of two claimed coverage windows — the interval every account
|
|
284
|
+
* slice of a provider actually covers. Returns undefined when either window
|
|
285
|
+
* is absent/malformed or the windows do not overlap (fail-closed: no window
|
|
286
|
+
* is claimed rather than an overstated one).
|
|
287
|
+
*/
|
|
288
|
+
export declare function intersectProviderCoverageIntervals(left: ProviderCoverageInterval | undefined, right: ProviderCoverageInterval | undefined): ProviderCoverageInterval | undefined;
|
|
289
|
+
/**
|
|
290
|
+
* Printable slice list, e.g.
|
|
291
|
+
* `env:OPENAI_ADMIN_KEY (6 records, billed $0.81) + env:OPENAI_ADMIN_KEY_ORG2 (18 records, billed $8.66)`.
|
|
292
|
+
*/
|
|
293
|
+
export declare function formatProviderAccountSlices(slices: readonly ProviderAccountSlice[]): string;
|
|
134
294
|
export declare function createProviderConnection(input: CreateProviderConnectionInput): ApprovedSource;
|
|
135
295
|
export declare function resolveTokenReference(reference: string, env?: Record<string, string | undefined>): string;
|
|
136
296
|
export {};
|
|
@@ -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
|
/**
|
|
@@ -19,6 +20,55 @@ export function isProviderAuthenticationError(error) {
|
|
|
19
20
|
return error instanceof ProviderConnectorError &&
|
|
20
21
|
error.code === "authentication_error";
|
|
21
22
|
}
|
|
23
|
+
/** Environment variables the Cursor connector reads for a reconciliation run. */
|
|
24
|
+
export const cursorReconciliationEnvVars = {
|
|
25
|
+
expectedUsd: "AI_SPEND_CURSOR_RECONCILE_EXPECTED_USD",
|
|
26
|
+
cycleStart: "AI_SPEND_CURSOR_RECONCILE_CYCLE_START",
|
|
27
|
+
toleranceUsd: "AI_SPEND_CURSOR_RECONCILE_TOLERANCE_USD"
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Read an operator-declared Cursor reconciliation anchor from the local
|
|
31
|
+
* environment. Absent variables mean "no reconciliation requested"; present
|
|
32
|
+
* but invalid variables fail closed with a reason (records stay estimated)
|
|
33
|
+
* instead of throwing, so a typo can never abort or silently verify a sync.
|
|
34
|
+
* Raw variable values are never echoed into the reason.
|
|
35
|
+
*/
|
|
36
|
+
export function parseCursorReconciliationEnv(env = process.env) {
|
|
37
|
+
const rawExpected = env[cursorReconciliationEnvVars.expectedUsd];
|
|
38
|
+
const rawCycleStart = env[cursorReconciliationEnvVars.cycleStart];
|
|
39
|
+
const rawTolerance = env[cursorReconciliationEnvVars.toleranceUsd];
|
|
40
|
+
if (rawExpected === undefined && rawCycleStart === undefined && rawTolerance === undefined) {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
if (rawExpected === undefined || rawCycleStart === undefined) {
|
|
44
|
+
return {
|
|
45
|
+
invalidReason: `both ${cursorReconciliationEnvVars.expectedUsd} and ${cursorReconciliationEnvVars.cycleStart} are required to request a reconciliation`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const expectedOnDemandUsd = Number(rawExpected.trim());
|
|
49
|
+
const toleranceUsd = rawTolerance === undefined ? undefined : Number(rawTolerance.trim());
|
|
50
|
+
const expectation = {
|
|
51
|
+
expectedOnDemandUsd,
|
|
52
|
+
expectedCycleStartDate: rawCycleStart.trim(),
|
|
53
|
+
...(toleranceUsd === undefined ? {} : { toleranceUsd })
|
|
54
|
+
};
|
|
55
|
+
const invalidReason = invalidCursorReconciliationExpectationReason(expectation);
|
|
56
|
+
return invalidReason ? { invalidReason } : { expectation };
|
|
57
|
+
}
|
|
58
|
+
function invalidCursorReconciliationExpectationReason(expectation) {
|
|
59
|
+
if (!Number.isFinite(expectation.expectedOnDemandUsd) || expectation.expectedOnDemandUsd <= 0) {
|
|
60
|
+
return `${cursorReconciliationEnvVars.expectedUsd} must be a positive USD amount read off the Cursor dashboard or invoice`;
|
|
61
|
+
}
|
|
62
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(expectation.expectedCycleStartDate) ||
|
|
63
|
+
!Number.isFinite(Date.parse(`${expectation.expectedCycleStartDate}T00:00:00Z`))) {
|
|
64
|
+
return `${cursorReconciliationEnvVars.cycleStart} must be the cycle start date shown on the dashboard, formatted YYYY-MM-DD`;
|
|
65
|
+
}
|
|
66
|
+
if (expectation.toleranceUsd !== undefined &&
|
|
67
|
+
(!Number.isFinite(expectation.toleranceUsd) || expectation.toleranceUsd < 0)) {
|
|
68
|
+
return `${cursorReconciliationEnvVars.toleranceUsd} must be a non-negative USD amount when set`;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
22
72
|
export function normalizeOpenAiCostResponse(response, options) {
|
|
23
73
|
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
24
74
|
const records = [];
|
|
@@ -399,10 +449,21 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
|
399
449
|
}
|
|
400
450
|
return records;
|
|
401
451
|
}
|
|
402
|
-
export function normalizeCursorSpendResponse(response, options) {
|
|
452
|
+
export function normalizeCursorSpendResponse(response, options, reconciliation) {
|
|
403
453
|
const users = extractArray(response, "teamMemberSpend");
|
|
404
454
|
const cycleStart = isRecord(response) ? numberValue(response.subscriptionCycleStart) : undefined;
|
|
405
455
|
const timestamp = typeof cycleStart === "number" ? new Date(cycleStart).toISOString() : new Date().toISOString();
|
|
456
|
+
// The Cursor connector's dollars are labeled estimated until an in-run
|
|
457
|
+
// reconciliation proves the connector total against a human-read dashboard
|
|
458
|
+
// or invoice figure for the same cycle. Only that evidence — never a
|
|
459
|
+
// hardcoded flip — can stamp these records "verified", and a mismatched or
|
|
460
|
+
// unprovable reconciliation fails closed back to estimated.
|
|
461
|
+
const reconciled = reconciliation?.status === "verified";
|
|
462
|
+
const confidence = reconciled ? "verified" : "estimated";
|
|
463
|
+
// Documented semantics: spendCents is "On-demand spend in cents for the
|
|
464
|
+
// current billing cycle" — seat fees and included-pool usage are excluded.
|
|
465
|
+
const baseOperation = "Cursor on-demand team spend (current billing cycle; excludes seat fees and included-pool usage)";
|
|
466
|
+
const operation = reconciled ? `${baseOperation}; ${reconciliation.note}` : baseOperation;
|
|
406
467
|
return users.flatMap((user) => {
|
|
407
468
|
if (!isRecord(user))
|
|
408
469
|
return [];
|
|
@@ -413,20 +474,17 @@ export function normalizeCursorSpendResponse(response, options) {
|
|
|
413
474
|
return [{
|
|
414
475
|
id: slugifySourceId(["cursor-spend", options.accountId, userId].filter(Boolean).join("-")),
|
|
415
476
|
timestamp,
|
|
416
|
-
|
|
417
|
-
// so its dollars are labeled estimated until reconciled against a real
|
|
418
|
-
// team's invoice. Never stamp "verified" on data we haven't verified.
|
|
419
|
-
source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence: "estimated", observedFrom: options.observedFrom },
|
|
477
|
+
source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence, observedFrom: options.observedFrom },
|
|
420
478
|
model: "cursor-team-usage",
|
|
421
479
|
inputTokens: 0,
|
|
422
480
|
outputTokens: 0,
|
|
423
481
|
amountUsd: cents / 100,
|
|
424
|
-
costConfidence:
|
|
482
|
+
costConfidence: confidence,
|
|
425
483
|
userId,
|
|
426
484
|
projectId: options.accountId,
|
|
427
485
|
providerCostType: "cursor_spend",
|
|
428
486
|
usageGranularity: "user_aggregate",
|
|
429
|
-
operation
|
|
487
|
+
operation
|
|
430
488
|
}];
|
|
431
489
|
});
|
|
432
490
|
}
|
|
@@ -571,8 +629,102 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
|
|
|
571
629
|
async function fetchCursor(input, token, fetcher, sourceId) {
|
|
572
630
|
const accountId = input.accountId ?? input.org ?? "cursor-team";
|
|
573
631
|
const spendFetch = await fetchCursorSpendPages(fetcher, token);
|
|
574
|
-
const
|
|
575
|
-
|
|
632
|
+
const requested = input.reconciliation
|
|
633
|
+
? { expectation: input.reconciliation, invalidReason: invalidCursorReconciliationExpectationReason(input.reconciliation) }
|
|
634
|
+
: parseCursorReconciliationEnv();
|
|
635
|
+
const reconciliation = assessCursorReconciliation(spendFetch, requested.expectation, requested.invalidReason);
|
|
636
|
+
const records = spendFetch.pages.flatMap((page) => normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId }, reconciliation));
|
|
637
|
+
const qa = qaSummary("cursor", [spendFetch]);
|
|
638
|
+
if (reconciliation) {
|
|
639
|
+
// The outcome must survive the persisted-QA round trip, so it rides in
|
|
640
|
+
// instructions (kept verbatim) and, on failure, in responseDrift.
|
|
641
|
+
qa.instructions = [...qa.instructions, `Reconciliation ${reconciliation.status}: ${reconciliation.note}`];
|
|
642
|
+
if (reconciliation.status !== "verified") {
|
|
643
|
+
qa.responseDrift.push({
|
|
644
|
+
label: "Cursor Admin API spend",
|
|
645
|
+
field: "teamMemberSpend[].spendCents (cycle total)",
|
|
646
|
+
issue: reconciliation.note
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return providerResult("cursor", sourceId, input.authReference, records, qa);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Compare the connector's summed current-cycle on-demand total against the
|
|
654
|
+
* operator-read dashboard/invoice figure. Every exit that is not an exact
|
|
655
|
+
* window-proven match inside the clamped tolerance fails closed: the records
|
|
656
|
+
* stay estimated and the note says exactly why. Returns undefined when no
|
|
657
|
+
* reconciliation was requested.
|
|
658
|
+
*/
|
|
659
|
+
function assessCursorReconciliation(spendFetch, expectation, invalidReason) {
|
|
660
|
+
if (!expectation && !invalidReason)
|
|
661
|
+
return undefined;
|
|
662
|
+
if (invalidReason || !expectation) {
|
|
663
|
+
return {
|
|
664
|
+
status: "not_provable",
|
|
665
|
+
note: `Cursor reconciliation input was rejected (${invalidReason ?? "missing expectation"}); records remain estimated.`
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (spendFetch.pagination.stoppedBecause !== "complete" || spendFetch.coverageIncomplete === true) {
|
|
669
|
+
return {
|
|
670
|
+
status: "not_provable",
|
|
671
|
+
note: `Cursor reconciliation requires a complete spend window; pagination stopped because "${spendFetch.pagination.stoppedBecause}" so a partial window cannot verify billed dollars. Records remain estimated.`
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const cycleStarts = spendFetch.pages.map((page) => isRecord(page) ? numberValue(page.subscriptionCycleStart) : undefined);
|
|
675
|
+
const cycleStart = cycleStarts[0];
|
|
676
|
+
if (typeof cycleStart !== "number" || cycleStarts.some((value) => value !== cycleStart)) {
|
|
677
|
+
return {
|
|
678
|
+
status: "not_provable",
|
|
679
|
+
note: "Cursor did not report one consistent subscriptionCycleStart across spend pages; the reconciliation window cannot be proven. Records remain estimated."
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const cycleStartIso = new Date(cycleStart).toISOString();
|
|
683
|
+
const apiCycleDate = cycleStartIso.slice(0, 10);
|
|
684
|
+
const declaredDateMs = Date.parse(`${expectation.expectedCycleStartDate}T00:00:00Z`);
|
|
685
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
686
|
+
if (!Number.isFinite(declaredDateMs) || Math.abs(Date.parse(`${apiCycleDate}T00:00:00Z`) - declaredDateMs) > dayMs) {
|
|
687
|
+
return {
|
|
688
|
+
status: "not_provable",
|
|
689
|
+
cycleStartIso,
|
|
690
|
+
note: `The declared cycle start ${expectation.expectedCycleStartDate} does not match the provider-reported cycle start ${apiCycleDate} (UTC); the dashboard figure and the connector read different windows. Records remain estimated.`
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const connectorTotalCents = spendFetch.pages.reduce((sum, page) => sum + extractArray(page, "teamMemberSpend").reduce((pageSum, member) => pageSum + (isRecord(member) ? numberValue(member.spendCents) ?? 0 : 0), 0), 0);
|
|
694
|
+
const connectorTotalUsd = connectorTotalCents / 100;
|
|
695
|
+
if (!(connectorTotalUsd > 0)) {
|
|
696
|
+
return {
|
|
697
|
+
status: "not_provable",
|
|
698
|
+
cycleStartIso,
|
|
699
|
+
connectorTotalUsd,
|
|
700
|
+
expectedOnDemandUsd: expectation.expectedOnDemandUsd,
|
|
701
|
+
note: "Cursor reconciliation needs a non-zero connector total; matching $0.00 against a dashboard figure proves nothing. Records remain estimated."
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
// Default $0.01 (dashboards round to cents); clamp to at most 1% of the
|
|
705
|
+
// expected figure so an oversized tolerance cannot manufacture a match.
|
|
706
|
+
const requestedTolerance = Math.max(expectation.toleranceUsd ?? 0.01, 0.01);
|
|
707
|
+
const toleranceUsd = Math.min(requestedTolerance, Math.max(0.01, expectation.expectedOnDemandUsd * 0.01));
|
|
708
|
+
const differenceUsd = Math.abs(connectorTotalUsd - expectation.expectedOnDemandUsd);
|
|
709
|
+
const shared = {
|
|
710
|
+
connectorTotalUsd,
|
|
711
|
+
expectedOnDemandUsd: expectation.expectedOnDemandUsd,
|
|
712
|
+
differenceUsd,
|
|
713
|
+
toleranceUsd,
|
|
714
|
+
cycleStartIso
|
|
715
|
+
};
|
|
716
|
+
if (differenceUsd <= toleranceUsd + 1e-9) {
|
|
717
|
+
return {
|
|
718
|
+
status: "verified",
|
|
719
|
+
...shared,
|
|
720
|
+
note: `reconciled to the operator-read dashboard/invoice on-demand total $${expectation.expectedOnDemandUsd.toFixed(2)} for the cycle starting ${apiCycleDate}: connector total $${connectorTotalUsd.toFixed(2)}, difference $${differenceUsd.toFixed(2)} within tolerance $${toleranceUsd.toFixed(2)}`
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
status: "mismatch",
|
|
725
|
+
...shared,
|
|
726
|
+
note: `Cursor reconciliation mismatch: connector on-demand total $${connectorTotalUsd.toFixed(2)} vs operator-read $${expectation.expectedOnDemandUsd.toFixed(2)} for the cycle starting ${apiCycleDate}; difference $${differenceUsd.toFixed(2)} exceeds tolerance $${toleranceUsd.toFixed(2)}. Records remain estimated until the totals agree.`
|
|
727
|
+
};
|
|
576
728
|
}
|
|
577
729
|
async function fetchCursorSpendPages(fetcher, token) {
|
|
578
730
|
const label = "Cursor Admin API spend";
|
|
@@ -1392,7 +1544,18 @@ function knownProviderFields(provider, label) {
|
|
|
1392
1544
|
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"]);
|
|
1393
1545
|
}
|
|
1394
1546
|
if (provider === "cursor") {
|
|
1395
|
-
return new Set([
|
|
1547
|
+
return new Set([
|
|
1548
|
+
...common,
|
|
1549
|
+
"teamMemberSpend", "teamMemberSpend[]", "teamMemberSpend[].userId", "teamMemberSpend[].email", "teamMemberSpend[].name", "teamMemberSpend[].role", "teamMemberSpend[].spendCents", "teamMemberSpend[].fastPremiumRequests", "teamMemberSpend[].hardLimitOverrideDollars",
|
|
1550
|
+
// Documented in the 2026 Admin API reference alongside spendCents.
|
|
1551
|
+
"teamMemberSpend[].overallSpendCents", "teamMemberSpend[].monthlyLimitDollars", "teamMemberSpend[].effectivePerUserLimitDollars",
|
|
1552
|
+
// Present in live responses and staff-acknowledged as a docs lag
|
|
1553
|
+
// (forum.cursor.com thread 162742, "docs for Get Spending Data are
|
|
1554
|
+
// behind the current API schema"). billingTier and the percent fields
|
|
1555
|
+
// are tiered-team-only and may be undefined elsewhere.
|
|
1556
|
+
"teamMemberSpend[].includedSpendCents", "teamMemberSpend[].profilePictureUrl", "teamMemberSpend[].billingTier", "teamMemberSpend[].autoPercentUsed", "teamMemberSpend[].apiPercentUsed", "teamMemberSpend[].totalPercentUsed",
|
|
1557
|
+
"subscriptionCycleStart", "totalMembers", "totalPages"
|
|
1558
|
+
]);
|
|
1396
1559
|
}
|
|
1397
1560
|
return new Set([...common]);
|
|
1398
1561
|
}
|
|
@@ -1429,7 +1592,9 @@ function providerInstructions(provider) {
|
|
|
1429
1592
|
if (provider === "cursor") {
|
|
1430
1593
|
return [
|
|
1431
1594
|
"Use a Cursor team admin API key reference, or fall back to Browser Account UI/manual export when API access is unavailable.",
|
|
1432
|
-
"
|
|
1595
|
+
"Cursor's 2026 docs list the Admin API under Enterprise teams; individual Pro/Ultra plans expose no billing API. Standard Admin API endpoints are rate-limited to 20 requests/minute per team.",
|
|
1596
|
+
"spendCents is on-demand spend for the current billing cycle; seat fees and included-pool usage are not in this total.",
|
|
1597
|
+
"Validate user-level spend against invoices before treating the source as finance-grade; set AI_SPEND_CURSOR_RECONCILE_EXPECTED_USD and AI_SPEND_CURSOR_RECONCILE_CYCLE_START to run an in-sync reconciliation."
|
|
1433
1598
|
];
|
|
1434
1599
|
}
|
|
1435
1600
|
return ["Use a local token reference only; never paste raw provider secrets into commands or reports."];
|
|
@@ -1533,6 +1698,236 @@ export function selectProviderFinancialHeadlineRecords(records) {
|
|
|
1533
1698
|
: providerRecords;
|
|
1534
1699
|
});
|
|
1535
1700
|
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Stable identity for one provider account (an OpenAI/Anthropic organization,
|
|
1703
|
+
* a Cursor team, a GitHub org/enterprise). Admin credentials are account-
|
|
1704
|
+
* scoped and multi-account setups are common, so records from different
|
|
1705
|
+
* accounts of one provider must coexist instead of replacing each other.
|
|
1706
|
+
*
|
|
1707
|
+
* The key prefers the explicit account flag the connector already requires
|
|
1708
|
+
* (--org/--enterprise/--account-id, which can share one credential); it
|
|
1709
|
+
* otherwise falls back to the user-chosen credential REFERENCE NAME
|
|
1710
|
+
* (e.g. "env:OPENAI_ADMIN_KEY_ORG2") — stable, printable, and never derived
|
|
1711
|
+
* from secret material. A provider-reported organization id would be
|
|
1712
|
+
* preferable, but the cost APIs aibill calls do not reliably return one
|
|
1713
|
+
* (the OpenAI costs request groups by project/line-item/api-key only), and a
|
|
1714
|
+
* sometimes-present key would split one account into two slices.
|
|
1715
|
+
*/
|
|
1716
|
+
export function providerAccountKey(input) {
|
|
1717
|
+
if (input.org)
|
|
1718
|
+
return `org:${input.org}`;
|
|
1719
|
+
if (input.enterprise)
|
|
1720
|
+
return `enterprise:${input.enterprise}`;
|
|
1721
|
+
if (input.accountId)
|
|
1722
|
+
return `account:${input.accountId}`;
|
|
1723
|
+
return input.authReference;
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Deterministic short digest of the RAW account key. The slug alone is not
|
|
1727
|
+
* injective — cursor `--account-id "team a"` and `--account-id "team-a"`
|
|
1728
|
+
* both slug to `team-a` — so the record-id prefix carries this digest of the
|
|
1729
|
+
* raw identity: distinct account keys can never share a record-id namespace,
|
|
1730
|
+
* while the same key always regenerates the same digest (idempotent
|
|
1731
|
+
* re-sync). Never derived from secret material: account keys are reference
|
|
1732
|
+
* names and explicit account flags by construction.
|
|
1733
|
+
*/
|
|
1734
|
+
function providerAccountKeyDigest(accountKey) {
|
|
1735
|
+
return createHash("sha256").update(accountKey, "utf8").digest("hex").slice(0, 8);
|
|
1736
|
+
}
|
|
1737
|
+
/** The deterministic record-id prefix for one account slice. */
|
|
1738
|
+
export function providerAccountRecordIdPrefix(accountKey) {
|
|
1739
|
+
return `${slugifySourceId(accountKey)}-${providerAccountKeyDigest(accountKey)}`;
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Stamp one sync's records with their account slice. The record id gains a
|
|
1743
|
+
* deterministic account prefix (slug + raw-key digest) so identical usage
|
|
1744
|
+
* buckets from two accounts of the same provider can never collide into one
|
|
1745
|
+
* row id — even for slug-equivalent account spellings — and re-syncing the
|
|
1746
|
+
* same account regenerates the same ids (idempotent replace).
|
|
1747
|
+
*
|
|
1748
|
+
* Migration note: slices tagged by the short-lived pre-digest format
|
|
1749
|
+
* (slug-only prefix) are superseded on their next re-sync — same-account
|
|
1750
|
+
* replacement keys on `source.account`, never on id shape — and any
|
|
1751
|
+
* colliding pre-digest rows already persisted are excluded fail-closed by
|
|
1752
|
+
* the id-conflict guard in {@link retainProviderRecordsForNewSync}.
|
|
1753
|
+
*/
|
|
1754
|
+
export function tagProviderAccountRecords(records, accountKey) {
|
|
1755
|
+
const prefix = providerAccountRecordIdPrefix(accountKey);
|
|
1756
|
+
return records.map((record) => ({
|
|
1757
|
+
...record,
|
|
1758
|
+
id: `${prefix}-${record.id}`,
|
|
1759
|
+
source: { ...record.source, account: accountKey }
|
|
1760
|
+
}));
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Records from a prior trusted snapshot that must survive a new sync of
|
|
1764
|
+
* `provider` + `accountKey`: every other provider's records, plus this
|
|
1765
|
+
* provider's records that belong to a DIFFERENT named account slice.
|
|
1766
|
+
* Re-syncing the same account replaces its own slice. Records with no account
|
|
1767
|
+
* label (synced before multi-account support) are replaced too — fail-closed:
|
|
1768
|
+
* they cannot be proven to come from a different account, and keeping them
|
|
1769
|
+
* could double-count the same organization.
|
|
1770
|
+
*
|
|
1771
|
+
* Id-conflict guard: a retained record may never share an id with a newly
|
|
1772
|
+
* synced record, nor with another retained record. Colliding ids describe
|
|
1773
|
+
* the same underlying row (possible only in state written by the pre-digest
|
|
1774
|
+
* prefix format, where slug-equivalent account spellings collided) — keeping
|
|
1775
|
+
* both would double-count, so the copy that is not part of the fresh sync is
|
|
1776
|
+
* dropped fail-closed.
|
|
1777
|
+
*/
|
|
1778
|
+
export function retainProviderRecordsForNewSync(priorRecords, provider, accountKey, syncedRecords) {
|
|
1779
|
+
const syncedIds = new Set(syncedRecords.map((record) => record.id));
|
|
1780
|
+
const seenIds = new Set();
|
|
1781
|
+
return priorRecords.filter((record) => {
|
|
1782
|
+
const replacedSlice = record.source.provider === provider &&
|
|
1783
|
+
!(typeof record.source.account === "string" && record.source.account !== accountKey);
|
|
1784
|
+
if (replacedSlice)
|
|
1785
|
+
return false;
|
|
1786
|
+
if (syncedIds.has(record.id) || seenIds.has(record.id))
|
|
1787
|
+
return false;
|
|
1788
|
+
seenIds.add(record.id);
|
|
1789
|
+
return true;
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
/** Group one provider's records into per-account slices for honest display. */
|
|
1793
|
+
export function providerAccountSlices(records, provider) {
|
|
1794
|
+
const slices = new Map();
|
|
1795
|
+
for (const record of records) {
|
|
1796
|
+
if (record.source.provider !== provider)
|
|
1797
|
+
continue;
|
|
1798
|
+
const key = record.source.account ?? null;
|
|
1799
|
+
const slice = slices.get(key) ?? { recordCount: 0, billedUsd: null };
|
|
1800
|
+
slice.recordCount += 1;
|
|
1801
|
+
if (record.costConfidence === "verified" && typeof record.amountUsd === "number") {
|
|
1802
|
+
slice.billedUsd = (slice.billedUsd ?? 0) + record.amountUsd;
|
|
1803
|
+
}
|
|
1804
|
+
slices.set(key, slice);
|
|
1805
|
+
}
|
|
1806
|
+
return [...slices.entries()]
|
|
1807
|
+
.map(([account, slice]) => ({ account, ...slice }))
|
|
1808
|
+
.sort((left, right) => (left.account ?? "").localeCompare(right.account ?? ""));
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* A slice's record ids with their account prefix stripped — the provider-side
|
|
1812
|
+
* bucket identity. Understands the current slug+digest prefix and the
|
|
1813
|
+
* short-lived pre-digest slug-only prefix; unprefixed ids pass through.
|
|
1814
|
+
*/
|
|
1815
|
+
function sliceInnerRecordId(id, accountKey) {
|
|
1816
|
+
const digestPrefix = `${providerAccountRecordIdPrefix(accountKey)}-`;
|
|
1817
|
+
if (id.startsWith(digestPrefix))
|
|
1818
|
+
return id.slice(digestPrefix.length);
|
|
1819
|
+
const slugPrefix = `${slugifySourceId(accountKey)}-`;
|
|
1820
|
+
if (id.startsWith(slugPrefix))
|
|
1821
|
+
return id.slice(slugPrefix.length);
|
|
1822
|
+
return id;
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Detect the same organization synced under two different references: when
|
|
1826
|
+
* two named slices of one provider hold IDENTICAL inner record ids (the ids
|
|
1827
|
+
* modulo their account prefixes), the provider almost certainly returned the
|
|
1828
|
+
* same data twice and the combined total double-counts. This is an honest
|
|
1829
|
+
* diagnostic, not a silent fix — the user chose both identities, so the user
|
|
1830
|
+
* removes one.
|
|
1831
|
+
*/
|
|
1832
|
+
export function duplicateProviderAccountSliceWarnings(records, provider) {
|
|
1833
|
+
const innerIdsByAccount = new Map();
|
|
1834
|
+
for (const record of records) {
|
|
1835
|
+
if (record.source.provider !== provider)
|
|
1836
|
+
continue;
|
|
1837
|
+
const account = record.source.account;
|
|
1838
|
+
if (typeof account !== "string")
|
|
1839
|
+
continue;
|
|
1840
|
+
const inner = innerIdsByAccount.get(account) ?? new Set();
|
|
1841
|
+
inner.add(sliceInnerRecordId(record.id, account));
|
|
1842
|
+
innerIdsByAccount.set(account, inner);
|
|
1843
|
+
}
|
|
1844
|
+
const accounts = [...innerIdsByAccount.keys()].sort();
|
|
1845
|
+
const warnings = [];
|
|
1846
|
+
for (let leftIndex = 0; leftIndex < accounts.length; leftIndex += 1) {
|
|
1847
|
+
for (let rightIndex = leftIndex + 1; rightIndex < accounts.length; rightIndex += 1) {
|
|
1848
|
+
const left = innerIdsByAccount.get(accounts[leftIndex]);
|
|
1849
|
+
const right = innerIdsByAccount.get(accounts[rightIndex]);
|
|
1850
|
+
if (left.size === 0 || left.size !== right.size)
|
|
1851
|
+
continue;
|
|
1852
|
+
if (![...left].every((id) => right.has(id)))
|
|
1853
|
+
continue;
|
|
1854
|
+
warnings.push(`${provider} slices ${accounts[leftIndex]} and ${accounts[rightIndex]} contain identical records — ` +
|
|
1855
|
+
"likely the same organization under two references; the combined total counts it twice. " +
|
|
1856
|
+
`Remove one: npx aibill drop-slice --provider ${provider} --account "${accounts[rightIndex]}"`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
return warnings;
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Honest notices for prior records a sync removed. Replacement is fail-closed
|
|
1863
|
+
* by design (unlabeled legacy rows, same-slice re-sync, id-collision guard),
|
|
1864
|
+
* but billed dollars must never disappear without a word: each dropped slice
|
|
1865
|
+
* is named with its record count and billed sum. A routine same-slice re-sync
|
|
1866
|
+
* that returns the same or more billed evidence stays quiet.
|
|
1867
|
+
*/
|
|
1868
|
+
export function providerSliceReplacementNotices(input) {
|
|
1869
|
+
const retained = new Set(input.retainedRecords);
|
|
1870
|
+
const dropped = input.priorRecords.filter((record) => !retained.has(record));
|
|
1871
|
+
if (dropped.length === 0)
|
|
1872
|
+
return [];
|
|
1873
|
+
const notices = [];
|
|
1874
|
+
for (const slice of providerAccountSlices(dropped, input.provider)) {
|
|
1875
|
+
const billed = slice.billedUsd === null
|
|
1876
|
+
? "no billed evidence"
|
|
1877
|
+
: `${formatProviderUsd(slice.billedUsd)} billed`;
|
|
1878
|
+
const rows = `${slice.recordCount} record${slice.recordCount === 1 ? "" : "s"}`;
|
|
1879
|
+
if (slice.account === null) {
|
|
1880
|
+
notices.push(`replaced prior unlabeled slice: ${billed} from ${rows} superseded`);
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
if (slice.account === input.accountKey) {
|
|
1884
|
+
const reducesBilledEvidence = slice.billedUsd !== null &&
|
|
1885
|
+
(input.syncedBilledUsd === null || input.syncedBilledUsd + 0.005 < slice.billedUsd);
|
|
1886
|
+
if (!reducesBilledEvidence)
|
|
1887
|
+
continue;
|
|
1888
|
+
const newBilled = input.syncedBilledUsd === null
|
|
1889
|
+
? "no billed evidence"
|
|
1890
|
+
: `billed ${formatProviderUsd(input.syncedBilledUsd)}`;
|
|
1891
|
+
notices.push(`replaced prior slice ${slice.account}: ${billed} from ${rows} superseded ` +
|
|
1892
|
+
`(this sync returned ${input.syncedRecordCount} record${input.syncedRecordCount === 1 ? "" : "s"}, ${newBilled})`);
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
notices.push(`replaced prior slice ${slice.account}: ${billed} from ${rows} superseded (record ids collided with newer state)`);
|
|
1896
|
+
}
|
|
1897
|
+
return notices;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Intersection of two claimed coverage windows — the interval every account
|
|
1901
|
+
* slice of a provider actually covers. Returns undefined when either window
|
|
1902
|
+
* is absent/malformed or the windows do not overlap (fail-closed: no window
|
|
1903
|
+
* is claimed rather than an overstated one).
|
|
1904
|
+
*/
|
|
1905
|
+
export function intersectProviderCoverageIntervals(left, right) {
|
|
1906
|
+
if (!left || !right)
|
|
1907
|
+
return undefined;
|
|
1908
|
+
if (typeof left.coverageStart !== "string" || typeof left.coverageEnd !== "string" ||
|
|
1909
|
+
typeof right.coverageStart !== "string" || typeof right.coverageEnd !== "string") {
|
|
1910
|
+
return undefined;
|
|
1911
|
+
}
|
|
1912
|
+
const coverageStart = left.coverageStart > right.coverageStart
|
|
1913
|
+
? left.coverageStart
|
|
1914
|
+
: right.coverageStart;
|
|
1915
|
+
const coverageEnd = left.coverageEnd < right.coverageEnd
|
|
1916
|
+
? left.coverageEnd
|
|
1917
|
+
: right.coverageEnd;
|
|
1918
|
+
return coverageStart <= coverageEnd ? { coverageStart, coverageEnd } : undefined;
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Printable slice list, e.g.
|
|
1922
|
+
* `env:OPENAI_ADMIN_KEY (6 records, billed $0.81) + env:OPENAI_ADMIN_KEY_ORG2 (18 records, billed $8.66)`.
|
|
1923
|
+
*/
|
|
1924
|
+
export function formatProviderAccountSlices(slices) {
|
|
1925
|
+
return slices.map((slice) => {
|
|
1926
|
+
const label = slice.account ?? "earlier sync (unlabeled account)";
|
|
1927
|
+
const billed = slice.billedUsd === null ? "" : `, billed ${formatProviderUsd(slice.billedUsd)}`;
|
|
1928
|
+
return `${label} (${slice.recordCount} record${slice.recordCount === 1 ? "" : "s"}${billed})`;
|
|
1929
|
+
}).join(" + ");
|
|
1930
|
+
}
|
|
1536
1931
|
function sumAmounts(records) {
|
|
1537
1932
|
const amounts = records
|
|
1538
1933
|
.map((record) => record.amountUsd)
|