@agent-finops/core 0.5.9 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 bucket of data) {
7
- const startTime = typeof bucket.start_time === "number" ? bucket.start_time : 0;
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
- for (const result of bucket.results ?? []) {
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 = result.amount?.currency?.toLowerCase() === "usd" || !result.amount?.currency
13
- ? parseDollarUsd(result.amount?.value)
25
+ const amountUsd = currency === "usd"
26
+ ? parseDollarUsd(amount?.value)
14
27
  : undefined;
15
28
  if (typeof amountUsd !== "number")
16
29
  continue;
17
- const lineItem = result.line_item ?? "OpenAI organization costs";
18
- const projectId = result.project_id ?? undefined;
19
- const apiKeyId = result.api_key_id ?? undefined;
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: typeof result.quantity === "number" ? result.quantity : undefined,
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 = numberValue(result.input_tokens) ?? 0;
58
- const outputTokens = numberValue(result.output_tokens) ?? 0;
59
- const cachedTokens = numberValue(result.input_cached_tokens) ?? 0;
60
- const audioInputTokens = numberValue(result.input_audio_tokens) ?? 0;
61
- const audioOutputTokens = numberValue(result.output_audio_tokens) ?? 0;
62
- const requestCount = numberValue(result.num_model_requests);
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: numberValue(result.num_model_requests),
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 = numberValue(core.num_sessions) ?? 0;
98
- const added = numberValue(lines.added) ?? 0;
99
- const removed = numberValue(lines.removed) ?? 0;
100
- const commits = numberValue(core.commits_by_claude_code) ?? 0;
101
- const prs = numberValue(core.pull_requests_by_claude_code) ?? 0;
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: (numberValue(tokens.input) ?? 0) + (numberValue(tokens.cache_read) ?? 0) + (numberValue(tokens.cache_creation) ?? 0),
120
- outputTokens: numberValue(tokens.output) ?? 0,
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 bucket of data) {
169
- const timestamp = bucket.starting_at ?? new Date(0).toISOString();
170
- for (const result of bucket.results ?? []) {
171
- const currency = result.currency?.toLowerCase() ?? "usd";
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(result.amount);
202
+ const amountUsd = parseMinorUsd(resultValue.amount);
175
203
  if (typeof amountUsd !== "number")
176
204
  continue;
177
- const description = result.description ?? result.cost_type ?? "Anthropic organization costs";
178
- const model = result.model ?? description;
179
- const workspaceId = result.workspace_id ?? undefined;
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, result.token_type ?? result.cost_type].filter(Boolean).join("-")),
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: result.cost_type ?? "anthropic_cost",
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: numberValue(tokenUsage?.prompt_tokens_sum) ?? 0,
243
- outputTokens: numberValue(tokenUsage?.output_tokens_sum) ?? 0,
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, "users").length > 0 ? extractArray(response, "users") : extractArray(response, "data");
257
- const timestamp = new Date().toISOString();
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.emailAddress) ?? stringValue(user.userId) ?? stringValue(user.id);
262
- const cents = numberValue(user.spendCents) ?? numberValue(user.usageBasedCents) ?? numberValue(user.chargedCents);
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 [{
@@ -283,22 +314,84 @@ export function normalizeCursorSpendResponse(response, options) {
283
314
  });
284
315
  }
285
316
  export async function fetchProviderUsageRecords(input) {
317
+ // Validate explicit bounds before resolving a credential or making a request.
318
+ // The interval-aware OpenAI/Anthropic result paths call the same pure helper
319
+ // to attach normalized bounds only after a successful fetch. Copilot and
320
+ // Cursor do not currently constrain their reads to these requested bounds,
321
+ // so they deliberately return no coverage interval.
322
+ requestedCoverageInterval(input);
286
323
  const token = (input.tokenResolver ?? defaultTokenResolver)(input.authReference);
287
324
  const fetcher = input.fetcher ?? defaultFetcher;
288
325
  const sourceId = input.sourceId ?? `${input.provider}-provider-api`;
289
- if (input.provider === "openai") {
290
- return fetchOpenAi(input, token, fetcher, sourceId);
326
+ const credentialVariants = resolvedCredentialVariants(input.provider, token);
327
+ try {
328
+ if (input.provider === "openai") {
329
+ return redactResolvedCredentialValue(await fetchOpenAi(input, token, fetcher, sourceId), credentialVariants);
330
+ }
331
+ if (input.provider === "anthropic") {
332
+ return redactResolvedCredentialValue(await fetchAnthropic(input, token, fetcher, sourceId), credentialVariants);
333
+ }
334
+ if (input.provider === "github-copilot") {
335
+ return redactResolvedCredentialValue(await fetchGitHubCopilot(input, token, fetcher, sourceId), credentialVariants);
336
+ }
337
+ if (input.provider === "cursor") {
338
+ return redactResolvedCredentialValue(await fetchCursor(input, token, fetcher, sourceId), credentialVariants);
339
+ }
340
+ throw new Error(`Provider connector not implemented yet: ${input.provider}`);
341
+ }
342
+ catch (error) {
343
+ // This is the shared credential boundary for CLI, MCP, and future hosts.
344
+ // Provider payloads, status text, fetch implementations, and validation
345
+ // errors are all untrusted after a credential has been resolved. Exact
346
+ // replacement covers opaque tokens that do not match a known key shape.
347
+ throw redactResolvedCredentialError(error, credentialVariants);
348
+ }
349
+ }
350
+ function resolvedCredentialVariants(provider, token) {
351
+ const values = token ? [token] : [];
352
+ if (provider === "cursor" && token) {
353
+ try {
354
+ const encoded = btoaCompat(`${token}:`);
355
+ const unpadded = encoded.replace(/=+$/g, "");
356
+ const base64Url = unpadded.replace(/\+/g, "-").replace(/\//g, "_");
357
+ values.push(encoded, unpadded, base64Url, `Basic ${encoded}`, `Basic ${unpadded}`, `Basic ${base64Url}`);
358
+ }
359
+ catch {
360
+ // Cursor's request will fail on the same unsupported credential. Keep the
361
+ // raw value in the redaction set so that failure is still safe to return.
362
+ }
291
363
  }
292
- if (input.provider === "anthropic") {
293
- return fetchAnthropic(input, token, fetcher, sourceId);
364
+ const encodedValues = values.flatMap((value) => {
365
+ const encoded = encodeURIComponent(value);
366
+ return encoded === value ? [value] : [value, encoded];
367
+ });
368
+ return Array.from(new Set(encodedValues)).sort((left, right) => right.length - left.length);
369
+ }
370
+ function exactRedactCredentialValues(value, credentialVariants) {
371
+ return credentialVariants.reduce((safeValue, credential) => safeValue.split(credential).join("[REDACTED]"), value);
372
+ }
373
+ function redactResolvedCredentialError(error, credentialVariants) {
374
+ const rawMessage = error instanceof Error ? error.message : String(error);
375
+ const withoutResolvedCredential = exactRedactCredentialValues(rawMessage, credentialVariants);
376
+ // Strip controls before a second exact-redaction pass: an adversarial
377
+ // provider can splice ANSI bytes through an opaque credential so the first
378
+ // literal replacement misses it and control stripping reconstructs it.
379
+ const safeMessage = exactRedactCredentialValues(sanitizeProviderMessage(withoutResolvedCredential), credentialVariants).trim();
380
+ return new Error(safeMessage || "Provider connector request failed without a safe error message.");
381
+ }
382
+ function redactResolvedCredentialValue(value, credentialVariants) {
383
+ if (typeof value === "string") {
384
+ const withoutResolvedCredential = exactRedactCredentialValues(value, credentialVariants);
385
+ return exactRedactCredentialValues(sanitizeProviderMessage(withoutResolvedCredential), credentialVariants);
294
386
  }
295
- if (input.provider === "github-copilot") {
296
- return fetchGitHubCopilot(input, token, fetcher, sourceId);
387
+ if (Array.isArray(value)) {
388
+ return value.map((item) => redactResolvedCredentialValue(item, credentialVariants));
297
389
  }
298
- if (input.provider === "cursor") {
299
- return fetchCursor(input, token, fetcher, sourceId);
390
+ if (value && typeof value === "object") {
391
+ return Object.fromEntries(Object.entries(value)
392
+ .map(([key, item]) => [key, redactResolvedCredentialValue(item, credentialVariants)]));
300
393
  }
301
- throw new Error(`Provider connector not implemented yet: ${input.provider}`);
394
+ return value;
302
395
  }
303
396
  async function fetchOpenAi(input, token, fetcher, sourceId) {
304
397
  const request = {
@@ -306,12 +399,14 @@ async function fetchOpenAi(input, token, fetcher, sourceId) {
306
399
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
307
400
  };
308
401
  const costFetch = await fetchPaginatedJson(fetcher, buildOpenAiCostsUrl(input.startTime, input.endTime), request, "openai", "OpenAI costs API");
402
+ markMalformedCostRows(costFetch, "openai", "OpenAI costs API");
309
403
  const usageFetch = await fetchPaginatedJson(fetcher, buildOpenAiUsageUrl(input.startTime, input.endTime), request, "openai", "OpenAI usage API");
404
+ markMalformedUsageRows(usageFetch, "openai", "OpenAI usage API");
310
405
  const records = [
311
406
  ...costFetch.pages.flatMap((page) => normalizeOpenAiCostResponse(page, { sourceId, observedFrom: "OpenAI organization costs API" })),
312
407
  ...usageFetch.pages.flatMap((page) => normalizeOpenAiUsageResponse(page, { sourceId, observedFrom: "OpenAI organization usage API" }))
313
408
  ];
314
- return providerResult("openai", sourceId, input.authReference, records, qaSummary("openai", [costFetch, usageFetch]));
409
+ return providerResult("openai", sourceId, input.authReference, records, qaSummary("openai", [costFetch, usageFetch]), requestedCoverageInterval(input));
315
410
  }
316
411
  async function fetchAnthropic(input, token, fetcher, sourceId) {
317
412
  const costRequest = {
@@ -319,12 +414,16 @@ async function fetchAnthropic(input, token, fetcher, sourceId) {
319
414
  headers: { "x-api-key": token, "anthropic-version": "2023-06-01", "Content-Type": "application/json" }
320
415
  };
321
416
  const costFetch = await fetchPaginatedJson(fetcher, buildAnthropicCostUrl(input.startTime, input.endTime), costRequest, "anthropic", "Anthropic Admin cost report");
417
+ markMalformedCostRows(costFetch, "anthropic", "Anthropic Admin cost report");
322
418
  const claudeCodeFetches = await fetchDateRangeJson(fetcher, buildAnthropicClaudeCodeUrl, input.startTime, input.endTime, costRequest, "anthropic", "Anthropic Claude Code usage report");
419
+ for (const fetchResult of claudeCodeFetches) {
420
+ markMalformedUsageRows(fetchResult, "anthropic", "Anthropic Claude Code usage report");
421
+ }
323
422
  const records = [
324
423
  ...costFetch.pages.flatMap((page) => normalizeAnthropicCostResponse(page, { sourceId, observedFrom: "Anthropic Admin Cost Report" })),
325
424
  ...claudeCodeFetches.flatMap((fetchResult) => fetchResult.pages.flatMap((page) => normalizeAnthropicClaudeCodeUsageResponse(page, { sourceId, observedFrom: "Anthropic Claude Code Usage Report", accountId: input.accountId })))
326
425
  ];
327
- return providerResult("anthropic", sourceId, input.authReference, records, qaSummary("anthropic", [costFetch, ...claudeCodeFetches]));
426
+ return providerResult("anthropic", sourceId, input.authReference, records, qaSummary("anthropic", [costFetch, ...claudeCodeFetches]), requestedCoverageInterval(input));
328
427
  }
329
428
  async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
330
429
  const accountId = input.org ?? input.enterprise;
@@ -332,38 +431,230 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
332
431
  throw new Error("GitHub Copilot connector requires --org or --enterprise.");
333
432
  const request = {
334
433
  method: "GET",
335
- headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }
434
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2026-03-10" }
336
435
  };
337
- const metricsFetch = await fetchPaginatedJson(fetcher, buildGitHubCopilotMetricsUrl(input), request, "github-copilot", "GitHub Copilot metrics");
436
+ const metricsManifestResponse = await fetchJsonOrThrow(fetcher, buildGitHubCopilotMetricsUrl(input), request, "github-copilot", "GitHub Copilot metrics manifest");
437
+ const metricsManifest = metricsManifestResponse.payload;
438
+ const metricsDownloadLinks = requireStringArray(metricsManifest, "download_links", "GitHub Copilot metrics manifest");
439
+ const metricsFetch = await fetchGitHubCopilotSignedReports(fetcher, metricsDownloadLinks, metricsManifest, metricsManifestResponse.rateLimit);
440
+ markMalformedUsageRows(metricsFetch, "github-copilot", "GitHub Copilot metrics reports");
338
441
  const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
442
+ if (seatFetch)
443
+ assessGitHubCopilotSeatCompleteness(seatFetch);
339
444
  const metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
340
445
  const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
341
446
  return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
342
447
  }
343
448
  async function fetchCursor(input, token, fetcher, sourceId) {
344
449
  const accountId = input.accountId ?? input.org ?? "cursor-team";
345
- const response = await fetchJsonOrThrow(fetcher, "https://api.cursor.com/teams/spend", {
346
- method: "POST",
347
- headers: { Authorization: `Basic ${btoaCompat(`${token}:`)}`, "Content-Type": "application/json" },
348
- body: JSON.stringify({})
349
- }, "cursor", "Cursor Admin API spend");
350
- const page = response.payload;
351
- const records = normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId });
352
- // The Cursor connector is matched to the published spec but not live-verified.
353
- // If the API answered with content but no spend fields we recognize, say so
354
- // loudly rather than silently report $0 (which reads as "you spent nothing").
355
- if (records.length === 0 && isRecord(page) && Object.keys(page).length > 0) {
356
- throw new Error("Cursor returned data but no spend fields this connector recognizes " +
357
- `(saw: ${Object.keys(page).slice(0, 8).join(", ")}). The Cursor connector is beta — ` +
358
- "please open an issue with this field list so we can map it: https://github.com/futurastudio/ai-spend-agent/issues");
359
- }
360
- const singleFetch = {
361
- pages: [page],
362
- pagination: { label: "Cursor Admin API spend", pagesFetched: 1, stoppedBecause: "complete", maxPages: 1 },
363
- rateLimits: response.rateLimit ? [response.rateLimit] : [],
364
- responseDrift: detectResponseDrift(page, "cursor", "Cursor Admin API spend")
450
+ const spendFetch = await fetchCursorSpendPages(fetcher, token);
451
+ const records = spendFetch.pages.flatMap((page) => normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId }));
452
+ return providerResult("cursor", sourceId, input.authReference, records, qaSummary("cursor", [spendFetch]));
453
+ }
454
+ async function fetchCursorSpendPages(fetcher, token) {
455
+ const label = "Cursor Admin API spend";
456
+ const pages = [];
457
+ const rateLimits = [];
458
+ const responseDrift = [];
459
+ const maxPages = 50;
460
+ const pageSize = 100;
461
+ let expectedTotalPages;
462
+ let expectedTotalMembers;
463
+ let stoppedBecause = "complete";
464
+ let note;
465
+ for (let pageNumber = 1; pageNumber <= (expectedTotalPages ?? 1) && pageNumber <= maxPages; pageNumber += 1) {
466
+ let response;
467
+ try {
468
+ response = await fetchJsonOrThrow(fetcher, "https://api.cursor.com/teams/spend", {
469
+ method: "POST",
470
+ headers: { Authorization: `Basic ${btoaCompat(`${token}:`)}`, "Content-Type": "application/json" },
471
+ body: JSON.stringify({ page: pageNumber, pageSize })
472
+ }, "cursor", label);
473
+ }
474
+ catch (error) {
475
+ if (pages.length === 0)
476
+ throw error;
477
+ stoppedBecause = "fetch_error";
478
+ note = `Stopped after ${pages.length} page(s): ${sanitizeProviderMessage(error instanceof Error ? error.message : String(error))}`;
479
+ break;
480
+ }
481
+ const page = response.payload;
482
+ const pageMetadata = validateCursorSpendPage(page, pageNumber);
483
+ if (expectedTotalPages === undefined)
484
+ expectedTotalPages = pageMetadata.totalPages;
485
+ if (expectedTotalMembers === undefined)
486
+ expectedTotalMembers = pageMetadata.totalMembers;
487
+ if (pageMetadata.totalPages !== expectedTotalPages || pageMetadata.totalMembers !== expectedTotalMembers) {
488
+ stoppedBecause = "fetch_error";
489
+ note = `Cursor pagination metadata changed on page ${pageNumber} (totalPages ${expectedTotalPages}→${pageMetadata.totalPages}, totalMembers ${expectedTotalMembers}→${pageMetadata.totalMembers}); results may be incomplete.`;
490
+ pages.push(page);
491
+ responseDrift.push({ label, field: "totalPages/totalMembers", issue: note });
492
+ break;
493
+ }
494
+ pages.push(page);
495
+ if (response.rateLimit)
496
+ rateLimits.push(response.rateLimit);
497
+ responseDrift.push(...detectResponseDrift(page, "cursor", label));
498
+ }
499
+ if ((expectedTotalPages ?? 0) > maxPages && stoppedBecause === "complete") {
500
+ stoppedBecause = "max_pages";
501
+ note = `Cursor reported ${expectedTotalPages} pages, exceeding the ${maxPages}-page connector limit.`;
502
+ }
503
+ const fetchedMembers = pages.reduce((sum, page) => sum + extractArray(page, "teamMemberSpend").length, 0);
504
+ if (stoppedBecause === "complete" && typeof expectedTotalMembers === "number" && fetchedMembers !== expectedTotalMembers) {
505
+ stoppedBecause = "missing_cursor";
506
+ note = `Cursor reported ${expectedTotalMembers} members but returned ${fetchedMembers}; refusing to mark the sync complete.`;
507
+ responseDrift.push({ label, field: "totalMembers", issue: note });
508
+ }
509
+ return {
510
+ pages,
511
+ pagination: { label, pagesFetched: pages.length, stoppedBecause, maxPages, limitPerPage: pageSize, ...(note ? { note } : {}) },
512
+ rateLimits,
513
+ responseDrift
514
+ };
515
+ }
516
+ function validateCursorSpendPage(page, pageNumber) {
517
+ if (!isRecord(page) || !Array.isArray(page.teamMemberSpend)) {
518
+ const fields = isRecord(page) ? Object.keys(page).slice(0, 8).join(", ") : typeof page;
519
+ throw new Error(`Cursor spend page ${pageNumber} is missing canonical teamMemberSpend data (saw: ${fields}).`);
520
+ }
521
+ const totalPages = numberValue(page.totalPages);
522
+ if (typeof totalPages !== "number" || !Number.isInteger(totalPages) || totalPages < 1) {
523
+ throw new Error(`Cursor spend page ${pageNumber} has invalid or missing totalPages; completeness cannot be proven.`);
524
+ }
525
+ const totalMembers = numberValue(page.totalMembers);
526
+ if (typeof totalMembers !== "number" || !Number.isInteger(totalMembers) || totalMembers < 0) {
527
+ throw new Error(`Cursor spend page ${pageNumber} has invalid or missing totalMembers; completeness cannot be proven.`);
528
+ }
529
+ for (const [index, member] of page.teamMemberSpend.entries()) {
530
+ const spendCents = isRecord(member) ? numberValue(member.spendCents) : undefined;
531
+ if (!isRecord(member) || (!stringValue(member.email) && !stringValue(member.userId)) || typeof spendCents !== "number" || spendCents < 0) {
532
+ const fields = isRecord(member) ? Object.keys(member).slice(0, 8).join(", ") : typeof member;
533
+ throw new Error(`Cursor spend page ${pageNumber} member ${index + 1} is missing email/userId or a non-negative spendCents value (saw: ${fields}).`);
534
+ }
535
+ if (member.fastPremiumRequests !== undefined && typeof nonNegativeIntegerValue(member.fastPremiumRequests) !== "number") {
536
+ throw new Error(`Cursor spend page ${pageNumber} member ${index + 1} has an invalid fastPremiumRequests quantity; expected a non-negative integer.`);
537
+ }
538
+ }
539
+ return { totalPages, totalMembers };
540
+ }
541
+ async function fetchGitHubCopilotSignedReports(fetcher, downloadLinks, manifest, manifestRateLimit) {
542
+ const label = "GitHub Copilot metrics reports";
543
+ const maxReports = 100;
544
+ if (downloadLinks.length > maxReports) {
545
+ throw new Error(`GitHub Copilot metrics manifest returned ${downloadLinks.length} report files, exceeding the ${maxReports}-file safety limit.`);
546
+ }
547
+ const pages = [];
548
+ const responseDrift = detectResponseDrift(manifest, "github-copilot", "GitHub Copilot metrics manifest");
549
+ for (const [index, candidate] of downloadLinks.entries()) {
550
+ const safeUrl = validateSignedDownloadUrl(candidate);
551
+ if (!safeUrl) {
552
+ throw new Error(`GitHub Copilot metrics report ${index + 1} had an unsafe signed download URL; only public HTTPS URLs without embedded credentials are accepted.`);
553
+ }
554
+ const body = await fetchTextOrThrow(fetcher, safeUrl, {
555
+ method: "GET",
556
+ // Signed report URLs carry their own authorization. Never replay the
557
+ // GitHub bearer token to a storage host.
558
+ headers: { Accept: "application/x-ndjson, application/json" }
559
+ }, "github-copilot", `GitHub Copilot metrics report ${index + 1}`);
560
+ const reports = parseNdjsonReports(body, index + 1);
561
+ if (reports.length === 0) {
562
+ throw new Error(`GitHub Copilot metrics report ${index + 1} was empty; refusing to report a complete sync.`);
563
+ }
564
+ for (const report of reports) {
565
+ if (!isRecord(report) || !Array.isArray(report.day_totals)) {
566
+ const fields = isRecord(report) ? Object.keys(report).slice(0, 8).join(", ") : typeof report;
567
+ throw new Error(`GitHub Copilot metrics report ${index + 1} did not contain the documented day_totals wrapper (saw: ${fields}).`);
568
+ }
569
+ pages.push(report);
570
+ responseDrift.push(...detectResponseDrift(report, "github-copilot", label));
571
+ }
572
+ }
573
+ return {
574
+ pages,
575
+ pagination: { label, pagesFetched: downloadLinks.length, stoppedBecause: "complete", maxPages: maxReports },
576
+ rateLimits: manifestRateLimit ? [manifestRateLimit] : [],
577
+ responseDrift
365
578
  };
366
- return providerResult("cursor", sourceId, input.authReference, records, qaSummary("cursor", [singleFetch]));
579
+ }
580
+ async function fetchTextOrThrow(fetcher, url, request, provider, label) {
581
+ let lastError;
582
+ for (let attempt = 0; attempt <= maxFetchRetries; attempt += 1) {
583
+ const response = await fetcher(url, request);
584
+ if (response.ok) {
585
+ if (!response.text)
586
+ throw new Error(`${label} did not expose a readable NDJSON body.`);
587
+ return response.text();
588
+ }
589
+ const payload = await response.json().catch(() => undefined);
590
+ lastError = new Error(providerPermissionPrompt(provider, label, response, payload));
591
+ const retryable = response.status === 429 || response.status >= 500;
592
+ if (!retryable || attempt === maxFetchRetries)
593
+ break;
594
+ const retryAfterSeconds = headerNumber(response.headers, "retry-after");
595
+ const delayMs = typeof retryAfterSeconds === "number"
596
+ ? Math.min(Math.max(retryAfterSeconds, 0) * 1000, maxRetryDelayMs)
597
+ : 500 * 2 ** attempt;
598
+ if (delayMs > 0)
599
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
600
+ }
601
+ throw lastError ?? new Error(`${label} request failed.`);
602
+ }
603
+ function parseNdjsonReports(body, reportNumber) {
604
+ const reports = [];
605
+ const lines = body.split(/\r?\n/).filter((line) => line.trim().length > 0);
606
+ for (const [lineIndex, line] of lines.entries()) {
607
+ let parsed;
608
+ try {
609
+ parsed = JSON.parse(line);
610
+ }
611
+ catch {
612
+ throw new Error(`GitHub Copilot metrics report ${reportNumber} contains malformed NDJSON at line ${lineIndex + 1}.`);
613
+ }
614
+ if (Array.isArray(parsed))
615
+ reports.push(...parsed);
616
+ else
617
+ reports.push(parsed);
618
+ }
619
+ return reports;
620
+ }
621
+ function validateSignedDownloadUrl(candidate) {
622
+ try {
623
+ const url = new URL(candidate);
624
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
625
+ if (url.protocol !== "https:" || (url.port && url.port !== "443") || url.username || url.password)
626
+ return undefined;
627
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname === "::" || hostname === "::1" || hostname === "0:0:0:0:0:0:0:1")
628
+ return undefined;
629
+ if (/^0\./.test(hostname) || /^127\./.test(hostname) || /^10\./.test(hostname) || /^192\.168\./.test(hostname) || /^169\.254\./.test(hostname))
630
+ return undefined;
631
+ if (/^(?:fc|fd|fe[89ab])/i.test(hostname))
632
+ return undefined;
633
+ const private172 = hostname.match(/^172\.(\d+)\./);
634
+ if (private172 && Number(private172[1]) >= 16 && Number(private172[1]) <= 31)
635
+ return undefined;
636
+ return url.toString();
637
+ }
638
+ catch {
639
+ return undefined;
640
+ }
641
+ }
642
+ function assessGitHubCopilotSeatCompleteness(fetchResult) {
643
+ const expected = fetchResult.pages
644
+ .map((page) => isRecord(page) ? numberValue(page.total_seats) : undefined)
645
+ .find((value) => typeof value === "number");
646
+ const actual = fetchResult.pages.reduce((sum, page) => sum + extractArray(page, "seats").length, 0);
647
+ if (typeof expected !== "number") {
648
+ if (fetchResult.pagination.stoppedBecause === "complete")
649
+ fetchResult.pagination.stoppedBecause = "missing_cursor";
650
+ fetchResult.pagination.note = "GitHub Copilot seats response omitted total_seats; completeness cannot be proven.";
651
+ fetchResult.responseDrift.push({ label: "GitHub Copilot seats", field: "total_seats", issue: fetchResult.pagination.note });
652
+ }
653
+ else if (fetchResult.pagination.stoppedBecause === "complete" && actual !== expected) {
654
+ fetchResult.pagination.stoppedBecause = "missing_cursor";
655
+ fetchResult.pagination.note = `GitHub reported ${expected} seats but returned ${actual}; completeness cannot be proven.`;
656
+ fetchResult.responseDrift.push({ label: "GitHub Copilot seats", field: "total_seats", issue: fetchResult.pagination.note });
657
+ }
367
658
  }
368
659
  async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
369
660
  const pages = [];
@@ -574,6 +865,228 @@ function headerNumber(headers, name) {
574
865
  function hasHeaderGetter(headers) {
575
866
  return typeof headers?.get === "function";
576
867
  }
868
+ function markMalformedCostRows(fetchResult, provider, label) {
869
+ const issues = [];
870
+ const maxDetailedIssues = 25;
871
+ let issueCount = 0;
872
+ const report = (field, issue) => {
873
+ issueCount += 1;
874
+ if (issues.length < maxDetailedIssues)
875
+ issues.push({ label, field, issue });
876
+ };
877
+ for (const page of fetchResult.pages) {
878
+ if (!isRecord(page) || !Array.isArray(page.data)) {
879
+ report("data", "cost response omitted the canonical data array; no financial completeness claim is safe");
880
+ continue;
881
+ }
882
+ for (const [bucketIndex, bucketValue] of page.data.entries()) {
883
+ const bucketPath = `data[${bucketIndex}]`;
884
+ if (!isRecord(bucketValue)) {
885
+ report(bucketPath, "cost response contained a non-object billing bucket");
886
+ continue;
887
+ }
888
+ if (provider === "openai") {
889
+ if (typeof validEpochSeconds(bucketValue.start_time) !== "number") {
890
+ report(`${bucketPath}.start_time`, "cost bucket had an invalid timestamp and its rows were excluded");
891
+ continue;
892
+ }
893
+ }
894
+ else if (!validDateTimeString(bucketValue.starting_at)) {
895
+ report(`${bucketPath}.starting_at`, "cost bucket had an invalid timestamp and its rows were excluded");
896
+ continue;
897
+ }
898
+ if (!Array.isArray(bucketValue.results)) {
899
+ report(`${bucketPath}.results`, "cost bucket omitted the canonical results array");
900
+ continue;
901
+ }
902
+ for (const [resultIndex, resultValue] of bucketValue.results.entries()) {
903
+ const resultPath = `${bucketPath}.results[${resultIndex}]`;
904
+ if (!isRecord(resultValue)) {
905
+ report(resultPath, "cost response contained a non-object billed-cost row");
906
+ continue;
907
+ }
908
+ if (provider === "openai") {
909
+ const amount = isRecord(resultValue.amount) ? resultValue.amount : undefined;
910
+ if (!amount) {
911
+ report(`${resultPath}.amount`, "billed-cost row had no canonical amount object and was excluded");
912
+ continue;
913
+ }
914
+ if (amount.currency !== undefined && (typeof amount.currency !== "string" || amount.currency.toLowerCase() !== "usd")) {
915
+ report(`${resultPath}.amount.currency`, "billed-cost row used an invalid or unsupported currency and was excluded from the USD headline");
916
+ continue;
917
+ }
918
+ if (typeof parseDollarUsd(amount.value) !== "number") {
919
+ report(`${resultPath}.amount.value`, "billed-cost row had an invalid dollar amount and was excluded");
920
+ }
921
+ if (resultValue.quantity !== undefined && typeof nonNegativeNumberValue(resultValue.quantity) !== "number") {
922
+ report(`${resultPath}.quantity`, "billed-cost row had a negative or invalid quantity; the quantity was excluded");
923
+ }
924
+ continue;
925
+ }
926
+ if (resultValue.currency !== undefined && (typeof resultValue.currency !== "string" || resultValue.currency.toLowerCase() !== "usd")) {
927
+ report(`${resultPath}.currency`, "billed-cost row used an invalid or unsupported currency and was excluded from the USD headline");
928
+ continue;
929
+ }
930
+ if (typeof parseMinorUsd(resultValue.amount) !== "number") {
931
+ report(`${resultPath}.amount`, "billed-cost row had an invalid minor-unit amount and was excluded");
932
+ }
933
+ }
934
+ }
935
+ }
936
+ if (issueCount === 0)
937
+ return;
938
+ if (issueCount > maxDetailedIssues) {
939
+ issues.push({
940
+ label,
941
+ field: "data[].results[]",
942
+ issue: `${issueCount - maxDetailedIssues} additional malformed billed-cost schema issue(s) were omitted from QA details`
943
+ });
944
+ }
945
+ fetchResult.coverageIncomplete = true;
946
+ fetchResult.responseDrift.push(...issues);
947
+ }
948
+ /**
949
+ * Provider APIs are untrusted even after transport succeeds. A negative or
950
+ * fractional token count cannot satisfy UsageRecord's finance-grade schema,
951
+ * and a negative count/quantity cannot support a completeness claim. Keep any
952
+ * independently valid evidence, but mark the whole source pull partial and
953
+ * omit the invalid values during normalization.
954
+ */
955
+ function markMalformedUsageRows(fetchResult, provider, label) {
956
+ const issues = [];
957
+ const maxDetailedIssues = 25;
958
+ let issueCount = 0;
959
+ const report = (field, issue) => {
960
+ issueCount += 1;
961
+ if (issues.length < maxDetailedIssues)
962
+ issues.push({ label, field, issue });
963
+ };
964
+ const checkInteger = (value, field, kind) => {
965
+ if (value !== undefined && typeof nonNegativeIntegerValue(value) !== "number") {
966
+ report(field, `${kind} must be a non-negative integer; the invalid value was excluded`);
967
+ }
968
+ };
969
+ for (const page of fetchResult.pages) {
970
+ if (provider === "openai") {
971
+ if (!isRecord(page) || !Array.isArray(page.data)) {
972
+ report("data", "usage response omitted the canonical data array; completeness cannot be proven");
973
+ continue;
974
+ }
975
+ for (const [bucketIndex, bucketValue] of page.data.entries()) {
976
+ const bucketPath = `data[${bucketIndex}]`;
977
+ if (!isRecord(bucketValue) || !Array.isArray(bucketValue.results)) {
978
+ report(bucketPath, "usage response contained a malformed bucket or omitted its results array");
979
+ continue;
980
+ }
981
+ for (const [resultIndex, resultValue] of bucketValue.results.entries()) {
982
+ const resultPath = `${bucketPath}.results[${resultIndex}]`;
983
+ if (!isRecord(resultValue)) {
984
+ report(resultPath, "usage response contained a non-object usage row");
985
+ continue;
986
+ }
987
+ for (const field of [
988
+ "input_tokens",
989
+ "input_uncached_tokens",
990
+ "input_cache_write_tokens",
991
+ "input_cached_tokens",
992
+ "input_text_tokens",
993
+ "input_image_tokens",
994
+ "input_audio_tokens",
995
+ "input_cached_text_tokens",
996
+ "input_cached_image_tokens",
997
+ "input_cached_audio_tokens",
998
+ "output_tokens",
999
+ "output_text_tokens",
1000
+ "output_image_tokens",
1001
+ "output_audio_tokens"
1002
+ ]) {
1003
+ checkInteger(resultValue[field], `${resultPath}.${field}`, "token count");
1004
+ }
1005
+ checkInteger(resultValue.num_model_requests, `${resultPath}.num_model_requests`, "quantity");
1006
+ }
1007
+ }
1008
+ continue;
1009
+ }
1010
+ if (provider === "anthropic") {
1011
+ if (!isRecord(page) || !Array.isArray(page.data)) {
1012
+ report("data", "Claude Code usage response omitted the canonical data array; completeness cannot be proven");
1013
+ continue;
1014
+ }
1015
+ for (const [rowIndex, rowValue] of page.data.entries()) {
1016
+ const rowPath = `data[${rowIndex}]`;
1017
+ if (!isRecord(rowValue)) {
1018
+ report(rowPath, "Claude Code usage response contained a non-object row");
1019
+ continue;
1020
+ }
1021
+ const core = isRecord(rowValue.core_metrics) ? rowValue.core_metrics : {};
1022
+ const lines = isRecord(core.lines_of_code) ? core.lines_of_code : {};
1023
+ checkInteger(core.num_sessions, `${rowPath}.core_metrics.num_sessions`, "quantity");
1024
+ checkInteger(lines.added, `${rowPath}.core_metrics.lines_of_code.added`, "quantity");
1025
+ checkInteger(lines.removed, `${rowPath}.core_metrics.lines_of_code.removed`, "quantity");
1026
+ checkInteger(core.commits_by_claude_code, `${rowPath}.core_metrics.commits_by_claude_code`, "quantity");
1027
+ checkInteger(core.pull_requests_by_claude_code, `${rowPath}.core_metrics.pull_requests_by_claude_code`, "quantity");
1028
+ const modelBreakdown = Array.isArray(rowValue.model_breakdown) ? rowValue.model_breakdown : [];
1029
+ for (const [modelIndex, modelValue] of modelBreakdown.entries()) {
1030
+ const modelPath = `${rowPath}.model_breakdown[${modelIndex}]`;
1031
+ if (!isRecord(modelValue)) {
1032
+ report(modelPath, "Claude Code usage response contained a non-object model row");
1033
+ continue;
1034
+ }
1035
+ const tokens = isRecord(modelValue.tokens) ? modelValue.tokens : {};
1036
+ for (const field of ["input", "output", "cache_read", "cache_creation"]) {
1037
+ checkInteger(tokens[field], `${modelPath}.tokens.${field}`, "token count");
1038
+ }
1039
+ }
1040
+ }
1041
+ continue;
1042
+ }
1043
+ if (!isRecord(page) || !Array.isArray(page.day_totals)) {
1044
+ report("day_totals", "Copilot metrics response omitted the canonical day_totals array; completeness cannot be proven");
1045
+ continue;
1046
+ }
1047
+ for (const [dayIndex, dayValue] of page.day_totals.entries()) {
1048
+ const dayPath = `day_totals[${dayIndex}]`;
1049
+ if (!isRecord(dayValue)) {
1050
+ report(dayPath, "Copilot metrics response contained a non-object day row");
1051
+ continue;
1052
+ }
1053
+ checkInteger(dayValue.daily_active_users, `${dayPath}.daily_active_users`, "quantity");
1054
+ const featureRows = Array.isArray(dayValue.totals_by_model_feature) ? dayValue.totals_by_model_feature : [];
1055
+ for (const [featureIndex, featureValue] of featureRows.entries()) {
1056
+ if (!isRecord(featureValue)) {
1057
+ report(`${dayPath}.totals_by_model_feature[${featureIndex}]`, "Copilot metrics response contained a non-object feature row");
1058
+ continue;
1059
+ }
1060
+ const featurePath = `${dayPath}.totals_by_model_feature[${featureIndex}]`;
1061
+ for (const field of ["engaged_users", "total_requests", "user_initiated_interaction_count"]) {
1062
+ checkInteger(featureValue[field], `${featurePath}.${field}`, "quantity");
1063
+ }
1064
+ }
1065
+ const cli = isRecord(dayValue.totals_by_cli) ? dayValue.totals_by_cli : undefined;
1066
+ if (!cli)
1067
+ continue;
1068
+ for (const field of ["request_count", "prompt_count", "session_count", "engaged_users", "total_requests"]) {
1069
+ checkInteger(cli[field], `${dayPath}.totals_by_cli.${field}`, "quantity");
1070
+ }
1071
+ const tokenUsage = isRecord(cli.token_usage) ? cli.token_usage : undefined;
1072
+ if (!tokenUsage)
1073
+ continue;
1074
+ checkInteger(tokenUsage.prompt_tokens_sum, `${dayPath}.totals_by_cli.token_usage.prompt_tokens_sum`, "token count");
1075
+ checkInteger(tokenUsage.output_tokens_sum, `${dayPath}.totals_by_cli.token_usage.output_tokens_sum`, "token count");
1076
+ }
1077
+ }
1078
+ if (issueCount === 0)
1079
+ return;
1080
+ if (issueCount > maxDetailedIssues) {
1081
+ issues.push({
1082
+ label,
1083
+ field: "usage rows",
1084
+ issue: `${issueCount - maxDetailedIssues} additional malformed usage schema issue(s) were omitted from QA details`
1085
+ });
1086
+ }
1087
+ fetchResult.coverageIncomplete = true;
1088
+ fetchResult.responseDrift.push(...issues);
1089
+ }
577
1090
  function detectResponseDrift(payload, provider, label) {
578
1091
  const known = knownProviderFields(provider, label);
579
1092
  const issues = [];
@@ -615,20 +1128,20 @@ function knownProviderFields(provider, label) {
615
1128
  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
1129
  }
617
1130
  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"]);
1131
+ 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
1132
  }
620
1133
  if (provider === "github-copilot" && label.toLowerCase().includes("seats")) {
621
- return new Set([...common, "total_seats", "plan_type", "seats", "seats[]", "seats[].created_at", "seats[].updated_at", "seats[].pending_cancellation_date", "seats[].last_activity_at", "seats[].last_activity_editor", "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"]);
1134
+ 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
1135
  }
623
1136
  if (provider === "cursor") {
624
- return new Set([...common, "users", "users[]", "users[].email", "users[].emailAddress", "users[].userId", "users[].id", "users[].name", "users[].role", "users[].spendCents", "users[].usageBasedCents", "users[].chargedCents", "users[].fastPremiumRequests", "users[].hardLimitOverrideDollars", "data[].email", "data[].emailAddress", "data[].userId", "data[].id", "data[].name", "data[].role", "data[].spendCents", "data[].usageBasedCents", "data[].chargedCents", "subscriptionCycleStart", "totalMembers", "totalPages"]);
1137
+ return new Set([...common, "teamMemberSpend", "teamMemberSpend[]", "teamMemberSpend[].userId", "teamMemberSpend[].email", "teamMemberSpend[].name", "teamMemberSpend[].role", "teamMemberSpend[].spendCents", "teamMemberSpend[].fastPremiumRequests", "teamMemberSpend[].hardLimitOverrideDollars", "subscriptionCycleStart", "totalMembers", "totalPages"]);
625
1138
  }
626
1139
  return new Set([...common]);
627
1140
  }
628
1141
  function qaSummary(provider, fetches) {
629
1142
  return {
630
1143
  provider,
631
- coverage: fetches.every((fetchResult) => fetchResult.pagination.stoppedBecause === "complete") ? "complete" : "partial",
1144
+ coverage: fetches.every((fetchResult) => fetchResult.pagination.stoppedBecause === "complete" && fetchResult.coverageIncomplete !== true) ? "complete" : "partial",
632
1145
  requestedEndpoints: Array.from(new Set(fetches.map((fetchResult) => fetchResult.pagination.label))),
633
1146
  pagination: fetches.map((fetchResult) => fetchResult.pagination),
634
1147
  rateLimits: fetches.flatMap((fetchResult) => fetchResult.rateLimits),
@@ -689,8 +1202,26 @@ function extractProviderMessage(payload) {
689
1202
  return stringValue(error?.message) ?? stringValue(payload.message) ?? "";
690
1203
  }
691
1204
  function sanitizeProviderMessage(message) {
692
- // One redaction implementation for the whole product (discovery.ts owns it).
693
- return redactSecrets(message).replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]");
1205
+ // Provider error bodies/status text are terminal-facing untrusted input.
1206
+ // Strip controls first so an escape sequence cannot split a secret pattern,
1207
+ // then apply the product-wide redaction rules.
1208
+ return redactSecrets(stripTerminalControlSequences(message))
1209
+ .replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]")
1210
+ .replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]")
1211
+ .trim();
1212
+ }
1213
+ function stripTerminalControlSequences(message) {
1214
+ return message
1215
+ // OSC (window title, hyperlinks, clipboard), terminated by BEL/ST or EOF.
1216
+ .replace(/(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/gu, "")
1217
+ // DCS/SOS/PM/APC string controls, terminated by ST or EOF.
1218
+ .replace(/(?:\u001b(?:P|X|\^|_)|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\u001b\\|\u009c|$)/gu, "")
1219
+ // CSI plus remaining two-character ESC sequences.
1220
+ .replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu, "")
1221
+ .replace(/\u001b[@-_]/gu, "")
1222
+ // Prevent line/status injection while keeping words readable.
1223
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
1224
+ .replace(/\s+/gu, " ");
694
1225
  }
695
1226
  export function summarizeProviderFinancials(records) {
696
1227
  const providerReportedBilledUsd = sumAmounts(records.filter((record) => record.costConfidence === "verified"));
@@ -732,7 +1263,7 @@ function sumAmounts(records) {
732
1263
  .filter((amount) => typeof amount === "number");
733
1264
  return amounts.length > 0 ? amounts.reduce((sum, amount) => sum + amount, 0) : null;
734
1265
  }
735
- function providerResult(provider, sourceId, authReference, records, qa) {
1266
+ function providerResult(provider, sourceId, authReference, records, qa, coverageInterval) {
736
1267
  const resolvedQa = qa ?? qaSummary(provider, []);
737
1268
  const coverage = resolvedQa.coverage
738
1269
  ?? (resolvedQa.pagination.every((pagination) => pagination.stoppedBecause === "complete") ? "complete" : "partial");
@@ -747,28 +1278,71 @@ function providerResult(provider, sourceId, authReference, records, qa) {
747
1278
  : headlineConfidence;
748
1279
  return {
749
1280
  provider,
750
- source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd ?? 0, completeness }),
1281
+ source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd, completeness }),
751
1282
  records,
752
1283
  fetchedAt: new Date().toISOString(),
753
1284
  coverage,
1285
+ ...(coverageInterval ? { coverageInterval } : {}),
754
1286
  financials,
755
1287
  completeness,
756
1288
  qa: resolvedQa
757
1289
  };
758
1290
  }
1291
+ function requestedCoverageInterval(input) {
1292
+ if (!Number.isFinite(input.startTime) || !Number.isInteger(input.startTime) || input.startTime < 0) {
1293
+ throw new Error("Provider coverage startTime requires a non-negative whole-second timestamp.");
1294
+ }
1295
+ const coverageStart = new Date(input.startTime * 1_000);
1296
+ if (Number.isNaN(coverageStart.getTime())) {
1297
+ throw new Error("Provider coverage interval falls outside the supported timestamp range.");
1298
+ }
1299
+ if (input.endTime === undefined) {
1300
+ if (coverageStart.getTime() > Date.now()) {
1301
+ throw new Error("Provider coverage startTime cannot be in the future.");
1302
+ }
1303
+ return undefined;
1304
+ }
1305
+ if (!Number.isFinite(input.endTime) || !Number.isInteger(input.endTime) ||
1306
+ input.endTime < input.startTime) {
1307
+ throw new Error("Provider coverage interval requires non-negative whole-second bounds with endTime at or after startTime.");
1308
+ }
1309
+ const coverageEnd = new Date(input.endTime * 1_000);
1310
+ if (Number.isNaN(coverageEnd.getTime())) {
1311
+ throw new Error("Provider coverage interval falls outside the supported timestamp range.");
1312
+ }
1313
+ if (coverageEnd.getTime() > Date.now()) {
1314
+ throw new Error("Provider coverage endTime cannot be in the future.");
1315
+ }
1316
+ return {
1317
+ coverageStart: coverageStart.toISOString(),
1318
+ coverageEnd: coverageEnd.toISOString()
1319
+ };
1320
+ }
759
1321
  export function createProviderConnection(input) {
760
1322
  const source = createProviderConnectorStub(input.provider, "provider_api", input.fetchedAt);
761
- const total = `$${input.totalUsd.toFixed(2)}`;
762
- const verification = input.completeness ?? "verified";
1323
+ const total = input.totalUsd === null ? "an unavailable financial headline" : formatProviderUsd(input.totalUsd);
1324
+ const financialEvidence = input.completeness ?? "verified";
763
1325
  return {
764
1326
  ...source,
765
1327
  id: input.sourceId ?? source.id,
766
- verification,
1328
+ validationCoverage: validationCoverageForCompletedProviderSync(input.provider),
1329
+ financialEvidence,
767
1330
  authReference: input.authReference,
768
1331
  fieldsMissing: [],
769
- scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} ${verification} records totaling ${total}.`
1332
+ scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} record(s); financial evidence: ${financialEvidence}; financial headline: ${total}.`
770
1333
  };
771
1334
  }
1335
+ function validationCoverageForCompletedProviderSync(provider) {
1336
+ if (provider === "openai" || provider === "anthropic")
1337
+ return "live_verified";
1338
+ if (provider === "cursor" || provider === "github-copilot" || provider === "copilot") {
1339
+ return "fixture_verified";
1340
+ }
1341
+ return "untested";
1342
+ }
1343
+ function formatProviderUsd(value) {
1344
+ return value > 0 && value < 0.01 ? "less than $0.01" : `$${value.toFixed(2)}`;
1345
+ }
772
1346
  export function resolveTokenReference(reference, env = process.env) {
773
1347
  if (!reference.startsWith("env:")) {
774
1348
  throw new Error("Provider auth reference must be a local reference such as env:OPENAI_ADMIN_KEY; raw secrets are not accepted.");
@@ -791,7 +1365,7 @@ function buildOpenAiCostsUrl(startTime, endTime) {
791
1365
  url.searchParams.append("group_by", "project_id");
792
1366
  url.searchParams.append("group_by", "line_item");
793
1367
  url.searchParams.append("group_by", "api_key_id");
794
- if (endTime)
1368
+ if (endTime !== undefined)
795
1369
  url.searchParams.set("end_time", String(endTime));
796
1370
  return url.toString();
797
1371
  }
@@ -804,14 +1378,14 @@ function buildOpenAiUsageUrl(startTime, endTime) {
804
1378
  url.searchParams.append("group_by", "user_id");
805
1379
  url.searchParams.append("group_by", "api_key_id");
806
1380
  url.searchParams.append("group_by", "model");
807
- if (endTime)
1381
+ if (endTime !== undefined)
808
1382
  url.searchParams.set("end_time", String(endTime));
809
1383
  return url.toString();
810
1384
  }
811
1385
  function buildAnthropicCostUrl(startTime, endTime) {
812
1386
  const url = new URL("https://api.anthropic.com/v1/organizations/cost_report");
813
1387
  url.searchParams.set("starting_at", new Date(startTime * 1000).toISOString());
814
- if (endTime)
1388
+ if (endTime !== undefined)
815
1389
  url.searchParams.set("ending_at", new Date(endTime * 1000).toISOString());
816
1390
  url.searchParams.set("bucket_width", "1d");
817
1391
  url.searchParams.append("group_by[]", "workspace_id");
@@ -843,20 +1417,45 @@ async function defaultFetcher(url, init) {
843
1417
  return fetch(url, { ...init, redirect: "manual" });
844
1418
  }
845
1419
  function parseMinorUsd(value) {
846
- const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
847
- return Number.isFinite(numeric) ? numeric / 100 : undefined;
1420
+ const numeric = typeof value === "number"
1421
+ ? value
1422
+ : typeof value === "string" && value.trim().length > 0
1423
+ ? Number(value)
1424
+ : Number.NaN;
1425
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric / 100 : undefined;
848
1426
  }
849
1427
  /** Amount already denominated in dollars, as number or decimal string. */
850
1428
  function parseDollarUsd(value) {
851
- const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
852
- return Number.isFinite(numeric) ? numeric : undefined;
1429
+ const numeric = typeof value === "number"
1430
+ ? value
1431
+ : typeof value === "string" && value.trim().length > 0
1432
+ ? Number(value)
1433
+ : Number.NaN;
1434
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : undefined;
853
1435
  }
854
1436
  function numberValue(value) {
855
1437
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
856
1438
  }
1439
+ function nonNegativeNumberValue(value) {
1440
+ const numeric = numberValue(value);
1441
+ return typeof numeric === "number" && numeric >= 0 ? numeric : undefined;
1442
+ }
1443
+ function nonNegativeIntegerValue(value) {
1444
+ const numeric = numberValue(value);
1445
+ return typeof numeric === "number" && Number.isInteger(numeric) && numeric >= 0 ? numeric : undefined;
1446
+ }
857
1447
  function stringValue(value) {
858
1448
  return typeof value === "string" && value.length > 0 ? value : undefined;
859
1449
  }
1450
+ function validEpochSeconds(value) {
1451
+ const seconds = numberValue(value);
1452
+ return typeof seconds === "number" && seconds >= 0 && Number.isFinite(new Date(seconds * 1000).getTime())
1453
+ ? seconds
1454
+ : undefined;
1455
+ }
1456
+ function validDateTimeString(value) {
1457
+ return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value)) ? value : undefined;
1458
+ }
860
1459
  function extractArray(value, key) {
861
1460
  if (Array.isArray(value))
862
1461
  return value;
@@ -864,6 +1463,20 @@ function extractArray(value, key) {
864
1463
  return value[key];
865
1464
  return [];
866
1465
  }
1466
+ function requireStringArray(value, key, label) {
1467
+ if (!isRecord(value) || !Array.isArray(value[key]) || value[key].length === 0) {
1468
+ throw new Error(`${label} returned no signed NDJSON ${key}; refusing to report an empty metrics sync.`);
1469
+ }
1470
+ const values = value[key];
1471
+ if (values.some((item) => typeof item !== "string" || item.length === 0)) {
1472
+ throw new Error(`${label} returned a malformed ${key} entry; refusing a partial metrics sync.`);
1473
+ }
1474
+ const strings = values;
1475
+ if (new Set(strings).size !== strings.length) {
1476
+ throw new Error(`${label} returned duplicate ${key}; refusing to double-count a report partition.`);
1477
+ }
1478
+ return strings;
1479
+ }
867
1480
  function isObject(value) {
868
1481
  return typeof value === "object" && value !== null;
869
1482
  }