@agent-finops/core 0.5.8 → 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/agentInventory.d.ts +11 -14
- package/dist/agentInventory.js +180 -46
- package/dist/analyze.js +165 -113
- package/dist/contextHealth.d.ts +10 -1
- package/dist/contextHealth.js +216 -44
- package/dist/cutList.d.ts +9 -3
- package/dist/cutList.js +99 -41
- package/dist/deadContext.d.ts +8 -4
- package/dist/deadContext.js +83 -26
- package/dist/discovery.d.ts +6 -2
- package/dist/discovery.js +36 -14
- package/dist/glance.d.ts +9 -2
- package/dist/glance.js +107 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/insights.js +53 -30
- package/dist/localAgentLogs.d.ts +80 -2
- package/dist/localAgentLogs.js +480 -88
- package/dist/modelPricing.js +0 -1
- package/dist/providerConnectors.d.ts +3 -2
- package/dist/providerConnectors.js +673 -89
- package/dist/sampleData.js +32 -4
- package/dist/schema.d.ts +105 -27
- package/dist/schema.js +110 -2
- 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/dist/toolInvocations.d.ts +47 -18
- package/dist/toolInvocations.js +200 -48
- package/package.json +1 -1
- package/samples/anthropic-usage.csv +4 -4
- package/samples/openai-usage.csv +7 -7
|
@@ -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,
|
|
@@ -35,7 +48,8 @@ export function normalizeOpenAiCostResponse(response, options) {
|
|
|
35
48
|
projectId,
|
|
36
49
|
apiKeyId,
|
|
37
50
|
providerCostType: "openai_cost",
|
|
38
|
-
|
|
51
|
+
usageGranularity: "billing_bucket",
|
|
52
|
+
quantity: nonNegativeNumberValue(resultValue.quantity),
|
|
39
53
|
operation: lineItem
|
|
40
54
|
});
|
|
41
55
|
}
|
|
@@ -53,12 +67,12 @@ export function normalizeOpenAiUsageResponse(response, options) {
|
|
|
53
67
|
const userId = result.user_id ?? undefined;
|
|
54
68
|
const apiKeyId = result.api_key_id ?? undefined;
|
|
55
69
|
const model = result.model ?? "openai-usage";
|
|
56
|
-
const inputTokens =
|
|
57
|
-
const outputTokens =
|
|
58
|
-
const cachedTokens =
|
|
59
|
-
const audioInputTokens =
|
|
60
|
-
const audioOutputTokens =
|
|
61
|
-
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);
|
|
62
76
|
if (inputTokens + outputTokens + audioInputTokens + audioOutputTokens === 0 && typeof requestCount !== "number")
|
|
63
77
|
continue;
|
|
64
78
|
records.push({
|
|
@@ -74,7 +88,8 @@ export function normalizeOpenAiUsageResponse(response, options) {
|
|
|
74
88
|
userId,
|
|
75
89
|
apiKeyId,
|
|
76
90
|
providerCostType: "openai_usage_evidence",
|
|
77
|
-
|
|
91
|
+
usageGranularity: "usage_bucket",
|
|
92
|
+
quantity: requestCount,
|
|
78
93
|
operation: "OpenAI completions usage evidence"
|
|
79
94
|
});
|
|
80
95
|
}
|
|
@@ -92,11 +107,11 @@ export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
|
|
|
92
107
|
const userId = stringValue(actor.email_address) ?? stringValue(actor.api_key_name) ?? stringValue(actor.id) ?? "unknown-claude-code-actor";
|
|
93
108
|
const core = isRecord(row.core_metrics) ? row.core_metrics : {};
|
|
94
109
|
const lines = isRecord(core.lines_of_code) ? core.lines_of_code : {};
|
|
95
|
-
const sessions =
|
|
96
|
-
const added =
|
|
97
|
-
const removed =
|
|
98
|
-
const commits =
|
|
99
|
-
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;
|
|
100
115
|
const organizationId = stringValue(row.organization_id) ?? options.accountId;
|
|
101
116
|
const modelBreakdown = Array.isArray(row.model_breakdown) ? row.model_breakdown : [];
|
|
102
117
|
for (const item of modelBreakdown) {
|
|
@@ -114,13 +129,14 @@ export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
|
|
|
114
129
|
timestamp: new Date(`${date}T00:00:00Z`).toISOString(),
|
|
115
130
|
source: { id: options.sourceId, name: "Anthropic Claude Code Usage Report", provider: "anthropic", confidence: "estimated", observedFrom: options.observedFrom },
|
|
116
131
|
model,
|
|
117
|
-
inputTokens: (
|
|
118
|
-
outputTokens:
|
|
132
|
+
inputTokens: (nonNegativeIntegerValue(tokens.input) ?? 0) + (nonNegativeIntegerValue(tokens.cache_read) ?? 0) + (nonNegativeIntegerValue(tokens.cache_creation) ?? 0),
|
|
133
|
+
outputTokens: nonNegativeIntegerValue(tokens.output) ?? 0,
|
|
119
134
|
amountUsd,
|
|
120
135
|
costConfidence: "estimated",
|
|
121
136
|
userId,
|
|
122
137
|
projectId: organizationId,
|
|
123
138
|
providerCostType: "anthropic_claude_code_usage",
|
|
139
|
+
usageGranularity: "daily_aggregate",
|
|
124
140
|
quantity: sessions,
|
|
125
141
|
operation: `Claude Code sessions: ${sessions}; LOC +${added}/-${removed}; commits ${commits}; PRs ${prs}`
|
|
126
142
|
});
|
|
@@ -130,8 +146,6 @@ export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
|
|
|
130
146
|
}
|
|
131
147
|
export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
132
148
|
const seats = extractArray(response, "seats");
|
|
133
|
-
const plan = stringValue(isRecord(response) ? response.plan_type : undefined) ?? "business";
|
|
134
|
-
const seatUsd = plan === "enterprise" ? 39 : 19;
|
|
135
149
|
const timestamp = new Date().toISOString();
|
|
136
150
|
return seats.flatMap((seat) => {
|
|
137
151
|
if (!isRecord(seat))
|
|
@@ -140,6 +154,12 @@ export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
|
140
154
|
const userId = stringValue(assignee.login) ?? stringValue(assignee.email) ?? stringValue(seat.login) ?? stringValue(seat.id);
|
|
141
155
|
if (!userId)
|
|
142
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;
|
|
143
163
|
const lastActivity = stringValue(seat.last_activity_at);
|
|
144
164
|
return [{
|
|
145
165
|
id: slugifySourceId(["github-copilot-seat", options.accountId, userId, plan].filter(Boolean).join("-")),
|
|
@@ -149,10 +169,11 @@ export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
|
149
169
|
inputTokens: 0,
|
|
150
170
|
outputTokens: 0,
|
|
151
171
|
amountUsd: seatUsd,
|
|
152
|
-
costConfidence: "estimated",
|
|
172
|
+
costConfidence: seatUsd === null ? "missing" : "estimated",
|
|
153
173
|
userId,
|
|
154
174
|
projectId: options.accountId,
|
|
155
175
|
providerCostType: "copilot_seat_reconciliation",
|
|
176
|
+
usageGranularity: "seat",
|
|
156
177
|
quantity: 1,
|
|
157
178
|
operation: `GitHub Copilot ${plan} seat; ${lastActivity ? `last activity ${lastActivity}` : "no recent activity reported"}`
|
|
158
179
|
}];
|
|
@@ -161,20 +182,33 @@ export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
|
161
182
|
export function normalizeAnthropicCostResponse(response, options) {
|
|
162
183
|
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
163
184
|
const records = [];
|
|
164
|
-
for (const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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;
|
|
168
200
|
if (currency !== "usd")
|
|
169
201
|
continue;
|
|
170
|
-
const amountUsd = parseMinorUsd(
|
|
202
|
+
const amountUsd = parseMinorUsd(resultValue.amount);
|
|
171
203
|
if (typeof amountUsd !== "number")
|
|
172
204
|
continue;
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
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);
|
|
176
210
|
records.push({
|
|
177
|
-
id: slugifySourceId(["anthropic-costs", timestamp, workspaceId, model,
|
|
211
|
+
id: slugifySourceId(["anthropic-costs", timestamp, workspaceId, model, tokenType ?? costType].filter(Boolean).join("-")),
|
|
178
212
|
timestamp: new Date(timestamp).toISOString(),
|
|
179
213
|
source: {
|
|
180
214
|
id: options.sourceId,
|
|
@@ -190,7 +224,8 @@ export function normalizeAnthropicCostResponse(response, options) {
|
|
|
190
224
|
costConfidence: "verified",
|
|
191
225
|
projectId: workspaceId,
|
|
192
226
|
workspaceId,
|
|
193
|
-
providerCostType:
|
|
227
|
+
providerCostType: costType ?? "anthropic_cost",
|
|
228
|
+
usageGranularity: "billing_bucket",
|
|
194
229
|
operation: description
|
|
195
230
|
});
|
|
196
231
|
}
|
|
@@ -222,6 +257,7 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
|
222
257
|
costConfidence: "missing",
|
|
223
258
|
projectId: options.accountId,
|
|
224
259
|
providerCostType: "copilot_usage_metrics",
|
|
260
|
+
usageGranularity: "daily_aggregate",
|
|
225
261
|
operation: feature
|
|
226
262
|
});
|
|
227
263
|
}
|
|
@@ -233,12 +269,13 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
|
233
269
|
timestamp,
|
|
234
270
|
source: { id: options.sourceId, name: "GitHub Copilot metrics API", provider: "github-copilot", confidence: "verified", observedFrom: options.observedFrom },
|
|
235
271
|
model: "github-copilot-cli",
|
|
236
|
-
inputTokens:
|
|
237
|
-
outputTokens:
|
|
272
|
+
inputTokens: nonNegativeIntegerValue(tokenUsage?.prompt_tokens_sum) ?? 0,
|
|
273
|
+
outputTokens: nonNegativeIntegerValue(tokenUsage?.output_tokens_sum) ?? 0,
|
|
238
274
|
amountUsd: null,
|
|
239
275
|
costConfidence: "missing",
|
|
240
276
|
projectId: options.accountId,
|
|
241
277
|
providerCostType: "copilot_cli_metrics",
|
|
278
|
+
usageGranularity: "daily_aggregate",
|
|
242
279
|
operation: "CLI requests"
|
|
243
280
|
});
|
|
244
281
|
}
|
|
@@ -246,13 +283,14 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
|
246
283
|
return records;
|
|
247
284
|
}
|
|
248
285
|
export function normalizeCursorSpendResponse(response, options) {
|
|
249
|
-
const users = extractArray(response, "
|
|
250
|
-
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();
|
|
251
289
|
return users.flatMap((user) => {
|
|
252
290
|
if (!isRecord(user))
|
|
253
291
|
return [];
|
|
254
|
-
const userId = stringValue(user.email) ?? stringValue(user.
|
|
255
|
-
const cents = numberValue(user.spendCents)
|
|
292
|
+
const userId = stringValue(user.email) ?? stringValue(user.userId);
|
|
293
|
+
const cents = numberValue(user.spendCents);
|
|
256
294
|
if (!userId || typeof cents !== "number")
|
|
257
295
|
return [];
|
|
258
296
|
return [{
|
|
@@ -270,6 +308,7 @@ export function normalizeCursorSpendResponse(response, options) {
|
|
|
270
308
|
userId,
|
|
271
309
|
projectId: options.accountId,
|
|
272
310
|
providerCostType: "cursor_spend",
|
|
311
|
+
usageGranularity: "user_aggregate",
|
|
273
312
|
operation: "Cursor team spend"
|
|
274
313
|
}];
|
|
275
314
|
});
|
|
@@ -278,19 +317,75 @@ export async function fetchProviderUsageRecords(input) {
|
|
|
278
317
|
const token = (input.tokenResolver ?? defaultTokenResolver)(input.authReference);
|
|
279
318
|
const fetcher = input.fetcher ?? defaultFetcher;
|
|
280
319
|
const sourceId = input.sourceId ?? `${input.provider}-provider-api`;
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|
|
283
357
|
}
|
|
284
|
-
|
|
285
|
-
|
|
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);
|
|
286
380
|
}
|
|
287
|
-
if (
|
|
288
|
-
return
|
|
381
|
+
if (Array.isArray(value)) {
|
|
382
|
+
return value.map((item) => redactResolvedCredentialValue(item, credentialVariants));
|
|
289
383
|
}
|
|
290
|
-
if (
|
|
291
|
-
return
|
|
384
|
+
if (value && typeof value === "object") {
|
|
385
|
+
return Object.fromEntries(Object.entries(value)
|
|
386
|
+
.map(([key, item]) => [key, redactResolvedCredentialValue(item, credentialVariants)]));
|
|
292
387
|
}
|
|
293
|
-
|
|
388
|
+
return value;
|
|
294
389
|
}
|
|
295
390
|
async function fetchOpenAi(input, token, fetcher, sourceId) {
|
|
296
391
|
const request = {
|
|
@@ -298,7 +393,9 @@ async function fetchOpenAi(input, token, fetcher, sourceId) {
|
|
|
298
393
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
|
299
394
|
};
|
|
300
395
|
const costFetch = await fetchPaginatedJson(fetcher, buildOpenAiCostsUrl(input.startTime, input.endTime), request, "openai", "OpenAI costs API");
|
|
396
|
+
markMalformedCostRows(costFetch, "openai", "OpenAI costs API");
|
|
301
397
|
const usageFetch = await fetchPaginatedJson(fetcher, buildOpenAiUsageUrl(input.startTime, input.endTime), request, "openai", "OpenAI usage API");
|
|
398
|
+
markMalformedUsageRows(usageFetch, "openai", "OpenAI usage API");
|
|
302
399
|
const records = [
|
|
303
400
|
...costFetch.pages.flatMap((page) => normalizeOpenAiCostResponse(page, { sourceId, observedFrom: "OpenAI organization costs API" })),
|
|
304
401
|
...usageFetch.pages.flatMap((page) => normalizeOpenAiUsageResponse(page, { sourceId, observedFrom: "OpenAI organization usage API" }))
|
|
@@ -311,7 +408,11 @@ async function fetchAnthropic(input, token, fetcher, sourceId) {
|
|
|
311
408
|
headers: { "x-api-key": token, "anthropic-version": "2023-06-01", "Content-Type": "application/json" }
|
|
312
409
|
};
|
|
313
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");
|
|
314
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
|
+
}
|
|
315
416
|
const records = [
|
|
316
417
|
...costFetch.pages.flatMap((page) => normalizeAnthropicCostResponse(page, { sourceId, observedFrom: "Anthropic Admin Cost Report" })),
|
|
317
418
|
...claudeCodeFetches.flatMap((fetchResult) => fetchResult.pages.flatMap((page) => normalizeAnthropicClaudeCodeUsageResponse(page, { sourceId, observedFrom: "Anthropic Claude Code Usage Report", accountId: input.accountId })))
|
|
@@ -324,38 +425,230 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
|
|
|
324
425
|
throw new Error("GitHub Copilot connector requires --org or --enterprise.");
|
|
325
426
|
const request = {
|
|
326
427
|
method: "GET",
|
|
327
|
-
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" }
|
|
328
429
|
};
|
|
329
|
-
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");
|
|
330
435
|
const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
|
|
436
|
+
if (seatFetch)
|
|
437
|
+
assessGitHubCopilotSeatCompleteness(seatFetch);
|
|
331
438
|
const metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
|
|
332
439
|
const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
|
|
333
440
|
return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
|
|
334
441
|
}
|
|
335
442
|
async function fetchCursor(input, token, fetcher, sourceId) {
|
|
336
443
|
const accountId = input.accountId ?? input.org ?? "cursor-team";
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
357
508
|
};
|
|
358
|
-
|
|
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
|
+
}
|
|
359
652
|
}
|
|
360
653
|
async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
|
|
361
654
|
const pages = [];
|
|
@@ -566,6 +859,228 @@ function headerNumber(headers, name) {
|
|
|
566
859
|
function hasHeaderGetter(headers) {
|
|
567
860
|
return typeof headers?.get === "function";
|
|
568
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
|
+
}
|
|
569
1084
|
function detectResponseDrift(payload, provider, label) {
|
|
570
1085
|
const known = knownProviderFields(provider, label);
|
|
571
1086
|
const issues = [];
|
|
@@ -607,20 +1122,20 @@ function knownProviderFields(provider, label) {
|
|
|
607
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[]"]);
|
|
608
1123
|
}
|
|
609
1124
|
if (provider === "github-copilot" && label.toLowerCase().includes("metrics")) {
|
|
610
|
-
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"]);
|
|
611
1126
|
}
|
|
612
1127
|
if (provider === "github-copilot" && label.toLowerCase().includes("seats")) {
|
|
613
|
-
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"]);
|
|
614
1129
|
}
|
|
615
1130
|
if (provider === "cursor") {
|
|
616
|
-
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"]);
|
|
617
1132
|
}
|
|
618
1133
|
return new Set([...common]);
|
|
619
1134
|
}
|
|
620
1135
|
function qaSummary(provider, fetches) {
|
|
621
1136
|
return {
|
|
622
1137
|
provider,
|
|
623
|
-
coverage: fetches.every((fetchResult) => fetchResult.pagination.stoppedBecause === "complete") ? "complete" : "partial",
|
|
1138
|
+
coverage: fetches.every((fetchResult) => fetchResult.pagination.stoppedBecause === "complete" && fetchResult.coverageIncomplete !== true) ? "complete" : "partial",
|
|
624
1139
|
requestedEndpoints: Array.from(new Set(fetches.map((fetchResult) => fetchResult.pagination.label))),
|
|
625
1140
|
pagination: fetches.map((fetchResult) => fetchResult.pagination),
|
|
626
1141
|
rateLimits: fetches.flatMap((fetchResult) => fetchResult.rateLimits),
|
|
@@ -681,8 +1196,26 @@ function extractProviderMessage(payload) {
|
|
|
681
1196
|
return stringValue(error?.message) ?? stringValue(payload.message) ?? "";
|
|
682
1197
|
}
|
|
683
1198
|
function sanitizeProviderMessage(message) {
|
|
684
|
-
//
|
|
685
|
-
|
|
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, " ");
|
|
686
1219
|
}
|
|
687
1220
|
export function summarizeProviderFinancials(records) {
|
|
688
1221
|
const providerReportedBilledUsd = sumAmounts(records.filter((record) => record.costConfidence === "verified"));
|
|
@@ -739,7 +1272,7 @@ function providerResult(provider, sourceId, authReference, records, qa) {
|
|
|
739
1272
|
: headlineConfidence;
|
|
740
1273
|
return {
|
|
741
1274
|
provider,
|
|
742
|
-
source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd
|
|
1275
|
+
source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd, completeness }),
|
|
743
1276
|
records,
|
|
744
1277
|
fetchedAt: new Date().toISOString(),
|
|
745
1278
|
coverage,
|
|
@@ -750,17 +1283,29 @@ function providerResult(provider, sourceId, authReference, records, qa) {
|
|
|
750
1283
|
}
|
|
751
1284
|
export function createProviderConnection(input) {
|
|
752
1285
|
const source = createProviderConnectorStub(input.provider, "provider_api", input.fetchedAt);
|
|
753
|
-
const total =
|
|
754
|
-
const
|
|
1286
|
+
const total = input.totalUsd === null ? "an unavailable financial headline" : formatProviderUsd(input.totalUsd);
|
|
1287
|
+
const financialEvidence = input.completeness ?? "verified";
|
|
755
1288
|
return {
|
|
756
1289
|
...source,
|
|
757
1290
|
id: input.sourceId ?? source.id,
|
|
758
|
-
|
|
1291
|
+
validationCoverage: validationCoverageForCompletedProviderSync(input.provider),
|
|
1292
|
+
financialEvidence,
|
|
759
1293
|
authReference: input.authReference,
|
|
760
1294
|
fieldsMissing: [],
|
|
761
|
-
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}.`
|
|
762
1296
|
};
|
|
763
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
|
+
}
|
|
764
1309
|
export function resolveTokenReference(reference, env = process.env) {
|
|
765
1310
|
if (!reference.startsWith("env:")) {
|
|
766
1311
|
throw new Error("Provider auth reference must be a local reference such as env:OPENAI_ADMIN_KEY; raw secrets are not accepted.");
|
|
@@ -835,20 +1380,45 @@ async function defaultFetcher(url, init) {
|
|
|
835
1380
|
return fetch(url, { ...init, redirect: "manual" });
|
|
836
1381
|
}
|
|
837
1382
|
function parseMinorUsd(value) {
|
|
838
|
-
const numeric = typeof value === "number"
|
|
839
|
-
|
|
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;
|
|
840
1389
|
}
|
|
841
1390
|
/** Amount already denominated in dollars, as number or decimal string. */
|
|
842
1391
|
function parseDollarUsd(value) {
|
|
843
|
-
const numeric = typeof value === "number"
|
|
844
|
-
|
|
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;
|
|
845
1398
|
}
|
|
846
1399
|
function numberValue(value) {
|
|
847
1400
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
848
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
|
+
}
|
|
849
1410
|
function stringValue(value) {
|
|
850
1411
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
851
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
|
+
}
|
|
852
1422
|
function extractArray(value, key) {
|
|
853
1423
|
if (Array.isArray(value))
|
|
854
1424
|
return value;
|
|
@@ -856,6 +1426,20 @@ function extractArray(value, key) {
|
|
|
856
1426
|
return value[key];
|
|
857
1427
|
return [];
|
|
858
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
|
+
}
|
|
859
1443
|
function isObject(value) {
|
|
860
1444
|
return typeof value === "object" && value !== null;
|
|
861
1445
|
}
|