@agent-finops/core 0.5.9 → 0.6.0

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