@agent-finops/core 0.5.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/discovery.d.ts +6 -2
- package/dist/discovery.js +36 -14
- package/dist/glance.d.ts +6 -2
- package/dist/glance.js +62 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/localAgentLogs.d.ts +38 -2
- package/dist/localAgentLogs.js +184 -20
- package/dist/modelPricing.js +0 -1
- package/dist/providerConnectors.d.ts +3 -2
- package/dist/providerConnectors.js +665 -89
- package/dist/sampleData.js +4 -3
- package/dist/schema.d.ts +31 -29
- package/dist/schema.js +27 -3
- package/dist/sourceRegistry.d.ts +30 -5
- package/dist/sourceRegistry.js +250 -21
- package/dist/sourceStatus.d.ts +65 -0
- package/dist/sourceStatus.js +147 -0
- package/dist/stateTrust.d.ts +37 -0
- package/dist/stateTrust.js +277 -0
- package/package.json +1 -1
- package/samples/openai-usage.csv +2 -2
|
@@ -3,20 +3,33 @@ import { redactSecrets } from "./discovery.js";
|
|
|
3
3
|
export function normalizeOpenAiCostResponse(response, options) {
|
|
4
4
|
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
5
5
|
const records = [];
|
|
6
|
-
for (const
|
|
7
|
-
|
|
6
|
+
for (const bucketValue of data) {
|
|
7
|
+
if (!isRecord(bucketValue))
|
|
8
|
+
continue;
|
|
9
|
+
const startTime = validEpochSeconds(bucketValue.start_time);
|
|
10
|
+
if (typeof startTime !== "number")
|
|
11
|
+
continue;
|
|
8
12
|
const timestamp = new Date(startTime * 1000).toISOString();
|
|
9
|
-
|
|
13
|
+
const results = Array.isArray(bucketValue.results) ? bucketValue.results : [];
|
|
14
|
+
for (const resultValue of results) {
|
|
15
|
+
if (!isRecord(resultValue))
|
|
16
|
+
continue;
|
|
17
|
+
const amount = isRecord(resultValue.amount) ? resultValue.amount : undefined;
|
|
18
|
+
const currency = amount?.currency === undefined
|
|
19
|
+
? "usd"
|
|
20
|
+
: typeof amount.currency === "string"
|
|
21
|
+
? amount.currency.toLowerCase()
|
|
22
|
+
: undefined;
|
|
10
23
|
// The live API returns amount.value as a decimal STRING (dollars);
|
|
11
24
|
// accept both string and number.
|
|
12
|
-
const amountUsd =
|
|
13
|
-
? parseDollarUsd(
|
|
25
|
+
const amountUsd = currency === "usd"
|
|
26
|
+
? parseDollarUsd(amount?.value)
|
|
14
27
|
: undefined;
|
|
15
28
|
if (typeof amountUsd !== "number")
|
|
16
29
|
continue;
|
|
17
|
-
const lineItem =
|
|
18
|
-
const projectId =
|
|
19
|
-
const apiKeyId =
|
|
30
|
+
const lineItem = stringValue(resultValue.line_item) ?? "OpenAI organization costs";
|
|
31
|
+
const projectId = stringValue(resultValue.project_id);
|
|
32
|
+
const apiKeyId = stringValue(resultValue.api_key_id);
|
|
20
33
|
records.push({
|
|
21
34
|
id: slugifySourceId(["openai-costs", String(startTime), projectId, apiKeyId, lineItem].filter(Boolean).join("-")),
|
|
22
35
|
timestamp,
|
|
@@ -36,7 +49,7 @@ export function normalizeOpenAiCostResponse(response, options) {
|
|
|
36
49
|
apiKeyId,
|
|
37
50
|
providerCostType: "openai_cost",
|
|
38
51
|
usageGranularity: "billing_bucket",
|
|
39
|
-
quantity:
|
|
52
|
+
quantity: nonNegativeNumberValue(resultValue.quantity),
|
|
40
53
|
operation: lineItem
|
|
41
54
|
});
|
|
42
55
|
}
|
|
@@ -54,12 +67,12 @@ export function normalizeOpenAiUsageResponse(response, options) {
|
|
|
54
67
|
const userId = result.user_id ?? undefined;
|
|
55
68
|
const apiKeyId = result.api_key_id ?? undefined;
|
|
56
69
|
const model = result.model ?? "openai-usage";
|
|
57
|
-
const inputTokens =
|
|
58
|
-
const outputTokens =
|
|
59
|
-
const cachedTokens =
|
|
60
|
-
const audioInputTokens =
|
|
61
|
-
const audioOutputTokens =
|
|
62
|
-
const requestCount =
|
|
70
|
+
const inputTokens = nonNegativeIntegerValue(result.input_tokens) ?? 0;
|
|
71
|
+
const outputTokens = nonNegativeIntegerValue(result.output_tokens) ?? 0;
|
|
72
|
+
const cachedTokens = nonNegativeIntegerValue(result.input_cached_tokens) ?? 0;
|
|
73
|
+
const audioInputTokens = nonNegativeIntegerValue(result.input_audio_tokens) ?? 0;
|
|
74
|
+
const audioOutputTokens = nonNegativeIntegerValue(result.output_audio_tokens) ?? 0;
|
|
75
|
+
const requestCount = nonNegativeIntegerValue(result.num_model_requests);
|
|
63
76
|
if (inputTokens + outputTokens + audioInputTokens + audioOutputTokens === 0 && typeof requestCount !== "number")
|
|
64
77
|
continue;
|
|
65
78
|
records.push({
|
|
@@ -76,7 +89,7 @@ export function normalizeOpenAiUsageResponse(response, options) {
|
|
|
76
89
|
apiKeyId,
|
|
77
90
|
providerCostType: "openai_usage_evidence",
|
|
78
91
|
usageGranularity: "usage_bucket",
|
|
79
|
-
quantity:
|
|
92
|
+
quantity: requestCount,
|
|
80
93
|
operation: "OpenAI completions usage evidence"
|
|
81
94
|
});
|
|
82
95
|
}
|
|
@@ -94,11 +107,11 @@ export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
|
|
|
94
107
|
const userId = stringValue(actor.email_address) ?? stringValue(actor.api_key_name) ?? stringValue(actor.id) ?? "unknown-claude-code-actor";
|
|
95
108
|
const core = isRecord(row.core_metrics) ? row.core_metrics : {};
|
|
96
109
|
const lines = isRecord(core.lines_of_code) ? core.lines_of_code : {};
|
|
97
|
-
const sessions =
|
|
98
|
-
const added =
|
|
99
|
-
const removed =
|
|
100
|
-
const commits =
|
|
101
|
-
const prs =
|
|
110
|
+
const sessions = nonNegativeIntegerValue(core.num_sessions) ?? 0;
|
|
111
|
+
const added = nonNegativeIntegerValue(lines.added) ?? 0;
|
|
112
|
+
const removed = nonNegativeIntegerValue(lines.removed) ?? 0;
|
|
113
|
+
const commits = nonNegativeIntegerValue(core.commits_by_claude_code) ?? 0;
|
|
114
|
+
const prs = nonNegativeIntegerValue(core.pull_requests_by_claude_code) ?? 0;
|
|
102
115
|
const organizationId = stringValue(row.organization_id) ?? options.accountId;
|
|
103
116
|
const modelBreakdown = Array.isArray(row.model_breakdown) ? row.model_breakdown : [];
|
|
104
117
|
for (const item of modelBreakdown) {
|
|
@@ -116,8 +129,8 @@ export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
|
|
|
116
129
|
timestamp: new Date(`${date}T00:00:00Z`).toISOString(),
|
|
117
130
|
source: { id: options.sourceId, name: "Anthropic Claude Code Usage Report", provider: "anthropic", confidence: "estimated", observedFrom: options.observedFrom },
|
|
118
131
|
model,
|
|
119
|
-
inputTokens: (
|
|
120
|
-
outputTokens:
|
|
132
|
+
inputTokens: (nonNegativeIntegerValue(tokens.input) ?? 0) + (nonNegativeIntegerValue(tokens.cache_read) ?? 0) + (nonNegativeIntegerValue(tokens.cache_creation) ?? 0),
|
|
133
|
+
outputTokens: nonNegativeIntegerValue(tokens.output) ?? 0,
|
|
121
134
|
amountUsd,
|
|
122
135
|
costConfidence: "estimated",
|
|
123
136
|
userId,
|
|
@@ -133,8 +146,6 @@ export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
|
|
|
133
146
|
}
|
|
134
147
|
export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
135
148
|
const seats = extractArray(response, "seats");
|
|
136
|
-
const plan = stringValue(isRecord(response) ? response.plan_type : undefined) ?? "business";
|
|
137
|
-
const seatUsd = plan === "enterprise" ? 39 : 19;
|
|
138
149
|
const timestamp = new Date().toISOString();
|
|
139
150
|
return seats.flatMap((seat) => {
|
|
140
151
|
if (!isRecord(seat))
|
|
@@ -143,6 +154,12 @@ export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
|
143
154
|
const userId = stringValue(assignee.login) ?? stringValue(assignee.email) ?? stringValue(seat.login) ?? stringValue(seat.id);
|
|
144
155
|
if (!userId)
|
|
145
156
|
return [];
|
|
157
|
+
// The current GitHub seat schema reports plan_type on each seat. Never
|
|
158
|
+
// inherit a top-level value: an enterprise can contain mixed Business and
|
|
159
|
+
// Enterprise organizations, and an unknown tier is evidence, not $19.
|
|
160
|
+
const reportedPlan = stringValue(seat.plan_type)?.toLowerCase();
|
|
161
|
+
const plan = reportedPlan === "business" || reportedPlan === "enterprise" ? reportedPlan : "unknown";
|
|
162
|
+
const seatUsd = plan === "enterprise" ? 39 : plan === "business" ? 19 : null;
|
|
146
163
|
const lastActivity = stringValue(seat.last_activity_at);
|
|
147
164
|
return [{
|
|
148
165
|
id: slugifySourceId(["github-copilot-seat", options.accountId, userId, plan].filter(Boolean).join("-")),
|
|
@@ -152,7 +169,7 @@ export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
|
152
169
|
inputTokens: 0,
|
|
153
170
|
outputTokens: 0,
|
|
154
171
|
amountUsd: seatUsd,
|
|
155
|
-
costConfidence: "estimated",
|
|
172
|
+
costConfidence: seatUsd === null ? "missing" : "estimated",
|
|
156
173
|
userId,
|
|
157
174
|
projectId: options.accountId,
|
|
158
175
|
providerCostType: "copilot_seat_reconciliation",
|
|
@@ -165,20 +182,33 @@ export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
|
165
182
|
export function normalizeAnthropicCostResponse(response, options) {
|
|
166
183
|
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
167
184
|
const records = [];
|
|
168
|
-
for (const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
for (const bucketValue of data) {
|
|
186
|
+
if (!isRecord(bucketValue))
|
|
187
|
+
continue;
|
|
188
|
+
const timestamp = validDateTimeString(bucketValue.starting_at);
|
|
189
|
+
if (!timestamp)
|
|
190
|
+
continue;
|
|
191
|
+
const results = Array.isArray(bucketValue.results) ? bucketValue.results : [];
|
|
192
|
+
for (const resultValue of results) {
|
|
193
|
+
if (!isRecord(resultValue))
|
|
194
|
+
continue;
|
|
195
|
+
const currency = resultValue.currency === undefined
|
|
196
|
+
? "usd"
|
|
197
|
+
: typeof resultValue.currency === "string"
|
|
198
|
+
? resultValue.currency.toLowerCase()
|
|
199
|
+
: undefined;
|
|
172
200
|
if (currency !== "usd")
|
|
173
201
|
continue;
|
|
174
|
-
const amountUsd = parseMinorUsd(
|
|
202
|
+
const amountUsd = parseMinorUsd(resultValue.amount);
|
|
175
203
|
if (typeof amountUsd !== "number")
|
|
176
204
|
continue;
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
205
|
+
const costType = stringValue(resultValue.cost_type);
|
|
206
|
+
const description = stringValue(resultValue.description) ?? costType ?? "Anthropic organization costs";
|
|
207
|
+
const model = stringValue(resultValue.model) ?? description;
|
|
208
|
+
const workspaceId = stringValue(resultValue.workspace_id);
|
|
209
|
+
const tokenType = stringValue(resultValue.token_type);
|
|
180
210
|
records.push({
|
|
181
|
-
id: slugifySourceId(["anthropic-costs", timestamp, workspaceId, model,
|
|
211
|
+
id: slugifySourceId(["anthropic-costs", timestamp, workspaceId, model, tokenType ?? costType].filter(Boolean).join("-")),
|
|
182
212
|
timestamp: new Date(timestamp).toISOString(),
|
|
183
213
|
source: {
|
|
184
214
|
id: options.sourceId,
|
|
@@ -194,7 +224,7 @@ export function normalizeAnthropicCostResponse(response, options) {
|
|
|
194
224
|
costConfidence: "verified",
|
|
195
225
|
projectId: workspaceId,
|
|
196
226
|
workspaceId,
|
|
197
|
-
providerCostType:
|
|
227
|
+
providerCostType: costType ?? "anthropic_cost",
|
|
198
228
|
usageGranularity: "billing_bucket",
|
|
199
229
|
operation: description
|
|
200
230
|
});
|
|
@@ -239,8 +269,8 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
|
239
269
|
timestamp,
|
|
240
270
|
source: { id: options.sourceId, name: "GitHub Copilot metrics API", provider: "github-copilot", confidence: "verified", observedFrom: options.observedFrom },
|
|
241
271
|
model: "github-copilot-cli",
|
|
242
|
-
inputTokens:
|
|
243
|
-
outputTokens:
|
|
272
|
+
inputTokens: nonNegativeIntegerValue(tokenUsage?.prompt_tokens_sum) ?? 0,
|
|
273
|
+
outputTokens: nonNegativeIntegerValue(tokenUsage?.output_tokens_sum) ?? 0,
|
|
244
274
|
amountUsd: null,
|
|
245
275
|
costConfidence: "missing",
|
|
246
276
|
projectId: options.accountId,
|
|
@@ -253,13 +283,14 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
|
253
283
|
return records;
|
|
254
284
|
}
|
|
255
285
|
export function normalizeCursorSpendResponse(response, options) {
|
|
256
|
-
const users = extractArray(response, "
|
|
257
|
-
const
|
|
286
|
+
const users = extractArray(response, "teamMemberSpend");
|
|
287
|
+
const cycleStart = isRecord(response) ? numberValue(response.subscriptionCycleStart) : undefined;
|
|
288
|
+
const timestamp = typeof cycleStart === "number" ? new Date(cycleStart).toISOString() : new Date().toISOString();
|
|
258
289
|
return users.flatMap((user) => {
|
|
259
290
|
if (!isRecord(user))
|
|
260
291
|
return [];
|
|
261
|
-
const userId = stringValue(user.email) ?? stringValue(user.
|
|
262
|
-
const cents = numberValue(user.spendCents)
|
|
292
|
+
const userId = stringValue(user.email) ?? stringValue(user.userId);
|
|
293
|
+
const cents = numberValue(user.spendCents);
|
|
263
294
|
if (!userId || typeof cents !== "number")
|
|
264
295
|
return [];
|
|
265
296
|
return [{
|
|
@@ -286,19 +317,75 @@ export async function fetchProviderUsageRecords(input) {
|
|
|
286
317
|
const token = (input.tokenResolver ?? defaultTokenResolver)(input.authReference);
|
|
287
318
|
const fetcher = input.fetcher ?? defaultFetcher;
|
|
288
319
|
const sourceId = input.sourceId ?? `${input.provider}-provider-api`;
|
|
289
|
-
|
|
290
|
-
|
|
320
|
+
const credentialVariants = resolvedCredentialVariants(input.provider, token);
|
|
321
|
+
try {
|
|
322
|
+
if (input.provider === "openai") {
|
|
323
|
+
return redactResolvedCredentialValue(await fetchOpenAi(input, token, fetcher, sourceId), credentialVariants);
|
|
324
|
+
}
|
|
325
|
+
if (input.provider === "anthropic") {
|
|
326
|
+
return redactResolvedCredentialValue(await fetchAnthropic(input, token, fetcher, sourceId), credentialVariants);
|
|
327
|
+
}
|
|
328
|
+
if (input.provider === "github-copilot") {
|
|
329
|
+
return redactResolvedCredentialValue(await fetchGitHubCopilot(input, token, fetcher, sourceId), credentialVariants);
|
|
330
|
+
}
|
|
331
|
+
if (input.provider === "cursor") {
|
|
332
|
+
return redactResolvedCredentialValue(await fetchCursor(input, token, fetcher, sourceId), credentialVariants);
|
|
333
|
+
}
|
|
334
|
+
throw new Error(`Provider connector not implemented yet: ${input.provider}`);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
// This is the shared credential boundary for CLI, MCP, and future hosts.
|
|
338
|
+
// Provider payloads, status text, fetch implementations, and validation
|
|
339
|
+
// errors are all untrusted after a credential has been resolved. Exact
|
|
340
|
+
// replacement covers opaque tokens that do not match a known key shape.
|
|
341
|
+
throw redactResolvedCredentialError(error, credentialVariants);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function resolvedCredentialVariants(provider, token) {
|
|
345
|
+
const values = token ? [token] : [];
|
|
346
|
+
if (provider === "cursor" && token) {
|
|
347
|
+
try {
|
|
348
|
+
const encoded = btoaCompat(`${token}:`);
|
|
349
|
+
const unpadded = encoded.replace(/=+$/g, "");
|
|
350
|
+
const base64Url = unpadded.replace(/\+/g, "-").replace(/\//g, "_");
|
|
351
|
+
values.push(encoded, unpadded, base64Url, `Basic ${encoded}`, `Basic ${unpadded}`, `Basic ${base64Url}`);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Cursor's request will fail on the same unsupported credential. Keep the
|
|
355
|
+
// raw value in the redaction set so that failure is still safe to return.
|
|
356
|
+
}
|
|
291
357
|
}
|
|
292
|
-
|
|
293
|
-
|
|
358
|
+
const encodedValues = values.flatMap((value) => {
|
|
359
|
+
const encoded = encodeURIComponent(value);
|
|
360
|
+
return encoded === value ? [value] : [value, encoded];
|
|
361
|
+
});
|
|
362
|
+
return Array.from(new Set(encodedValues)).sort((left, right) => right.length - left.length);
|
|
363
|
+
}
|
|
364
|
+
function exactRedactCredentialValues(value, credentialVariants) {
|
|
365
|
+
return credentialVariants.reduce((safeValue, credential) => safeValue.split(credential).join("[REDACTED]"), value);
|
|
366
|
+
}
|
|
367
|
+
function redactResolvedCredentialError(error, credentialVariants) {
|
|
368
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
369
|
+
const withoutResolvedCredential = exactRedactCredentialValues(rawMessage, credentialVariants);
|
|
370
|
+
// Strip controls before a second exact-redaction pass: an adversarial
|
|
371
|
+
// provider can splice ANSI bytes through an opaque credential so the first
|
|
372
|
+
// literal replacement misses it and control stripping reconstructs it.
|
|
373
|
+
const safeMessage = exactRedactCredentialValues(sanitizeProviderMessage(withoutResolvedCredential), credentialVariants).trim();
|
|
374
|
+
return new Error(safeMessage || "Provider connector request failed without a safe error message.");
|
|
375
|
+
}
|
|
376
|
+
function redactResolvedCredentialValue(value, credentialVariants) {
|
|
377
|
+
if (typeof value === "string") {
|
|
378
|
+
const withoutResolvedCredential = exactRedactCredentialValues(value, credentialVariants);
|
|
379
|
+
return exactRedactCredentialValues(sanitizeProviderMessage(withoutResolvedCredential), credentialVariants);
|
|
294
380
|
}
|
|
295
|
-
if (
|
|
296
|
-
return
|
|
381
|
+
if (Array.isArray(value)) {
|
|
382
|
+
return value.map((item) => redactResolvedCredentialValue(item, credentialVariants));
|
|
297
383
|
}
|
|
298
|
-
if (
|
|
299
|
-
return
|
|
384
|
+
if (value && typeof value === "object") {
|
|
385
|
+
return Object.fromEntries(Object.entries(value)
|
|
386
|
+
.map(([key, item]) => [key, redactResolvedCredentialValue(item, credentialVariants)]));
|
|
300
387
|
}
|
|
301
|
-
|
|
388
|
+
return value;
|
|
302
389
|
}
|
|
303
390
|
async function fetchOpenAi(input, token, fetcher, sourceId) {
|
|
304
391
|
const request = {
|
|
@@ -306,7 +393,9 @@ async function fetchOpenAi(input, token, fetcher, sourceId) {
|
|
|
306
393
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
|
307
394
|
};
|
|
308
395
|
const costFetch = await fetchPaginatedJson(fetcher, buildOpenAiCostsUrl(input.startTime, input.endTime), request, "openai", "OpenAI costs API");
|
|
396
|
+
markMalformedCostRows(costFetch, "openai", "OpenAI costs API");
|
|
309
397
|
const usageFetch = await fetchPaginatedJson(fetcher, buildOpenAiUsageUrl(input.startTime, input.endTime), request, "openai", "OpenAI usage API");
|
|
398
|
+
markMalformedUsageRows(usageFetch, "openai", "OpenAI usage API");
|
|
310
399
|
const records = [
|
|
311
400
|
...costFetch.pages.flatMap((page) => normalizeOpenAiCostResponse(page, { sourceId, observedFrom: "OpenAI organization costs API" })),
|
|
312
401
|
...usageFetch.pages.flatMap((page) => normalizeOpenAiUsageResponse(page, { sourceId, observedFrom: "OpenAI organization usage API" }))
|
|
@@ -319,7 +408,11 @@ async function fetchAnthropic(input, token, fetcher, sourceId) {
|
|
|
319
408
|
headers: { "x-api-key": token, "anthropic-version": "2023-06-01", "Content-Type": "application/json" }
|
|
320
409
|
};
|
|
321
410
|
const costFetch = await fetchPaginatedJson(fetcher, buildAnthropicCostUrl(input.startTime, input.endTime), costRequest, "anthropic", "Anthropic Admin cost report");
|
|
411
|
+
markMalformedCostRows(costFetch, "anthropic", "Anthropic Admin cost report");
|
|
322
412
|
const claudeCodeFetches = await fetchDateRangeJson(fetcher, buildAnthropicClaudeCodeUrl, input.startTime, input.endTime, costRequest, "anthropic", "Anthropic Claude Code usage report");
|
|
413
|
+
for (const fetchResult of claudeCodeFetches) {
|
|
414
|
+
markMalformedUsageRows(fetchResult, "anthropic", "Anthropic Claude Code usage report");
|
|
415
|
+
}
|
|
323
416
|
const records = [
|
|
324
417
|
...costFetch.pages.flatMap((page) => normalizeAnthropicCostResponse(page, { sourceId, observedFrom: "Anthropic Admin Cost Report" })),
|
|
325
418
|
...claudeCodeFetches.flatMap((fetchResult) => fetchResult.pages.flatMap((page) => normalizeAnthropicClaudeCodeUsageResponse(page, { sourceId, observedFrom: "Anthropic Claude Code Usage Report", accountId: input.accountId })))
|
|
@@ -332,38 +425,230 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
|
|
|
332
425
|
throw new Error("GitHub Copilot connector requires --org or --enterprise.");
|
|
333
426
|
const request = {
|
|
334
427
|
method: "GET",
|
|
335
|
-
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "
|
|
428
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2026-03-10" }
|
|
336
429
|
};
|
|
337
|
-
const
|
|
430
|
+
const metricsManifestResponse = await fetchJsonOrThrow(fetcher, buildGitHubCopilotMetricsUrl(input), request, "github-copilot", "GitHub Copilot metrics manifest");
|
|
431
|
+
const metricsManifest = metricsManifestResponse.payload;
|
|
432
|
+
const metricsDownloadLinks = requireStringArray(metricsManifest, "download_links", "GitHub Copilot metrics manifest");
|
|
433
|
+
const metricsFetch = await fetchGitHubCopilotSignedReports(fetcher, metricsDownloadLinks, metricsManifest, metricsManifestResponse.rateLimit);
|
|
434
|
+
markMalformedUsageRows(metricsFetch, "github-copilot", "GitHub Copilot metrics reports");
|
|
338
435
|
const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
|
|
436
|
+
if (seatFetch)
|
|
437
|
+
assessGitHubCopilotSeatCompleteness(seatFetch);
|
|
339
438
|
const metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
|
|
340
439
|
const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
|
|
341
440
|
return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
|
|
342
441
|
}
|
|
343
442
|
async function fetchCursor(input, token, fetcher, sourceId) {
|
|
344
443
|
const accountId = input.accountId ?? input.org ?? "cursor-team";
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
444
|
+
const spendFetch = await fetchCursorSpendPages(fetcher, token);
|
|
445
|
+
const records = spendFetch.pages.flatMap((page) => normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId }));
|
|
446
|
+
return providerResult("cursor", sourceId, input.authReference, records, qaSummary("cursor", [spendFetch]));
|
|
447
|
+
}
|
|
448
|
+
async function fetchCursorSpendPages(fetcher, token) {
|
|
449
|
+
const label = "Cursor Admin API spend";
|
|
450
|
+
const pages = [];
|
|
451
|
+
const rateLimits = [];
|
|
452
|
+
const responseDrift = [];
|
|
453
|
+
const maxPages = 50;
|
|
454
|
+
const pageSize = 100;
|
|
455
|
+
let expectedTotalPages;
|
|
456
|
+
let expectedTotalMembers;
|
|
457
|
+
let stoppedBecause = "complete";
|
|
458
|
+
let note;
|
|
459
|
+
for (let pageNumber = 1; pageNumber <= (expectedTotalPages ?? 1) && pageNumber <= maxPages; pageNumber += 1) {
|
|
460
|
+
let response;
|
|
461
|
+
try {
|
|
462
|
+
response = await fetchJsonOrThrow(fetcher, "https://api.cursor.com/teams/spend", {
|
|
463
|
+
method: "POST",
|
|
464
|
+
headers: { Authorization: `Basic ${btoaCompat(`${token}:`)}`, "Content-Type": "application/json" },
|
|
465
|
+
body: JSON.stringify({ page: pageNumber, pageSize })
|
|
466
|
+
}, "cursor", label);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (pages.length === 0)
|
|
470
|
+
throw error;
|
|
471
|
+
stoppedBecause = "fetch_error";
|
|
472
|
+
note = `Stopped after ${pages.length} page(s): ${sanitizeProviderMessage(error instanceof Error ? error.message : String(error))}`;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
const page = response.payload;
|
|
476
|
+
const pageMetadata = validateCursorSpendPage(page, pageNumber);
|
|
477
|
+
if (expectedTotalPages === undefined)
|
|
478
|
+
expectedTotalPages = pageMetadata.totalPages;
|
|
479
|
+
if (expectedTotalMembers === undefined)
|
|
480
|
+
expectedTotalMembers = pageMetadata.totalMembers;
|
|
481
|
+
if (pageMetadata.totalPages !== expectedTotalPages || pageMetadata.totalMembers !== expectedTotalMembers) {
|
|
482
|
+
stoppedBecause = "fetch_error";
|
|
483
|
+
note = `Cursor pagination metadata changed on page ${pageNumber} (totalPages ${expectedTotalPages}→${pageMetadata.totalPages}, totalMembers ${expectedTotalMembers}→${pageMetadata.totalMembers}); results may be incomplete.`;
|
|
484
|
+
pages.push(page);
|
|
485
|
+
responseDrift.push({ label, field: "totalPages/totalMembers", issue: note });
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
pages.push(page);
|
|
489
|
+
if (response.rateLimit)
|
|
490
|
+
rateLimits.push(response.rateLimit);
|
|
491
|
+
responseDrift.push(...detectResponseDrift(page, "cursor", label));
|
|
492
|
+
}
|
|
493
|
+
if ((expectedTotalPages ?? 0) > maxPages && stoppedBecause === "complete") {
|
|
494
|
+
stoppedBecause = "max_pages";
|
|
495
|
+
note = `Cursor reported ${expectedTotalPages} pages, exceeding the ${maxPages}-page connector limit.`;
|
|
496
|
+
}
|
|
497
|
+
const fetchedMembers = pages.reduce((sum, page) => sum + extractArray(page, "teamMemberSpend").length, 0);
|
|
498
|
+
if (stoppedBecause === "complete" && typeof expectedTotalMembers === "number" && fetchedMembers !== expectedTotalMembers) {
|
|
499
|
+
stoppedBecause = "missing_cursor";
|
|
500
|
+
note = `Cursor reported ${expectedTotalMembers} members but returned ${fetchedMembers}; refusing to mark the sync complete.`;
|
|
501
|
+
responseDrift.push({ label, field: "totalMembers", issue: note });
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
pages,
|
|
505
|
+
pagination: { label, pagesFetched: pages.length, stoppedBecause, maxPages, limitPerPage: pageSize, ...(note ? { note } : {}) },
|
|
506
|
+
rateLimits,
|
|
507
|
+
responseDrift
|
|
365
508
|
};
|
|
366
|
-
|
|
509
|
+
}
|
|
510
|
+
function validateCursorSpendPage(page, pageNumber) {
|
|
511
|
+
if (!isRecord(page) || !Array.isArray(page.teamMemberSpend)) {
|
|
512
|
+
const fields = isRecord(page) ? Object.keys(page).slice(0, 8).join(", ") : typeof page;
|
|
513
|
+
throw new Error(`Cursor spend page ${pageNumber} is missing canonical teamMemberSpend data (saw: ${fields}).`);
|
|
514
|
+
}
|
|
515
|
+
const totalPages = numberValue(page.totalPages);
|
|
516
|
+
if (typeof totalPages !== "number" || !Number.isInteger(totalPages) || totalPages < 1) {
|
|
517
|
+
throw new Error(`Cursor spend page ${pageNumber} has invalid or missing totalPages; completeness cannot be proven.`);
|
|
518
|
+
}
|
|
519
|
+
const totalMembers = numberValue(page.totalMembers);
|
|
520
|
+
if (typeof totalMembers !== "number" || !Number.isInteger(totalMembers) || totalMembers < 0) {
|
|
521
|
+
throw new Error(`Cursor spend page ${pageNumber} has invalid or missing totalMembers; completeness cannot be proven.`);
|
|
522
|
+
}
|
|
523
|
+
for (const [index, member] of page.teamMemberSpend.entries()) {
|
|
524
|
+
const spendCents = isRecord(member) ? numberValue(member.spendCents) : undefined;
|
|
525
|
+
if (!isRecord(member) || (!stringValue(member.email) && !stringValue(member.userId)) || typeof spendCents !== "number" || spendCents < 0) {
|
|
526
|
+
const fields = isRecord(member) ? Object.keys(member).slice(0, 8).join(", ") : typeof member;
|
|
527
|
+
throw new Error(`Cursor spend page ${pageNumber} member ${index + 1} is missing email/userId or a non-negative spendCents value (saw: ${fields}).`);
|
|
528
|
+
}
|
|
529
|
+
if (member.fastPremiumRequests !== undefined && typeof nonNegativeIntegerValue(member.fastPremiumRequests) !== "number") {
|
|
530
|
+
throw new Error(`Cursor spend page ${pageNumber} member ${index + 1} has an invalid fastPremiumRequests quantity; expected a non-negative integer.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { totalPages, totalMembers };
|
|
534
|
+
}
|
|
535
|
+
async function fetchGitHubCopilotSignedReports(fetcher, downloadLinks, manifest, manifestRateLimit) {
|
|
536
|
+
const label = "GitHub Copilot metrics reports";
|
|
537
|
+
const maxReports = 100;
|
|
538
|
+
if (downloadLinks.length > maxReports) {
|
|
539
|
+
throw new Error(`GitHub Copilot metrics manifest returned ${downloadLinks.length} report files, exceeding the ${maxReports}-file safety limit.`);
|
|
540
|
+
}
|
|
541
|
+
const pages = [];
|
|
542
|
+
const responseDrift = detectResponseDrift(manifest, "github-copilot", "GitHub Copilot metrics manifest");
|
|
543
|
+
for (const [index, candidate] of downloadLinks.entries()) {
|
|
544
|
+
const safeUrl = validateSignedDownloadUrl(candidate);
|
|
545
|
+
if (!safeUrl) {
|
|
546
|
+
throw new Error(`GitHub Copilot metrics report ${index + 1} had an unsafe signed download URL; only public HTTPS URLs without embedded credentials are accepted.`);
|
|
547
|
+
}
|
|
548
|
+
const body = await fetchTextOrThrow(fetcher, safeUrl, {
|
|
549
|
+
method: "GET",
|
|
550
|
+
// Signed report URLs carry their own authorization. Never replay the
|
|
551
|
+
// GitHub bearer token to a storage host.
|
|
552
|
+
headers: { Accept: "application/x-ndjson, application/json" }
|
|
553
|
+
}, "github-copilot", `GitHub Copilot metrics report ${index + 1}`);
|
|
554
|
+
const reports = parseNdjsonReports(body, index + 1);
|
|
555
|
+
if (reports.length === 0) {
|
|
556
|
+
throw new Error(`GitHub Copilot metrics report ${index + 1} was empty; refusing to report a complete sync.`);
|
|
557
|
+
}
|
|
558
|
+
for (const report of reports) {
|
|
559
|
+
if (!isRecord(report) || !Array.isArray(report.day_totals)) {
|
|
560
|
+
const fields = isRecord(report) ? Object.keys(report).slice(0, 8).join(", ") : typeof report;
|
|
561
|
+
throw new Error(`GitHub Copilot metrics report ${index + 1} did not contain the documented day_totals wrapper (saw: ${fields}).`);
|
|
562
|
+
}
|
|
563
|
+
pages.push(report);
|
|
564
|
+
responseDrift.push(...detectResponseDrift(report, "github-copilot", label));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
pages,
|
|
569
|
+
pagination: { label, pagesFetched: downloadLinks.length, stoppedBecause: "complete", maxPages: maxReports },
|
|
570
|
+
rateLimits: manifestRateLimit ? [manifestRateLimit] : [],
|
|
571
|
+
responseDrift
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async function fetchTextOrThrow(fetcher, url, request, provider, label) {
|
|
575
|
+
let lastError;
|
|
576
|
+
for (let attempt = 0; attempt <= maxFetchRetries; attempt += 1) {
|
|
577
|
+
const response = await fetcher(url, request);
|
|
578
|
+
if (response.ok) {
|
|
579
|
+
if (!response.text)
|
|
580
|
+
throw new Error(`${label} did not expose a readable NDJSON body.`);
|
|
581
|
+
return response.text();
|
|
582
|
+
}
|
|
583
|
+
const payload = await response.json().catch(() => undefined);
|
|
584
|
+
lastError = new Error(providerPermissionPrompt(provider, label, response, payload));
|
|
585
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
586
|
+
if (!retryable || attempt === maxFetchRetries)
|
|
587
|
+
break;
|
|
588
|
+
const retryAfterSeconds = headerNumber(response.headers, "retry-after");
|
|
589
|
+
const delayMs = typeof retryAfterSeconds === "number"
|
|
590
|
+
? Math.min(Math.max(retryAfterSeconds, 0) * 1000, maxRetryDelayMs)
|
|
591
|
+
: 500 * 2 ** attempt;
|
|
592
|
+
if (delayMs > 0)
|
|
593
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
|
|
594
|
+
}
|
|
595
|
+
throw lastError ?? new Error(`${label} request failed.`);
|
|
596
|
+
}
|
|
597
|
+
function parseNdjsonReports(body, reportNumber) {
|
|
598
|
+
const reports = [];
|
|
599
|
+
const lines = body.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
600
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
601
|
+
let parsed;
|
|
602
|
+
try {
|
|
603
|
+
parsed = JSON.parse(line);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
throw new Error(`GitHub Copilot metrics report ${reportNumber} contains malformed NDJSON at line ${lineIndex + 1}.`);
|
|
607
|
+
}
|
|
608
|
+
if (Array.isArray(parsed))
|
|
609
|
+
reports.push(...parsed);
|
|
610
|
+
else
|
|
611
|
+
reports.push(parsed);
|
|
612
|
+
}
|
|
613
|
+
return reports;
|
|
614
|
+
}
|
|
615
|
+
function validateSignedDownloadUrl(candidate) {
|
|
616
|
+
try {
|
|
617
|
+
const url = new URL(candidate);
|
|
618
|
+
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
619
|
+
if (url.protocol !== "https:" || (url.port && url.port !== "443") || url.username || url.password)
|
|
620
|
+
return undefined;
|
|
621
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname === "::" || hostname === "::1" || hostname === "0:0:0:0:0:0:0:1")
|
|
622
|
+
return undefined;
|
|
623
|
+
if (/^0\./.test(hostname) || /^127\./.test(hostname) || /^10\./.test(hostname) || /^192\.168\./.test(hostname) || /^169\.254\./.test(hostname))
|
|
624
|
+
return undefined;
|
|
625
|
+
if (/^(?:fc|fd|fe[89ab])/i.test(hostname))
|
|
626
|
+
return undefined;
|
|
627
|
+
const private172 = hostname.match(/^172\.(\d+)\./);
|
|
628
|
+
if (private172 && Number(private172[1]) >= 16 && Number(private172[1]) <= 31)
|
|
629
|
+
return undefined;
|
|
630
|
+
return url.toString();
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function assessGitHubCopilotSeatCompleteness(fetchResult) {
|
|
637
|
+
const expected = fetchResult.pages
|
|
638
|
+
.map((page) => isRecord(page) ? numberValue(page.total_seats) : undefined)
|
|
639
|
+
.find((value) => typeof value === "number");
|
|
640
|
+
const actual = fetchResult.pages.reduce((sum, page) => sum + extractArray(page, "seats").length, 0);
|
|
641
|
+
if (typeof expected !== "number") {
|
|
642
|
+
if (fetchResult.pagination.stoppedBecause === "complete")
|
|
643
|
+
fetchResult.pagination.stoppedBecause = "missing_cursor";
|
|
644
|
+
fetchResult.pagination.note = "GitHub Copilot seats response omitted total_seats; completeness cannot be proven.";
|
|
645
|
+
fetchResult.responseDrift.push({ label: "GitHub Copilot seats", field: "total_seats", issue: fetchResult.pagination.note });
|
|
646
|
+
}
|
|
647
|
+
else if (fetchResult.pagination.stoppedBecause === "complete" && actual !== expected) {
|
|
648
|
+
fetchResult.pagination.stoppedBecause = "missing_cursor";
|
|
649
|
+
fetchResult.pagination.note = `GitHub reported ${expected} seats but returned ${actual}; completeness cannot be proven.`;
|
|
650
|
+
fetchResult.responseDrift.push({ label: "GitHub Copilot seats", field: "total_seats", issue: fetchResult.pagination.note });
|
|
651
|
+
}
|
|
367
652
|
}
|
|
368
653
|
async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
|
|
369
654
|
const pages = [];
|
|
@@ -574,6 +859,228 @@ function headerNumber(headers, name) {
|
|
|
574
859
|
function hasHeaderGetter(headers) {
|
|
575
860
|
return typeof headers?.get === "function";
|
|
576
861
|
}
|
|
862
|
+
function markMalformedCostRows(fetchResult, provider, label) {
|
|
863
|
+
const issues = [];
|
|
864
|
+
const maxDetailedIssues = 25;
|
|
865
|
+
let issueCount = 0;
|
|
866
|
+
const report = (field, issue) => {
|
|
867
|
+
issueCount += 1;
|
|
868
|
+
if (issues.length < maxDetailedIssues)
|
|
869
|
+
issues.push({ label, field, issue });
|
|
870
|
+
};
|
|
871
|
+
for (const page of fetchResult.pages) {
|
|
872
|
+
if (!isRecord(page) || !Array.isArray(page.data)) {
|
|
873
|
+
report("data", "cost response omitted the canonical data array; no financial completeness claim is safe");
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
for (const [bucketIndex, bucketValue] of page.data.entries()) {
|
|
877
|
+
const bucketPath = `data[${bucketIndex}]`;
|
|
878
|
+
if (!isRecord(bucketValue)) {
|
|
879
|
+
report(bucketPath, "cost response contained a non-object billing bucket");
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (provider === "openai") {
|
|
883
|
+
if (typeof validEpochSeconds(bucketValue.start_time) !== "number") {
|
|
884
|
+
report(`${bucketPath}.start_time`, "cost bucket had an invalid timestamp and its rows were excluded");
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
else if (!validDateTimeString(bucketValue.starting_at)) {
|
|
889
|
+
report(`${bucketPath}.starting_at`, "cost bucket had an invalid timestamp and its rows were excluded");
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
if (!Array.isArray(bucketValue.results)) {
|
|
893
|
+
report(`${bucketPath}.results`, "cost bucket omitted the canonical results array");
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
for (const [resultIndex, resultValue] of bucketValue.results.entries()) {
|
|
897
|
+
const resultPath = `${bucketPath}.results[${resultIndex}]`;
|
|
898
|
+
if (!isRecord(resultValue)) {
|
|
899
|
+
report(resultPath, "cost response contained a non-object billed-cost row");
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (provider === "openai") {
|
|
903
|
+
const amount = isRecord(resultValue.amount) ? resultValue.amount : undefined;
|
|
904
|
+
if (!amount) {
|
|
905
|
+
report(`${resultPath}.amount`, "billed-cost row had no canonical amount object and was excluded");
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (amount.currency !== undefined && (typeof amount.currency !== "string" || amount.currency.toLowerCase() !== "usd")) {
|
|
909
|
+
report(`${resultPath}.amount.currency`, "billed-cost row used an invalid or unsupported currency and was excluded from the USD headline");
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (typeof parseDollarUsd(amount.value) !== "number") {
|
|
913
|
+
report(`${resultPath}.amount.value`, "billed-cost row had an invalid dollar amount and was excluded");
|
|
914
|
+
}
|
|
915
|
+
if (resultValue.quantity !== undefined && typeof nonNegativeNumberValue(resultValue.quantity) !== "number") {
|
|
916
|
+
report(`${resultPath}.quantity`, "billed-cost row had a negative or invalid quantity; the quantity was excluded");
|
|
917
|
+
}
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (resultValue.currency !== undefined && (typeof resultValue.currency !== "string" || resultValue.currency.toLowerCase() !== "usd")) {
|
|
921
|
+
report(`${resultPath}.currency`, "billed-cost row used an invalid or unsupported currency and was excluded from the USD headline");
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (typeof parseMinorUsd(resultValue.amount) !== "number") {
|
|
925
|
+
report(`${resultPath}.amount`, "billed-cost row had an invalid minor-unit amount and was excluded");
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (issueCount === 0)
|
|
931
|
+
return;
|
|
932
|
+
if (issueCount > maxDetailedIssues) {
|
|
933
|
+
issues.push({
|
|
934
|
+
label,
|
|
935
|
+
field: "data[].results[]",
|
|
936
|
+
issue: `${issueCount - maxDetailedIssues} additional malformed billed-cost schema issue(s) were omitted from QA details`
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
fetchResult.coverageIncomplete = true;
|
|
940
|
+
fetchResult.responseDrift.push(...issues);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Provider APIs are untrusted even after transport succeeds. A negative or
|
|
944
|
+
* fractional token count cannot satisfy UsageRecord's finance-grade schema,
|
|
945
|
+
* and a negative count/quantity cannot support a completeness claim. Keep any
|
|
946
|
+
* independently valid evidence, but mark the whole source pull partial and
|
|
947
|
+
* omit the invalid values during normalization.
|
|
948
|
+
*/
|
|
949
|
+
function markMalformedUsageRows(fetchResult, provider, label) {
|
|
950
|
+
const issues = [];
|
|
951
|
+
const maxDetailedIssues = 25;
|
|
952
|
+
let issueCount = 0;
|
|
953
|
+
const report = (field, issue) => {
|
|
954
|
+
issueCount += 1;
|
|
955
|
+
if (issues.length < maxDetailedIssues)
|
|
956
|
+
issues.push({ label, field, issue });
|
|
957
|
+
};
|
|
958
|
+
const checkInteger = (value, field, kind) => {
|
|
959
|
+
if (value !== undefined && typeof nonNegativeIntegerValue(value) !== "number") {
|
|
960
|
+
report(field, `${kind} must be a non-negative integer; the invalid value was excluded`);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
for (const page of fetchResult.pages) {
|
|
964
|
+
if (provider === "openai") {
|
|
965
|
+
if (!isRecord(page) || !Array.isArray(page.data)) {
|
|
966
|
+
report("data", "usage response omitted the canonical data array; completeness cannot be proven");
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
for (const [bucketIndex, bucketValue] of page.data.entries()) {
|
|
970
|
+
const bucketPath = `data[${bucketIndex}]`;
|
|
971
|
+
if (!isRecord(bucketValue) || !Array.isArray(bucketValue.results)) {
|
|
972
|
+
report(bucketPath, "usage response contained a malformed bucket or omitted its results array");
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
for (const [resultIndex, resultValue] of bucketValue.results.entries()) {
|
|
976
|
+
const resultPath = `${bucketPath}.results[${resultIndex}]`;
|
|
977
|
+
if (!isRecord(resultValue)) {
|
|
978
|
+
report(resultPath, "usage response contained a non-object usage row");
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
for (const field of [
|
|
982
|
+
"input_tokens",
|
|
983
|
+
"input_uncached_tokens",
|
|
984
|
+
"input_cache_write_tokens",
|
|
985
|
+
"input_cached_tokens",
|
|
986
|
+
"input_text_tokens",
|
|
987
|
+
"input_image_tokens",
|
|
988
|
+
"input_audio_tokens",
|
|
989
|
+
"input_cached_text_tokens",
|
|
990
|
+
"input_cached_image_tokens",
|
|
991
|
+
"input_cached_audio_tokens",
|
|
992
|
+
"output_tokens",
|
|
993
|
+
"output_text_tokens",
|
|
994
|
+
"output_image_tokens",
|
|
995
|
+
"output_audio_tokens"
|
|
996
|
+
]) {
|
|
997
|
+
checkInteger(resultValue[field], `${resultPath}.${field}`, "token count");
|
|
998
|
+
}
|
|
999
|
+
checkInteger(resultValue.num_model_requests, `${resultPath}.num_model_requests`, "quantity");
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
if (provider === "anthropic") {
|
|
1005
|
+
if (!isRecord(page) || !Array.isArray(page.data)) {
|
|
1006
|
+
report("data", "Claude Code usage response omitted the canonical data array; completeness cannot be proven");
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
for (const [rowIndex, rowValue] of page.data.entries()) {
|
|
1010
|
+
const rowPath = `data[${rowIndex}]`;
|
|
1011
|
+
if (!isRecord(rowValue)) {
|
|
1012
|
+
report(rowPath, "Claude Code usage response contained a non-object row");
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const core = isRecord(rowValue.core_metrics) ? rowValue.core_metrics : {};
|
|
1016
|
+
const lines = isRecord(core.lines_of_code) ? core.lines_of_code : {};
|
|
1017
|
+
checkInteger(core.num_sessions, `${rowPath}.core_metrics.num_sessions`, "quantity");
|
|
1018
|
+
checkInteger(lines.added, `${rowPath}.core_metrics.lines_of_code.added`, "quantity");
|
|
1019
|
+
checkInteger(lines.removed, `${rowPath}.core_metrics.lines_of_code.removed`, "quantity");
|
|
1020
|
+
checkInteger(core.commits_by_claude_code, `${rowPath}.core_metrics.commits_by_claude_code`, "quantity");
|
|
1021
|
+
checkInteger(core.pull_requests_by_claude_code, `${rowPath}.core_metrics.pull_requests_by_claude_code`, "quantity");
|
|
1022
|
+
const modelBreakdown = Array.isArray(rowValue.model_breakdown) ? rowValue.model_breakdown : [];
|
|
1023
|
+
for (const [modelIndex, modelValue] of modelBreakdown.entries()) {
|
|
1024
|
+
const modelPath = `${rowPath}.model_breakdown[${modelIndex}]`;
|
|
1025
|
+
if (!isRecord(modelValue)) {
|
|
1026
|
+
report(modelPath, "Claude Code usage response contained a non-object model row");
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const tokens = isRecord(modelValue.tokens) ? modelValue.tokens : {};
|
|
1030
|
+
for (const field of ["input", "output", "cache_read", "cache_creation"]) {
|
|
1031
|
+
checkInteger(tokens[field], `${modelPath}.tokens.${field}`, "token count");
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (!isRecord(page) || !Array.isArray(page.day_totals)) {
|
|
1038
|
+
report("day_totals", "Copilot metrics response omitted the canonical day_totals array; completeness cannot be proven");
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
for (const [dayIndex, dayValue] of page.day_totals.entries()) {
|
|
1042
|
+
const dayPath = `day_totals[${dayIndex}]`;
|
|
1043
|
+
if (!isRecord(dayValue)) {
|
|
1044
|
+
report(dayPath, "Copilot metrics response contained a non-object day row");
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
checkInteger(dayValue.daily_active_users, `${dayPath}.daily_active_users`, "quantity");
|
|
1048
|
+
const featureRows = Array.isArray(dayValue.totals_by_model_feature) ? dayValue.totals_by_model_feature : [];
|
|
1049
|
+
for (const [featureIndex, featureValue] of featureRows.entries()) {
|
|
1050
|
+
if (!isRecord(featureValue)) {
|
|
1051
|
+
report(`${dayPath}.totals_by_model_feature[${featureIndex}]`, "Copilot metrics response contained a non-object feature row");
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const featurePath = `${dayPath}.totals_by_model_feature[${featureIndex}]`;
|
|
1055
|
+
for (const field of ["engaged_users", "total_requests", "user_initiated_interaction_count"]) {
|
|
1056
|
+
checkInteger(featureValue[field], `${featurePath}.${field}`, "quantity");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const cli = isRecord(dayValue.totals_by_cli) ? dayValue.totals_by_cli : undefined;
|
|
1060
|
+
if (!cli)
|
|
1061
|
+
continue;
|
|
1062
|
+
for (const field of ["request_count", "prompt_count", "session_count", "engaged_users", "total_requests"]) {
|
|
1063
|
+
checkInteger(cli[field], `${dayPath}.totals_by_cli.${field}`, "quantity");
|
|
1064
|
+
}
|
|
1065
|
+
const tokenUsage = isRecord(cli.token_usage) ? cli.token_usage : undefined;
|
|
1066
|
+
if (!tokenUsage)
|
|
1067
|
+
continue;
|
|
1068
|
+
checkInteger(tokenUsage.prompt_tokens_sum, `${dayPath}.totals_by_cli.token_usage.prompt_tokens_sum`, "token count");
|
|
1069
|
+
checkInteger(tokenUsage.output_tokens_sum, `${dayPath}.totals_by_cli.token_usage.output_tokens_sum`, "token count");
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (issueCount === 0)
|
|
1073
|
+
return;
|
|
1074
|
+
if (issueCount > maxDetailedIssues) {
|
|
1075
|
+
issues.push({
|
|
1076
|
+
label,
|
|
1077
|
+
field: "usage rows",
|
|
1078
|
+
issue: `${issueCount - maxDetailedIssues} additional malformed usage schema issue(s) were omitted from QA details`
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
fetchResult.coverageIncomplete = true;
|
|
1082
|
+
fetchResult.responseDrift.push(...issues);
|
|
1083
|
+
}
|
|
577
1084
|
function detectResponseDrift(payload, provider, label) {
|
|
578
1085
|
const known = knownProviderFields(provider, label);
|
|
579
1086
|
const issues = [];
|
|
@@ -615,20 +1122,20 @@ function knownProviderFields(provider, label) {
|
|
|
615
1122
|
return new Set([...common, "data[].date", "data[].actor", "data[].actor.email_address", "data[].actor.api_key_name", "data[].actor.id", "data[].actor.type", "data[].organization_id", "data[].customer_type", "data[].terminal_type", "data[].subscription_type", "data[].core_metrics", "data[].core_metrics.num_sessions", "data[].core_metrics.lines_of_code", "data[].core_metrics.lines_of_code.added", "data[].core_metrics.lines_of_code.removed", "data[].core_metrics.commits_by_claude_code", "data[].core_metrics.pull_requests_by_claude_code", "data[].model_breakdown", "data[].model_breakdown[]", "data[].model_breakdown[].model", "data[].model_breakdown[].tokens", "data[].model_breakdown[].tokens.input", "data[].model_breakdown[].tokens.output", "data[].model_breakdown[].tokens.cache_read", "data[].model_breakdown[].tokens.cache_creation", "data[].model_breakdown[].estimated_cost", "data[].model_breakdown[].estimated_cost.currency", "data[].model_breakdown[].estimated_cost.amount", "data[].tool_actions", "data[].tool_actions[]"]);
|
|
616
1123
|
}
|
|
617
1124
|
if (provider === "github-copilot" && label.toLowerCase().includes("metrics")) {
|
|
618
|
-
return new Set([...common, "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.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.engaged_users", "day_totals[].totals_by_cli.total_requests", "report_start_day", "report_end_day", "generated_at"]);
|
|
1125
|
+
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"]);
|
|
619
1126
|
}
|
|
620
1127
|
if (provider === "github-copilot" && label.toLowerCase().includes("seats")) {
|
|
621
|
-
return new Set([...common, "total_seats", "
|
|
1128
|
+
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"]);
|
|
622
1129
|
}
|
|
623
1130
|
if (provider === "cursor") {
|
|
624
|
-
return new Set([...common, "
|
|
1131
|
+
return new Set([...common, "teamMemberSpend", "teamMemberSpend[]", "teamMemberSpend[].userId", "teamMemberSpend[].email", "teamMemberSpend[].name", "teamMemberSpend[].role", "teamMemberSpend[].spendCents", "teamMemberSpend[].fastPremiumRequests", "teamMemberSpend[].hardLimitOverrideDollars", "subscriptionCycleStart", "totalMembers", "totalPages"]);
|
|
625
1132
|
}
|
|
626
1133
|
return new Set([...common]);
|
|
627
1134
|
}
|
|
628
1135
|
function qaSummary(provider, fetches) {
|
|
629
1136
|
return {
|
|
630
1137
|
provider,
|
|
631
|
-
coverage: fetches.every((fetchResult) => fetchResult.pagination.stoppedBecause === "complete") ? "complete" : "partial",
|
|
1138
|
+
coverage: fetches.every((fetchResult) => fetchResult.pagination.stoppedBecause === "complete" && fetchResult.coverageIncomplete !== true) ? "complete" : "partial",
|
|
632
1139
|
requestedEndpoints: Array.from(new Set(fetches.map((fetchResult) => fetchResult.pagination.label))),
|
|
633
1140
|
pagination: fetches.map((fetchResult) => fetchResult.pagination),
|
|
634
1141
|
rateLimits: fetches.flatMap((fetchResult) => fetchResult.rateLimits),
|
|
@@ -689,8 +1196,26 @@ function extractProviderMessage(payload) {
|
|
|
689
1196
|
return stringValue(error?.message) ?? stringValue(payload.message) ?? "";
|
|
690
1197
|
}
|
|
691
1198
|
function sanitizeProviderMessage(message) {
|
|
692
|
-
//
|
|
693
|
-
|
|
1199
|
+
// Provider error bodies/status text are terminal-facing untrusted input.
|
|
1200
|
+
// Strip controls first so an escape sequence cannot split a secret pattern,
|
|
1201
|
+
// then apply the product-wide redaction rules.
|
|
1202
|
+
return redactSecrets(stripTerminalControlSequences(message))
|
|
1203
|
+
.replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]")
|
|
1204
|
+
.replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]")
|
|
1205
|
+
.trim();
|
|
1206
|
+
}
|
|
1207
|
+
function stripTerminalControlSequences(message) {
|
|
1208
|
+
return message
|
|
1209
|
+
// OSC (window title, hyperlinks, clipboard), terminated by BEL/ST or EOF.
|
|
1210
|
+
.replace(/(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/gu, "")
|
|
1211
|
+
// DCS/SOS/PM/APC string controls, terminated by ST or EOF.
|
|
1212
|
+
.replace(/(?:\u001b(?:P|X|\^|_)|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\u001b\\|\u009c|$)/gu, "")
|
|
1213
|
+
// CSI plus remaining two-character ESC sequences.
|
|
1214
|
+
.replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu, "")
|
|
1215
|
+
.replace(/\u001b[@-_]/gu, "")
|
|
1216
|
+
// Prevent line/status injection while keeping words readable.
|
|
1217
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
1218
|
+
.replace(/\s+/gu, " ");
|
|
694
1219
|
}
|
|
695
1220
|
export function summarizeProviderFinancials(records) {
|
|
696
1221
|
const providerReportedBilledUsd = sumAmounts(records.filter((record) => record.costConfidence === "verified"));
|
|
@@ -747,7 +1272,7 @@ function providerResult(provider, sourceId, authReference, records, qa) {
|
|
|
747
1272
|
: headlineConfidence;
|
|
748
1273
|
return {
|
|
749
1274
|
provider,
|
|
750
|
-
source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd
|
|
1275
|
+
source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd, completeness }),
|
|
751
1276
|
records,
|
|
752
1277
|
fetchedAt: new Date().toISOString(),
|
|
753
1278
|
coverage,
|
|
@@ -758,17 +1283,29 @@ function providerResult(provider, sourceId, authReference, records, qa) {
|
|
|
758
1283
|
}
|
|
759
1284
|
export function createProviderConnection(input) {
|
|
760
1285
|
const source = createProviderConnectorStub(input.provider, "provider_api", input.fetchedAt);
|
|
761
|
-
const total =
|
|
762
|
-
const
|
|
1286
|
+
const total = input.totalUsd === null ? "an unavailable financial headline" : formatProviderUsd(input.totalUsd);
|
|
1287
|
+
const financialEvidence = input.completeness ?? "verified";
|
|
763
1288
|
return {
|
|
764
1289
|
...source,
|
|
765
1290
|
id: input.sourceId ?? source.id,
|
|
766
|
-
|
|
1291
|
+
validationCoverage: validationCoverageForCompletedProviderSync(input.provider),
|
|
1292
|
+
financialEvidence,
|
|
767
1293
|
authReference: input.authReference,
|
|
768
1294
|
fieldsMissing: [],
|
|
769
|
-
scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} ${
|
|
1295
|
+
scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} record(s); financial evidence: ${financialEvidence}; financial headline: ${total}.`
|
|
770
1296
|
};
|
|
771
1297
|
}
|
|
1298
|
+
function validationCoverageForCompletedProviderSync(provider) {
|
|
1299
|
+
if (provider === "openai" || provider === "anthropic")
|
|
1300
|
+
return "live_verified";
|
|
1301
|
+
if (provider === "cursor" || provider === "github-copilot" || provider === "copilot") {
|
|
1302
|
+
return "fixture_verified";
|
|
1303
|
+
}
|
|
1304
|
+
return "untested";
|
|
1305
|
+
}
|
|
1306
|
+
function formatProviderUsd(value) {
|
|
1307
|
+
return value > 0 && value < 0.01 ? "less than $0.01" : `$${value.toFixed(2)}`;
|
|
1308
|
+
}
|
|
772
1309
|
export function resolveTokenReference(reference, env = process.env) {
|
|
773
1310
|
if (!reference.startsWith("env:")) {
|
|
774
1311
|
throw new Error("Provider auth reference must be a local reference such as env:OPENAI_ADMIN_KEY; raw secrets are not accepted.");
|
|
@@ -843,20 +1380,45 @@ async function defaultFetcher(url, init) {
|
|
|
843
1380
|
return fetch(url, { ...init, redirect: "manual" });
|
|
844
1381
|
}
|
|
845
1382
|
function parseMinorUsd(value) {
|
|
846
|
-
const numeric = typeof value === "number"
|
|
847
|
-
|
|
1383
|
+
const numeric = typeof value === "number"
|
|
1384
|
+
? value
|
|
1385
|
+
: typeof value === "string" && value.trim().length > 0
|
|
1386
|
+
? Number(value)
|
|
1387
|
+
: Number.NaN;
|
|
1388
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric / 100 : undefined;
|
|
848
1389
|
}
|
|
849
1390
|
/** Amount already denominated in dollars, as number or decimal string. */
|
|
850
1391
|
function parseDollarUsd(value) {
|
|
851
|
-
const numeric = typeof value === "number"
|
|
852
|
-
|
|
1392
|
+
const numeric = typeof value === "number"
|
|
1393
|
+
? value
|
|
1394
|
+
: typeof value === "string" && value.trim().length > 0
|
|
1395
|
+
? Number(value)
|
|
1396
|
+
: Number.NaN;
|
|
1397
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : undefined;
|
|
853
1398
|
}
|
|
854
1399
|
function numberValue(value) {
|
|
855
1400
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
856
1401
|
}
|
|
1402
|
+
function nonNegativeNumberValue(value) {
|
|
1403
|
+
const numeric = numberValue(value);
|
|
1404
|
+
return typeof numeric === "number" && numeric >= 0 ? numeric : undefined;
|
|
1405
|
+
}
|
|
1406
|
+
function nonNegativeIntegerValue(value) {
|
|
1407
|
+
const numeric = numberValue(value);
|
|
1408
|
+
return typeof numeric === "number" && Number.isInteger(numeric) && numeric >= 0 ? numeric : undefined;
|
|
1409
|
+
}
|
|
857
1410
|
function stringValue(value) {
|
|
858
1411
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
859
1412
|
}
|
|
1413
|
+
function validEpochSeconds(value) {
|
|
1414
|
+
const seconds = numberValue(value);
|
|
1415
|
+
return typeof seconds === "number" && seconds >= 0 && Number.isFinite(new Date(seconds * 1000).getTime())
|
|
1416
|
+
? seconds
|
|
1417
|
+
: undefined;
|
|
1418
|
+
}
|
|
1419
|
+
function validDateTimeString(value) {
|
|
1420
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value)) ? value : undefined;
|
|
1421
|
+
}
|
|
860
1422
|
function extractArray(value, key) {
|
|
861
1423
|
if (Array.isArray(value))
|
|
862
1424
|
return value;
|
|
@@ -864,6 +1426,20 @@ function extractArray(value, key) {
|
|
|
864
1426
|
return value[key];
|
|
865
1427
|
return [];
|
|
866
1428
|
}
|
|
1429
|
+
function requireStringArray(value, key, label) {
|
|
1430
|
+
if (!isRecord(value) || !Array.isArray(value[key]) || value[key].length === 0) {
|
|
1431
|
+
throw new Error(`${label} returned no signed NDJSON ${key}; refusing to report an empty metrics sync.`);
|
|
1432
|
+
}
|
|
1433
|
+
const values = value[key];
|
|
1434
|
+
if (values.some((item) => typeof item !== "string" || item.length === 0)) {
|
|
1435
|
+
throw new Error(`${label} returned a malformed ${key} entry; refusing a partial metrics sync.`);
|
|
1436
|
+
}
|
|
1437
|
+
const strings = values;
|
|
1438
|
+
if (new Set(strings).size !== strings.length) {
|
|
1439
|
+
throw new Error(`${label} returned duplicate ${key}; refusing to double-count a report partition.`);
|
|
1440
|
+
}
|
|
1441
|
+
return strings;
|
|
1442
|
+
}
|
|
867
1443
|
function isObject(value) {
|
|
868
1444
|
return typeof value === "object" && value !== null;
|
|
869
1445
|
}
|