@agent-finops/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyze.d.ts +6 -0
- package/dist/analyze.js +260 -0
- package/dist/attribution.d.ts +4 -0
- package/dist/attribution.js +117 -0
- package/dist/credentialDetection.d.ts +45 -0
- package/dist/credentialDetection.js +133 -0
- package/dist/cutList.d.ts +29 -0
- package/dist/cutList.js +201 -0
- package/dist/discovery.d.ts +19 -0
- package/dist/discovery.js +176 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/insights.d.ts +3 -0
- package/dist/insights.js +183 -0
- package/dist/localAgentLogs.d.ts +53 -0
- package/dist/localAgentLogs.js +243 -0
- package/dist/modelPricing.d.ts +35 -0
- package/dist/modelPricing.js +45 -0
- package/dist/planMath.d.ts +42 -0
- package/dist/planMath.js +58 -0
- package/dist/providerConnectors.d.ts +88 -0
- package/dist/providerConnectors.js +683 -0
- package/dist/sampleData.d.ts +5 -0
- package/dist/sampleData.js +59 -0
- package/dist/schema.d.ts +431 -0
- package/dist/schema.js +158 -0
- package/dist/sourceRegistry.d.ts +109 -0
- package/dist/sourceRegistry.js +380 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +27 -0
- package/samples/anthropic-usage.csv +4 -0
- package/samples/openai-usage.csv +7 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { createProviderConnectorStub, slugifySourceId } from "./sourceRegistry.js";
|
|
2
|
+
export function normalizeOpenAiCostResponse(response, options) {
|
|
3
|
+
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
4
|
+
const records = [];
|
|
5
|
+
for (const bucket of data) {
|
|
6
|
+
const startTime = typeof bucket.start_time === "number" ? bucket.start_time : 0;
|
|
7
|
+
const timestamp = new Date(startTime * 1000).toISOString();
|
|
8
|
+
for (const result of bucket.results ?? []) {
|
|
9
|
+
// The live API returns amount.value as a decimal STRING (dollars);
|
|
10
|
+
// accept both string and number.
|
|
11
|
+
const amountUsd = result.amount?.currency?.toLowerCase() === "usd" || !result.amount?.currency
|
|
12
|
+
? parseDollarUsd(result.amount?.value)
|
|
13
|
+
: undefined;
|
|
14
|
+
if (typeof amountUsd !== "number")
|
|
15
|
+
continue;
|
|
16
|
+
const lineItem = result.line_item ?? "OpenAI organization costs";
|
|
17
|
+
const projectId = result.project_id ?? undefined;
|
|
18
|
+
const apiKeyId = result.api_key_id ?? undefined;
|
|
19
|
+
records.push({
|
|
20
|
+
id: slugifySourceId(["openai-costs", String(startTime), projectId, apiKeyId, lineItem].filter(Boolean).join("-")),
|
|
21
|
+
timestamp,
|
|
22
|
+
source: {
|
|
23
|
+
id: options.sourceId,
|
|
24
|
+
name: "OpenAI organization costs API",
|
|
25
|
+
provider: "openai",
|
|
26
|
+
confidence: "verified",
|
|
27
|
+
observedFrom: options.observedFrom
|
|
28
|
+
},
|
|
29
|
+
model: lineItem,
|
|
30
|
+
inputTokens: 0,
|
|
31
|
+
outputTokens: 0,
|
|
32
|
+
amountUsd,
|
|
33
|
+
costConfidence: "verified",
|
|
34
|
+
projectId,
|
|
35
|
+
apiKeyId,
|
|
36
|
+
providerCostType: "openai_cost",
|
|
37
|
+
quantity: typeof result.quantity === "number" ? result.quantity : undefined,
|
|
38
|
+
operation: lineItem
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return records;
|
|
43
|
+
}
|
|
44
|
+
export function normalizeOpenAiUsageResponse(response, options) {
|
|
45
|
+
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
46
|
+
const records = [];
|
|
47
|
+
for (const bucket of data) {
|
|
48
|
+
const startTime = typeof bucket.start_time === "number" ? bucket.start_time : 0;
|
|
49
|
+
const timestamp = new Date(startTime * 1000).toISOString();
|
|
50
|
+
for (const result of bucket.results ?? []) {
|
|
51
|
+
const projectId = result.project_id ?? undefined;
|
|
52
|
+
const userId = result.user_id ?? undefined;
|
|
53
|
+
const apiKeyId = result.api_key_id ?? undefined;
|
|
54
|
+
const model = result.model ?? "openai-usage";
|
|
55
|
+
const inputTokens = numberValue(result.input_tokens) ?? 0;
|
|
56
|
+
const outputTokens = numberValue(result.output_tokens) ?? 0;
|
|
57
|
+
const cachedTokens = numberValue(result.input_cached_tokens) ?? 0;
|
|
58
|
+
const audioInputTokens = numberValue(result.input_audio_tokens) ?? 0;
|
|
59
|
+
const audioOutputTokens = numberValue(result.output_audio_tokens) ?? 0;
|
|
60
|
+
const requestCount = numberValue(result.num_model_requests);
|
|
61
|
+
if (inputTokens + outputTokens + audioInputTokens + audioOutputTokens === 0 && typeof requestCount !== "number")
|
|
62
|
+
continue;
|
|
63
|
+
records.push({
|
|
64
|
+
id: slugifySourceId(["openai-usage", String(startTime), projectId, userId, apiKeyId, model].filter(Boolean).join("-")),
|
|
65
|
+
timestamp,
|
|
66
|
+
source: { id: options.sourceId, name: "OpenAI organization usage API", provider: "openai", confidence: "verified", observedFrom: options.observedFrom },
|
|
67
|
+
model,
|
|
68
|
+
inputTokens: inputTokens + audioInputTokens,
|
|
69
|
+
outputTokens: outputTokens + audioOutputTokens,
|
|
70
|
+
amountUsd: null,
|
|
71
|
+
costConfidence: "missing",
|
|
72
|
+
projectId,
|
|
73
|
+
userId,
|
|
74
|
+
apiKeyId,
|
|
75
|
+
providerCostType: "openai_usage_evidence",
|
|
76
|
+
quantity: numberValue(result.num_model_requests),
|
|
77
|
+
operation: "OpenAI completions usage evidence"
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return records;
|
|
82
|
+
}
|
|
83
|
+
export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
|
|
84
|
+
const rows = extractArray(response, "data");
|
|
85
|
+
const records = [];
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
if (!isRecord(row))
|
|
88
|
+
continue;
|
|
89
|
+
const date = stringValue(row.date) ?? new Date(0).toISOString().slice(0, 10);
|
|
90
|
+
const actor = isRecord(row.actor) ? row.actor : {};
|
|
91
|
+
const userId = stringValue(actor.email_address) ?? stringValue(actor.api_key_name) ?? stringValue(actor.id) ?? "unknown-claude-code-actor";
|
|
92
|
+
const core = isRecord(row.core_metrics) ? row.core_metrics : {};
|
|
93
|
+
const lines = isRecord(core.lines_of_code) ? core.lines_of_code : {};
|
|
94
|
+
const sessions = numberValue(core.num_sessions) ?? 0;
|
|
95
|
+
const added = numberValue(lines.added) ?? 0;
|
|
96
|
+
const removed = numberValue(lines.removed) ?? 0;
|
|
97
|
+
const commits = numberValue(core.commits_by_claude_code) ?? 0;
|
|
98
|
+
const prs = numberValue(core.pull_requests_by_claude_code) ?? 0;
|
|
99
|
+
const organizationId = stringValue(row.organization_id) ?? options.accountId;
|
|
100
|
+
const modelBreakdown = Array.isArray(row.model_breakdown) ? row.model_breakdown : [];
|
|
101
|
+
for (const item of modelBreakdown) {
|
|
102
|
+
if (!isRecord(item))
|
|
103
|
+
continue;
|
|
104
|
+
const model = stringValue(item.model) ?? "claude-code";
|
|
105
|
+
const tokens = isRecord(item.tokens) ? item.tokens : {};
|
|
106
|
+
const cost = isRecord(item.estimated_cost) ? item.estimated_cost : {};
|
|
107
|
+
const currency = stringValue(cost.currency)?.toLowerCase() ?? "usd";
|
|
108
|
+
const amountUsd = currency === "usd" ? parseMinorUsd(cost.amount) : undefined;
|
|
109
|
+
if (typeof amountUsd !== "number")
|
|
110
|
+
continue;
|
|
111
|
+
records.push({
|
|
112
|
+
id: slugifySourceId(["anthropic-claude-code", date, userId, model].filter(Boolean).join("-")),
|
|
113
|
+
timestamp: new Date(`${date}T00:00:00Z`).toISOString(),
|
|
114
|
+
source: { id: options.sourceId, name: "Anthropic Claude Code Usage Report", provider: "anthropic", confidence: "estimated", observedFrom: options.observedFrom },
|
|
115
|
+
model,
|
|
116
|
+
inputTokens: (numberValue(tokens.input) ?? 0) + (numberValue(tokens.cache_read) ?? 0) + (numberValue(tokens.cache_creation) ?? 0),
|
|
117
|
+
outputTokens: numberValue(tokens.output) ?? 0,
|
|
118
|
+
amountUsd,
|
|
119
|
+
costConfidence: "estimated",
|
|
120
|
+
userId,
|
|
121
|
+
projectId: organizationId,
|
|
122
|
+
providerCostType: "anthropic_claude_code_usage",
|
|
123
|
+
quantity: sessions,
|
|
124
|
+
operation: `Claude Code sessions: ${sessions}; LOC +${added}/-${removed}; commits ${commits}; PRs ${prs}`
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return records;
|
|
129
|
+
}
|
|
130
|
+
export function normalizeGitHubCopilotSeatResponse(response, options) {
|
|
131
|
+
const seats = extractArray(response, "seats");
|
|
132
|
+
const plan = stringValue(isRecord(response) ? response.plan_type : undefined) ?? "business";
|
|
133
|
+
const seatUsd = plan === "enterprise" ? 39 : 19;
|
|
134
|
+
const timestamp = new Date().toISOString();
|
|
135
|
+
return seats.flatMap((seat) => {
|
|
136
|
+
if (!isRecord(seat))
|
|
137
|
+
return [];
|
|
138
|
+
const assignee = isRecord(seat.assignee) ? seat.assignee : {};
|
|
139
|
+
const userId = stringValue(assignee.login) ?? stringValue(assignee.email) ?? stringValue(seat.login) ?? stringValue(seat.id);
|
|
140
|
+
if (!userId)
|
|
141
|
+
return [];
|
|
142
|
+
const lastActivity = stringValue(seat.last_activity_at);
|
|
143
|
+
return [{
|
|
144
|
+
id: slugifySourceId(["github-copilot-seat", options.accountId, userId, plan].filter(Boolean).join("-")),
|
|
145
|
+
timestamp,
|
|
146
|
+
source: { id: options.sourceId, name: "GitHub Copilot billing seats API", provider: "github-copilot", confidence: "estimated", observedFrom: options.observedFrom },
|
|
147
|
+
model: `github-copilot-${plan}-seat`,
|
|
148
|
+
inputTokens: 0,
|
|
149
|
+
outputTokens: 0,
|
|
150
|
+
amountUsd: seatUsd,
|
|
151
|
+
costConfidence: "estimated",
|
|
152
|
+
userId,
|
|
153
|
+
projectId: options.accountId,
|
|
154
|
+
providerCostType: "copilot_seat_reconciliation",
|
|
155
|
+
quantity: 1,
|
|
156
|
+
operation: `GitHub Copilot ${plan} seat; ${lastActivity ? `last activity ${lastActivity}` : "no recent activity reported"}`
|
|
157
|
+
}];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export function normalizeAnthropicCostResponse(response, options) {
|
|
161
|
+
const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
|
|
162
|
+
const records = [];
|
|
163
|
+
for (const bucket of data) {
|
|
164
|
+
const timestamp = bucket.starting_at ?? new Date(0).toISOString();
|
|
165
|
+
for (const result of bucket.results ?? []) {
|
|
166
|
+
const currency = result.currency?.toLowerCase() ?? "usd";
|
|
167
|
+
if (currency !== "usd")
|
|
168
|
+
continue;
|
|
169
|
+
const amountUsd = parseMinorUsd(result.amount);
|
|
170
|
+
if (typeof amountUsd !== "number")
|
|
171
|
+
continue;
|
|
172
|
+
const description = result.description ?? result.cost_type ?? "Anthropic organization costs";
|
|
173
|
+
const model = result.model ?? description;
|
|
174
|
+
const workspaceId = result.workspace_id ?? undefined;
|
|
175
|
+
records.push({
|
|
176
|
+
id: slugifySourceId(["anthropic-costs", timestamp, workspaceId, model, result.token_type ?? result.cost_type].filter(Boolean).join("-")),
|
|
177
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
178
|
+
source: {
|
|
179
|
+
id: options.sourceId,
|
|
180
|
+
name: "Anthropic Admin Cost Report",
|
|
181
|
+
provider: "anthropic",
|
|
182
|
+
confidence: "verified",
|
|
183
|
+
observedFrom: options.observedFrom
|
|
184
|
+
},
|
|
185
|
+
model,
|
|
186
|
+
inputTokens: 0,
|
|
187
|
+
outputTokens: 0,
|
|
188
|
+
amountUsd,
|
|
189
|
+
costConfidence: "verified",
|
|
190
|
+
projectId: workspaceId,
|
|
191
|
+
workspaceId,
|
|
192
|
+
providerCostType: result.cost_type ?? "anthropic_cost",
|
|
193
|
+
operation: description
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return records;
|
|
198
|
+
}
|
|
199
|
+
export function normalizeGitHubCopilotMetricsResponse(response, options) {
|
|
200
|
+
const records = [];
|
|
201
|
+
const dayTotals = extractArray(response, "day_totals");
|
|
202
|
+
for (const day of dayTotals) {
|
|
203
|
+
if (!isRecord(day))
|
|
204
|
+
continue;
|
|
205
|
+
const dayString = stringValue(day.day) ?? new Date(0).toISOString().slice(0, 10);
|
|
206
|
+
const timestamp = new Date(`${dayString}T00:00:00Z`).toISOString();
|
|
207
|
+
const modelFeatureRows = Array.isArray(day.totals_by_model_feature) ? day.totals_by_model_feature : [];
|
|
208
|
+
for (const row of modelFeatureRows) {
|
|
209
|
+
if (!isRecord(row))
|
|
210
|
+
continue;
|
|
211
|
+
const model = stringValue(row.model) ?? "github-copilot";
|
|
212
|
+
const feature = stringValue(row.feature) ?? "copilot usage";
|
|
213
|
+
records.push({
|
|
214
|
+
id: slugifySourceId(["github-copilot", dayString, options.accountId, model, feature].filter(Boolean).join("-")),
|
|
215
|
+
timestamp,
|
|
216
|
+
source: { id: options.sourceId, name: "GitHub Copilot metrics API", provider: "github-copilot", confidence: "verified", observedFrom: options.observedFrom },
|
|
217
|
+
model,
|
|
218
|
+
inputTokens: 0,
|
|
219
|
+
outputTokens: 0,
|
|
220
|
+
amountUsd: null,
|
|
221
|
+
costConfidence: "missing",
|
|
222
|
+
projectId: options.accountId,
|
|
223
|
+
providerCostType: "copilot_usage_metrics",
|
|
224
|
+
operation: feature
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const cli = isRecord(day.totals_by_cli) ? day.totals_by_cli : undefined;
|
|
228
|
+
const tokenUsage = cli && isRecord(cli.token_usage) ? cli.token_usage : undefined;
|
|
229
|
+
if (cli) {
|
|
230
|
+
records.push({
|
|
231
|
+
id: slugifySourceId(["github-copilot-cli", dayString, options.accountId].filter(Boolean).join("-")),
|
|
232
|
+
timestamp,
|
|
233
|
+
source: { id: options.sourceId, name: "GitHub Copilot metrics API", provider: "github-copilot", confidence: "verified", observedFrom: options.observedFrom },
|
|
234
|
+
model: "github-copilot-cli",
|
|
235
|
+
inputTokens: numberValue(tokenUsage?.prompt_tokens_sum) ?? 0,
|
|
236
|
+
outputTokens: numberValue(tokenUsage?.output_tokens_sum) ?? 0,
|
|
237
|
+
amountUsd: null,
|
|
238
|
+
costConfidence: "missing",
|
|
239
|
+
projectId: options.accountId,
|
|
240
|
+
providerCostType: "copilot_cli_metrics",
|
|
241
|
+
operation: "CLI requests"
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return records;
|
|
246
|
+
}
|
|
247
|
+
export function normalizeCursorSpendResponse(response, options) {
|
|
248
|
+
const users = extractArray(response, "users").length > 0 ? extractArray(response, "users") : extractArray(response, "data");
|
|
249
|
+
const timestamp = new Date().toISOString();
|
|
250
|
+
return users.flatMap((user) => {
|
|
251
|
+
if (!isRecord(user))
|
|
252
|
+
return [];
|
|
253
|
+
const userId = stringValue(user.email) ?? stringValue(user.emailAddress) ?? stringValue(user.userId) ?? stringValue(user.id);
|
|
254
|
+
const cents = numberValue(user.spendCents) ?? numberValue(user.usageBasedCents) ?? numberValue(user.chargedCents);
|
|
255
|
+
if (!userId || typeof cents !== "number")
|
|
256
|
+
return [];
|
|
257
|
+
return [{
|
|
258
|
+
id: slugifySourceId(["cursor-spend", options.accountId, userId].filter(Boolean).join("-")),
|
|
259
|
+
timestamp,
|
|
260
|
+
source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence: "verified", observedFrom: options.observedFrom },
|
|
261
|
+
model: "cursor-team-usage",
|
|
262
|
+
inputTokens: 0,
|
|
263
|
+
outputTokens: 0,
|
|
264
|
+
amountUsd: cents / 100,
|
|
265
|
+
costConfidence: "verified",
|
|
266
|
+
userId,
|
|
267
|
+
projectId: options.accountId,
|
|
268
|
+
providerCostType: "cursor_spend",
|
|
269
|
+
operation: "Cursor team spend"
|
|
270
|
+
}];
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
export async function fetchProviderUsageRecords(input) {
|
|
274
|
+
const token = (input.tokenResolver ?? defaultTokenResolver)(input.authReference);
|
|
275
|
+
const fetcher = input.fetcher ?? defaultFetcher;
|
|
276
|
+
const sourceId = input.sourceId ?? `${input.provider}-provider-api`;
|
|
277
|
+
if (input.provider === "openai") {
|
|
278
|
+
return fetchOpenAi(input, token, fetcher, sourceId);
|
|
279
|
+
}
|
|
280
|
+
if (input.provider === "anthropic") {
|
|
281
|
+
return fetchAnthropic(input, token, fetcher, sourceId);
|
|
282
|
+
}
|
|
283
|
+
if (input.provider === "github-copilot") {
|
|
284
|
+
return fetchGitHubCopilot(input, token, fetcher, sourceId);
|
|
285
|
+
}
|
|
286
|
+
if (input.provider === "cursor") {
|
|
287
|
+
return fetchCursor(input, token, fetcher, sourceId);
|
|
288
|
+
}
|
|
289
|
+
throw new Error(`Provider connector not implemented yet: ${input.provider}`);
|
|
290
|
+
}
|
|
291
|
+
async function fetchOpenAi(input, token, fetcher, sourceId) {
|
|
292
|
+
const request = {
|
|
293
|
+
method: "GET",
|
|
294
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
|
295
|
+
};
|
|
296
|
+
const costFetch = await fetchPaginatedJson(fetcher, buildOpenAiCostsUrl(input.startTime, input.endTime), request, "openai", "OpenAI costs API");
|
|
297
|
+
const usageFetch = await fetchPaginatedJson(fetcher, buildOpenAiUsageUrl(input.startTime, input.endTime), request, "openai", "OpenAI usage API");
|
|
298
|
+
const records = [
|
|
299
|
+
...costFetch.pages.flatMap((page) => normalizeOpenAiCostResponse(page, { sourceId, observedFrom: "OpenAI organization costs API" })),
|
|
300
|
+
...usageFetch.pages.flatMap((page) => normalizeOpenAiUsageResponse(page, { sourceId, observedFrom: "OpenAI organization usage API" }))
|
|
301
|
+
];
|
|
302
|
+
return providerResult("openai", sourceId, input.authReference, records, "verified", qaSummary("openai", [costFetch, usageFetch]));
|
|
303
|
+
}
|
|
304
|
+
async function fetchAnthropic(input, token, fetcher, sourceId) {
|
|
305
|
+
const costRequest = {
|
|
306
|
+
method: "GET",
|
|
307
|
+
headers: { "x-api-key": token, "anthropic-version": "2023-06-01", "Content-Type": "application/json" }
|
|
308
|
+
};
|
|
309
|
+
const costFetch = await fetchPaginatedJson(fetcher, buildAnthropicCostUrl(input.startTime, input.endTime), costRequest, "anthropic", "Anthropic Admin cost report");
|
|
310
|
+
const claudeCodeFetches = await fetchDateRangeJson(fetcher, buildAnthropicClaudeCodeUrl, input.startTime, input.endTime, costRequest, "anthropic", "Anthropic Claude Code usage report");
|
|
311
|
+
const records = [
|
|
312
|
+
...costFetch.pages.flatMap((page) => normalizeAnthropicCostResponse(page, { sourceId, observedFrom: "Anthropic Admin Cost Report" })),
|
|
313
|
+
...claudeCodeFetches.flatMap((fetchResult) => fetchResult.pages.flatMap((page) => normalizeAnthropicClaudeCodeUsageResponse(page, { sourceId, observedFrom: "Anthropic Claude Code Usage Report", accountId: input.accountId })))
|
|
314
|
+
];
|
|
315
|
+
return providerResult("anthropic", sourceId, input.authReference, records, "verified", qaSummary("anthropic", [costFetch, ...claudeCodeFetches]));
|
|
316
|
+
}
|
|
317
|
+
async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
|
|
318
|
+
const accountId = input.org ?? input.enterprise;
|
|
319
|
+
if (!accountId)
|
|
320
|
+
throw new Error("GitHub Copilot connector requires --org or --enterprise.");
|
|
321
|
+
const request = {
|
|
322
|
+
method: "GET",
|
|
323
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }
|
|
324
|
+
};
|
|
325
|
+
const metricsFetch = await fetchPaginatedJson(fetcher, buildGitHubCopilotMetricsUrl(input), request, "github-copilot", "GitHub Copilot metrics");
|
|
326
|
+
const seatFetch = input.org ? await fetchPaginatedJson(fetcher, buildGitHubCopilotSeatsUrl(input.org), request, "github-copilot", "GitHub Copilot seats") : undefined;
|
|
327
|
+
const metricsRecords = metricsFetch.pages.flatMap((page) => normalizeGitHubCopilotMetricsResponse(page, { sourceId, observedFrom: "GitHub Copilot metrics API", accountId }));
|
|
328
|
+
const seatRecords = seatFetch ? seatFetch.pages.flatMap((page) => normalizeGitHubCopilotSeatResponse(page, { sourceId, observedFrom: "GitHub Copilot billing seats API", accountId })) : [];
|
|
329
|
+
return providerResult("github-copilot", sourceId, input.authReference, [...metricsRecords, ...seatRecords], "verified", qaSummary("github-copilot", [metricsFetch, ...(seatFetch ? [seatFetch] : [])]));
|
|
330
|
+
}
|
|
331
|
+
async function fetchCursor(input, token, fetcher, sourceId) {
|
|
332
|
+
const accountId = input.accountId ?? input.org ?? "cursor-team";
|
|
333
|
+
const response = await fetchJsonOrThrow(fetcher, "https://api.cursor.com/teams/spend", {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { Authorization: `Basic ${btoaCompat(`${token}:`)}`, "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify({})
|
|
337
|
+
}, "cursor", "Cursor Admin API spend");
|
|
338
|
+
const page = response.payload;
|
|
339
|
+
const records = normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId });
|
|
340
|
+
const singleFetch = {
|
|
341
|
+
pages: [page],
|
|
342
|
+
pagination: { label: "Cursor Admin API spend", pagesFetched: 1, stoppedBecause: "complete", maxPages: 1 },
|
|
343
|
+
rateLimits: response.rateLimit ? [response.rateLimit] : [],
|
|
344
|
+
responseDrift: detectResponseDrift(page, "cursor", "Cursor Admin API spend")
|
|
345
|
+
};
|
|
346
|
+
return providerResult("cursor", sourceId, input.authReference, records, "verified", qaSummary("cursor", [singleFetch]));
|
|
347
|
+
}
|
|
348
|
+
async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label) {
|
|
349
|
+
const pages = [];
|
|
350
|
+
const rateLimits = [];
|
|
351
|
+
const responseDrift = [];
|
|
352
|
+
let nextUrl = initialUrl;
|
|
353
|
+
let stoppedBecause = "complete";
|
|
354
|
+
const maxPages = 50;
|
|
355
|
+
for (let pageCount = 0; nextUrl && pageCount < maxPages; pageCount += 1) {
|
|
356
|
+
const response = await fetchJsonOrThrow(fetcher, nextUrl, request, provider, label);
|
|
357
|
+
const page = response.payload;
|
|
358
|
+
pages.push(page);
|
|
359
|
+
if (response.rateLimit)
|
|
360
|
+
rateLimits.push(response.rateLimit);
|
|
361
|
+
responseDrift.push(...detectResponseDrift(page, provider, label));
|
|
362
|
+
const nextPage = nextPageFromPayload(page);
|
|
363
|
+
const nextLink = nextUrlFromHeaders(response.headers);
|
|
364
|
+
const hasMore = isRecord(page) && (page.has_more === true || page.hasMore === true || Boolean(nextPage) || Boolean(nextLink));
|
|
365
|
+
if (nextLink) {
|
|
366
|
+
nextUrl = nextLink;
|
|
367
|
+
}
|
|
368
|
+
else if (hasMore && nextPage) {
|
|
369
|
+
nextUrl = appendPageCursor(initialUrl, nextPage);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
if (hasMore && !nextPage) {
|
|
373
|
+
stoppedBecause = "missing_cursor";
|
|
374
|
+
responseDrift.push({ label, field: "next_page", issue: "pagination indicated more pages but no next cursor was returned" });
|
|
375
|
+
}
|
|
376
|
+
nextUrl = undefined;
|
|
377
|
+
}
|
|
378
|
+
if (pageCount === maxPages - 1 && nextUrl)
|
|
379
|
+
stoppedBecause = "max_pages";
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
pages,
|
|
383
|
+
pagination: { label, pagesFetched: pages.length, stoppedBecause, maxPages, limitPerPage: limitPerPageFromUrl(initialUrl) },
|
|
384
|
+
rateLimits,
|
|
385
|
+
responseDrift
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function fetchDateRangeJson(fetcher, buildUrl, startTime, endTime, request, provider, label) {
|
|
389
|
+
const results = [];
|
|
390
|
+
const daySeconds = 24 * 60 * 60;
|
|
391
|
+
const finalTime = endTime ?? startTime;
|
|
392
|
+
for (let cursor = startTime, count = 0; cursor <= finalTime && count < 370; cursor += daySeconds, count += 1) {
|
|
393
|
+
results.push(await fetchPaginatedJson(fetcher, buildUrl(cursor), request, provider, label));
|
|
394
|
+
}
|
|
395
|
+
return results;
|
|
396
|
+
}
|
|
397
|
+
async function fetchJsonOrThrow(fetcher, url, request, provider, label) {
|
|
398
|
+
const response = await fetcher(url, request);
|
|
399
|
+
const payload = await response.json().catch(() => undefined);
|
|
400
|
+
if (!response.ok) {
|
|
401
|
+
throw new Error(providerPermissionPrompt(provider, label, response, payload));
|
|
402
|
+
}
|
|
403
|
+
return { payload, rateLimit: rateLimitFromHeaders(label, response.headers), headers: response.headers };
|
|
404
|
+
}
|
|
405
|
+
function nextPageFromPayload(payload) {
|
|
406
|
+
if (!isRecord(payload))
|
|
407
|
+
return undefined;
|
|
408
|
+
const bodyLink = isRecord(payload.links) ? stringValue(payload.links.next) : undefined;
|
|
409
|
+
return stringValue(payload.next_page) ?? stringValue(payload.nextPage) ?? stringValue(payload.next) ?? bodyLink;
|
|
410
|
+
}
|
|
411
|
+
function nextUrlFromHeaders(headers) {
|
|
412
|
+
const link = headerString(headers, "link");
|
|
413
|
+
if (!link)
|
|
414
|
+
return undefined;
|
|
415
|
+
for (const part of link.split(",")) {
|
|
416
|
+
const match = part.match(/<([^>]+)>\s*;\s*rel="next"/i);
|
|
417
|
+
if (match?.[1])
|
|
418
|
+
return match[1];
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
function appendPageCursor(initialUrl, cursor) {
|
|
423
|
+
const url = new URL(initialUrl);
|
|
424
|
+
url.searchParams.set("page", cursor);
|
|
425
|
+
return url.toString();
|
|
426
|
+
}
|
|
427
|
+
function limitPerPageFromUrl(rawUrl) {
|
|
428
|
+
const params = new URL(rawUrl).searchParams;
|
|
429
|
+
const rawLimit = params.get("limit") ?? params.get("per_page");
|
|
430
|
+
if (!rawLimit)
|
|
431
|
+
return undefined;
|
|
432
|
+
const limit = Number(rawLimit);
|
|
433
|
+
return Number.isFinite(limit) ? limit : undefined;
|
|
434
|
+
}
|
|
435
|
+
function rateLimitFromHeaders(label, headers) {
|
|
436
|
+
const remaining = headerNumber(headers, "x-ratelimit-remaining-requests") ?? headerNumber(headers, "x-ratelimit-remaining");
|
|
437
|
+
const retryAfter = headerNumber(headers, "retry-after");
|
|
438
|
+
if (typeof remaining !== "number" && typeof retryAfter !== "number")
|
|
439
|
+
return undefined;
|
|
440
|
+
return { label, remainingRequests: remaining, retryAfterSeconds: retryAfter };
|
|
441
|
+
}
|
|
442
|
+
function headerString(headers, name) {
|
|
443
|
+
if (!headers)
|
|
444
|
+
return undefined;
|
|
445
|
+
const value = hasHeaderGetter(headers) ? headers.get(name) : headers[name] ?? headers[name.toLowerCase()];
|
|
446
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
447
|
+
}
|
|
448
|
+
function headerNumber(headers, name) {
|
|
449
|
+
if (!headers)
|
|
450
|
+
return undefined;
|
|
451
|
+
const value = hasHeaderGetter(headers) ? headers.get(name) : headers[name] ?? headers[name.toLowerCase()];
|
|
452
|
+
const numeric = Number(value);
|
|
453
|
+
return Number.isFinite(numeric) ? numeric : undefined;
|
|
454
|
+
}
|
|
455
|
+
function hasHeaderGetter(headers) {
|
|
456
|
+
return typeof headers?.get === "function";
|
|
457
|
+
}
|
|
458
|
+
function detectResponseDrift(payload, provider, label) {
|
|
459
|
+
const known = knownProviderFields(provider, label);
|
|
460
|
+
const issues = [];
|
|
461
|
+
walkProviderFields(payload, "", (path) => {
|
|
462
|
+
if (path && !known.has(path.replace(/\[\d+\]/g, "[]"))) {
|
|
463
|
+
issues.push({ label, field: path, issue: "unknown field observed in provider response" });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
return issues;
|
|
467
|
+
}
|
|
468
|
+
function walkProviderFields(value, path, visit) {
|
|
469
|
+
if (Array.isArray(value)) {
|
|
470
|
+
value.slice(0, 2).forEach((item, index) => walkProviderFields(item, `${path}[${index}]`, visit));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!isRecord(value))
|
|
474
|
+
return;
|
|
475
|
+
for (const [key, child] of Object.entries(value)) {
|
|
476
|
+
const next = path ? `${path}.${key}` : key;
|
|
477
|
+
visit(next);
|
|
478
|
+
walkProviderFields(child, next, visit);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function knownProviderFields(provider, label) {
|
|
482
|
+
const common = ["data", "data[]", "has_more", "hasMore", "next_page", "nextPage"];
|
|
483
|
+
if (provider === "openai" && label.includes("costs")) {
|
|
484
|
+
return new Set([...common, "data[].object", "data[].start_time", "data[].end_time", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].amount", "data[].results[].amount.value", "data[].results[].amount.currency", "data[].results[].line_item", "data[].results[].project_id", "data[].results[].api_key_id", "data[].results[].quantity"]);
|
|
485
|
+
}
|
|
486
|
+
if (provider === "openai" && label.includes("usage")) {
|
|
487
|
+
return new Set([...common, "data[].object", "data[].start_time", "data[].end_time", "data[].results", "data[].results[]", "data[].results[].object", "data[].results[].input_tokens", "data[].results[].output_tokens", "data[].results[].input_cached_tokens", "data[].results[].input_audio_tokens", "data[].results[].output_audio_tokens", "data[].results[].num_model_requests", "data[].results[].project_id", "data[].results[].user_id", "data[].results[].api_key_id", "data[].results[].model"]);
|
|
488
|
+
}
|
|
489
|
+
return new Set([...common]);
|
|
490
|
+
}
|
|
491
|
+
function qaSummary(provider, fetches) {
|
|
492
|
+
return {
|
|
493
|
+
provider,
|
|
494
|
+
requestedEndpoints: Array.from(new Set(fetches.map((fetchResult) => fetchResult.pagination.label))),
|
|
495
|
+
pagination: fetches.map((fetchResult) => fetchResult.pagination),
|
|
496
|
+
rateLimits: fetches.flatMap((fetchResult) => fetchResult.rateLimits),
|
|
497
|
+
responseDrift: fetches.flatMap((fetchResult) => fetchResult.responseDrift),
|
|
498
|
+
instructions: providerInstructions(provider)
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function providerInstructions(provider) {
|
|
502
|
+
if (provider === "openai") {
|
|
503
|
+
return [
|
|
504
|
+
"Use an OpenAI admin key reference with organization usage and cost read access.",
|
|
505
|
+
"Keep cost buckets and usage buckets separate; usage evidence does not imply dollars until billing reconciliation."
|
|
506
|
+
];
|
|
507
|
+
}
|
|
508
|
+
if (provider === "anthropic") {
|
|
509
|
+
return [
|
|
510
|
+
"Use an Anthropic Admin API key reference with organization cost report and Claude Code usage report read access.",
|
|
511
|
+
"Treat Claude Code usage-report costs as estimated unless reconciled to Admin cost report totals."
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
if (provider === "github-copilot") {
|
|
515
|
+
return [
|
|
516
|
+
"Use a GitHub token reference with org or enterprise Copilot metrics and billing seats read access.",
|
|
517
|
+
"Seat records estimate monthly commitment; metrics records are usage evidence without direct spend allocation."
|
|
518
|
+
];
|
|
519
|
+
}
|
|
520
|
+
if (provider === "cursor") {
|
|
521
|
+
return [
|
|
522
|
+
"Use a Cursor team admin API key reference, or fall back to Browser Account UI/manual export when API access is unavailable.",
|
|
523
|
+
"Validate user-level spend against invoices before treating the source as finance-grade."
|
|
524
|
+
];
|
|
525
|
+
}
|
|
526
|
+
return ["Use a local token reference only; never paste raw provider secrets into commands or reports."];
|
|
527
|
+
}
|
|
528
|
+
function providerPermissionPrompt(provider, label, response, payload) {
|
|
529
|
+
const rawMessage = sanitizeProviderMessage(extractProviderMessage(payload));
|
|
530
|
+
const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
|
|
531
|
+
if (response.status === 401 || response.status === 403) {
|
|
532
|
+
if (provider === "openai") {
|
|
533
|
+
return `Missing OpenAI admin read scopes for ${label} (${status}). Reconnect with organization usage/cost read access or use an admin token reference. ${rawMessage}`;
|
|
534
|
+
}
|
|
535
|
+
if (provider === "anthropic") {
|
|
536
|
+
return `Missing Anthropic Admin read scopes for ${label} (${status}). Reconnect with organization cost report and Claude Code usage report read access. ${rawMessage}`;
|
|
537
|
+
}
|
|
538
|
+
if (provider === "github-copilot") {
|
|
539
|
+
return `Missing GitHub Copilot org or enterprise read scopes for ${label} (${status}). Reconnect with Copilot metrics and billing seats read access. ${rawMessage}`;
|
|
540
|
+
}
|
|
541
|
+
if (provider === "cursor") {
|
|
542
|
+
return `Missing Cursor team admin read scopes for ${label} (${status}). Use Cursor Admin API access or fall back to Browser Account UI/manual export. ${rawMessage}`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return `${label} request failed with ${status}. ${rawMessage}`.trim();
|
|
546
|
+
}
|
|
547
|
+
function extractProviderMessage(payload) {
|
|
548
|
+
if (!isRecord(payload))
|
|
549
|
+
return "";
|
|
550
|
+
const error = isRecord(payload.error) ? payload.error : undefined;
|
|
551
|
+
return stringValue(error?.message) ?? stringValue(payload.message) ?? "";
|
|
552
|
+
}
|
|
553
|
+
function sanitizeProviderMessage(message) {
|
|
554
|
+
return message.replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]").replace(/gh[pousr]_[A-Za-z0-9_]+/g, "[REDACTED]");
|
|
555
|
+
}
|
|
556
|
+
function providerResult(provider, sourceId, authReference, records, fallbackCompleteness, qa) {
|
|
557
|
+
const totalUsd = records.reduce((sum, record) => sum + (record.amountUsd ?? 0), 0);
|
|
558
|
+
return {
|
|
559
|
+
provider,
|
|
560
|
+
source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd }),
|
|
561
|
+
records,
|
|
562
|
+
fetchedAt: new Date().toISOString(),
|
|
563
|
+
completeness: records.length > 0 ? fallbackCompleteness : "missing",
|
|
564
|
+
qa: qa ?? qaSummary(provider, [])
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
export function createProviderConnection(input) {
|
|
568
|
+
const source = createProviderConnectorStub(input.provider, "provider_api", input.fetchedAt);
|
|
569
|
+
const total = `$${input.totalUsd.toFixed(2)}`;
|
|
570
|
+
return {
|
|
571
|
+
...source,
|
|
572
|
+
id: input.sourceId ?? source.id,
|
|
573
|
+
verification: "verified",
|
|
574
|
+
authReference: input.authReference,
|
|
575
|
+
fieldsMissing: [],
|
|
576
|
+
scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} verified records totaling ${total}.`
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
export function resolveTokenReference(reference, env = process.env) {
|
|
580
|
+
if (!reference.startsWith("env:")) {
|
|
581
|
+
throw new Error("Provider auth reference must be a local reference such as env:OPENAI_ADMIN_KEY; raw secrets are not accepted.");
|
|
582
|
+
}
|
|
583
|
+
const envName = reference.slice("env:".length);
|
|
584
|
+
if (!/^[A-Z0-9_]+$/.test(envName)) {
|
|
585
|
+
throw new Error("Provider auth env reference must use an uppercase environment variable name.");
|
|
586
|
+
}
|
|
587
|
+
const value = env[envName];
|
|
588
|
+
if (!value) {
|
|
589
|
+
throw new Error(`Provider auth reference ${reference} is not set in the local environment.`);
|
|
590
|
+
}
|
|
591
|
+
return value;
|
|
592
|
+
}
|
|
593
|
+
function buildOpenAiCostsUrl(startTime, endTime) {
|
|
594
|
+
const url = new URL("https://api.openai.com/v1/organization/costs");
|
|
595
|
+
url.searchParams.set("start_time", String(startTime));
|
|
596
|
+
url.searchParams.set("bucket_width", "1d");
|
|
597
|
+
url.searchParams.set("limit", "180");
|
|
598
|
+
url.searchParams.append("group_by", "project_id");
|
|
599
|
+
url.searchParams.append("group_by", "line_item");
|
|
600
|
+
url.searchParams.append("group_by", "api_key_id");
|
|
601
|
+
if (endTime)
|
|
602
|
+
url.searchParams.set("end_time", String(endTime));
|
|
603
|
+
return url.toString();
|
|
604
|
+
}
|
|
605
|
+
function buildOpenAiUsageUrl(startTime, endTime) {
|
|
606
|
+
const url = new URL("https://api.openai.com/v1/organization/usage/completions");
|
|
607
|
+
url.searchParams.set("start_time", String(startTime));
|
|
608
|
+
url.searchParams.set("bucket_width", "1d");
|
|
609
|
+
url.searchParams.set("limit", "31");
|
|
610
|
+
url.searchParams.append("group_by", "project_id");
|
|
611
|
+
url.searchParams.append("group_by", "user_id");
|
|
612
|
+
url.searchParams.append("group_by", "api_key_id");
|
|
613
|
+
url.searchParams.append("group_by", "model");
|
|
614
|
+
if (endTime)
|
|
615
|
+
url.searchParams.set("end_time", String(endTime));
|
|
616
|
+
return url.toString();
|
|
617
|
+
}
|
|
618
|
+
function buildAnthropicCostUrl(startTime, endTime) {
|
|
619
|
+
const url = new URL("https://api.anthropic.com/v1/organizations/cost_report");
|
|
620
|
+
url.searchParams.set("starting_at", new Date(startTime * 1000).toISOString());
|
|
621
|
+
if (endTime)
|
|
622
|
+
url.searchParams.set("ending_at", new Date(endTime * 1000).toISOString());
|
|
623
|
+
url.searchParams.set("bucket_width", "1d");
|
|
624
|
+
url.searchParams.append("group_by[]", "workspace_id");
|
|
625
|
+
url.searchParams.append("group_by[]", "description");
|
|
626
|
+
return url.toString();
|
|
627
|
+
}
|
|
628
|
+
function buildGitHubCopilotMetricsUrl(input) {
|
|
629
|
+
if (input.enterprise)
|
|
630
|
+
return `https://api.github.com/enterprises/${encodeURIComponent(input.enterprise)}/copilot/metrics/reports/enterprise-28-day/latest`;
|
|
631
|
+
if (input.org)
|
|
632
|
+
return `https://api.github.com/orgs/${encodeURIComponent(input.org)}/copilot/metrics/reports/organization-28-day/latest`;
|
|
633
|
+
throw new Error("GitHub Copilot connector requires --org or --enterprise.");
|
|
634
|
+
}
|
|
635
|
+
function buildAnthropicClaudeCodeUrl(startTime) {
|
|
636
|
+
const url = new URL("https://api.anthropic.com/v1/organizations/usage_report/claude_code");
|
|
637
|
+
url.searchParams.set("starting_at", new Date(startTime * 1000).toISOString().slice(0, 10));
|
|
638
|
+
url.searchParams.set("limit", "1000");
|
|
639
|
+
return url.toString();
|
|
640
|
+
}
|
|
641
|
+
function buildGitHubCopilotSeatsUrl(org) {
|
|
642
|
+
return `https://api.github.com/orgs/${encodeURIComponent(org)}/copilot/billing/seats?per_page=100`;
|
|
643
|
+
}
|
|
644
|
+
function defaultTokenResolver(reference) {
|
|
645
|
+
return resolveTokenReference(reference);
|
|
646
|
+
}
|
|
647
|
+
async function defaultFetcher(url, init) {
|
|
648
|
+
return fetch(url, init);
|
|
649
|
+
}
|
|
650
|
+
function parseMinorUsd(value) {
|
|
651
|
+
const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
652
|
+
return Number.isFinite(numeric) ? numeric / 100 : undefined;
|
|
653
|
+
}
|
|
654
|
+
/** Amount already denominated in dollars, as number or decimal string. */
|
|
655
|
+
function parseDollarUsd(value) {
|
|
656
|
+
const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
657
|
+
return Number.isFinite(numeric) ? numeric : undefined;
|
|
658
|
+
}
|
|
659
|
+
function numberValue(value) {
|
|
660
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
661
|
+
}
|
|
662
|
+
function stringValue(value) {
|
|
663
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
664
|
+
}
|
|
665
|
+
function extractArray(value, key) {
|
|
666
|
+
if (Array.isArray(value))
|
|
667
|
+
return value;
|
|
668
|
+
if (isRecord(value) && Array.isArray(value[key]))
|
|
669
|
+
return value[key];
|
|
670
|
+
return [];
|
|
671
|
+
}
|
|
672
|
+
function isObject(value) {
|
|
673
|
+
return typeof value === "object" && value !== null;
|
|
674
|
+
}
|
|
675
|
+
function isRecord(value) {
|
|
676
|
+
return typeof value === "object" && value !== null;
|
|
677
|
+
}
|
|
678
|
+
function btoaCompat(value) {
|
|
679
|
+
if (typeof btoa === "function")
|
|
680
|
+
return btoa(value);
|
|
681
|
+
return Buffer.from(value).toString("base64");
|
|
682
|
+
}
|
|
683
|
+
//# sourceMappingURL=providerConnectors.js.map
|