@rigour-labs/core 5.3.3 → 5.4.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/services/context-telemetry-service.d.ts +13 -3
- package/dist/services/context-telemetry-service.js +100 -34
- package/dist/services/context-telemetry-service.test.js +39 -0
- package/dist/services/cursor-usage-client.d.ts +19 -0
- package/dist/services/cursor-usage-client.js +73 -0
- package/dist/services/cursor-usage-normalizer.d.ts +24 -0
- package/dist/services/cursor-usage-normalizer.js +102 -0
- package/dist/services/cursor-usage-sync.test.d.ts +1 -0
- package/dist/services/cursor-usage-sync.test.js +173 -0
- package/dist/services/model-pricing.d.ts +36 -0
- package/dist/services/model-pricing.js +137 -0
- package/dist/services/model-pricing.test.d.ts +1 -0
- package/dist/services/model-pricing.test.js +44 -0
- package/dist/storage/context-telemetry.d.ts +1 -0
- package/package.json +6 -6
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Calculates exact observed usage, avoided tokens, cache performance, and cost breakdowns.
|
|
4
4
|
* Supports CSV/JSON Cursor usage imports.
|
|
5
5
|
*/
|
|
6
|
+
import { type CursorUsageSyncOptions } from './cursor-usage-client.js';
|
|
6
7
|
/** Heuristic token counter (~4 chars per token for code/text) */
|
|
7
8
|
export declare function estimateTokenCount(text: string): number;
|
|
8
9
|
/** Default session token budgets */
|
|
@@ -26,11 +27,14 @@ export interface TaskCostStats {
|
|
|
26
27
|
costUsd: number;
|
|
27
28
|
source: string;
|
|
28
29
|
isEstimated: boolean;
|
|
30
|
+
pricingBasis?: string;
|
|
29
31
|
};
|
|
30
32
|
estimated: {
|
|
31
33
|
potentialContextAvoided: number;
|
|
32
34
|
estimatedCostAvoidedUsd: number;
|
|
33
35
|
isEstimated: boolean;
|
|
36
|
+
inputPricePerMillionUsd?: number;
|
|
37
|
+
pricingBasis?: string;
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
40
|
export interface CachePerformanceStats {
|
|
@@ -106,11 +110,17 @@ export declare function importCursorUsageCsv(csvContent: string, cwd?: string):
|
|
|
106
110
|
* Import Cursor usage data from JSON format (e.g. from Cursor Admin API).
|
|
107
111
|
*/
|
|
108
112
|
export declare function importCursorUsageJson(jsonData: unknown, cwd?: string): Promise<number>;
|
|
113
|
+
export interface CursorAdminSyncResult {
|
|
114
|
+
importedCount: number;
|
|
115
|
+
totalEvents: number;
|
|
116
|
+
}
|
|
109
117
|
/**
|
|
110
|
-
* Fetch Cursor usage from Admin API
|
|
111
|
-
*
|
|
118
|
+
* Fetch Cursor usage from Admin API and import token-based events.
|
|
119
|
+
* Costs are reported in cents by Cursor (`chargedCents`) and converted to USD.
|
|
112
120
|
*/
|
|
113
|
-
export declare function fetchCursorUsageFromAdminApi(
|
|
121
|
+
export declare function fetchCursorUsageFromAdminApi(cwdOrOptions?: string | CursorUsageSyncOptions): Promise<number>;
|
|
122
|
+
export declare function syncCursorUsageFromAdminApi(cwdOrOptions?: string | CursorUsageSyncOptions): Promise<CursorAdminSyncResult>;
|
|
123
|
+
export declare function countCursorAdminImportedEvents(cwd?: string): Promise<number>;
|
|
114
124
|
/**
|
|
115
125
|
* Summarize agent scope overlap and governance rule overhead for Studio.
|
|
116
126
|
*/
|
|
@@ -8,20 +8,15 @@ import fs from 'fs-extra';
|
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import { getContextEvents, getModelUsages, getCheckpointMetrics, recordModelUsage, } from '../storage/index.js';
|
|
10
10
|
import { getCursorApiKey } from '../settings.js';
|
|
11
|
+
import { estimateAvoidedContextCostUsd, estimateTokenCostUsd, computeWeightedInputPricePerMillion, } from './model-pricing.js';
|
|
12
|
+
import { fetchCursorUsagePages } from './cursor-usage-client.js';
|
|
13
|
+
import { CURSOR_ADMIN_API_SOURCE, centsToUsd, isCursorAdminApiEvent, normalizeCursorUsageEvent } from './cursor-usage-normalizer.js';
|
|
11
14
|
/** Heuristic token counter (~4 chars per token for code/text) */
|
|
12
15
|
export function estimateTokenCount(text) {
|
|
13
16
|
if (!text)
|
|
14
17
|
return 0;
|
|
15
18
|
return Math.ceil(text.length / 4);
|
|
16
19
|
}
|
|
17
|
-
/** Default Model Input Rates (per million tokens) */
|
|
18
|
-
const DEFAULT_MODEL_PRICES = {
|
|
19
|
-
'claude-3-5-sonnet': 3.0,
|
|
20
|
-
'gpt-4o': 2.5,
|
|
21
|
-
'gpt-4o-mini': 0.15,
|
|
22
|
-
'o1-preview': 15.0,
|
|
23
|
-
'default': 3.0,
|
|
24
|
-
};
|
|
25
20
|
/** Default session token budgets */
|
|
26
21
|
export const DEFAULT_SESSION_SOFT_LIMIT = 160_000;
|
|
27
22
|
export const DEFAULT_SESSION_HARD_LIMIT = 200_000;
|
|
@@ -85,32 +80,46 @@ export async function getTaskCostStats(taskId, cwd) {
|
|
|
85
80
|
const stats = await getTaskContextStats(taskId, cwd);
|
|
86
81
|
let actualInputTokens = 0;
|
|
87
82
|
let actualOutputTokens = 0;
|
|
88
|
-
let
|
|
83
|
+
let observedCostUsd = 0;
|
|
89
84
|
let primarySource = 'estimated';
|
|
90
85
|
const hasObservedUsage = usages.length > 0;
|
|
91
86
|
for (const u of usages) {
|
|
92
87
|
actualInputTokens += u.inputTokens || 0;
|
|
93
88
|
actualOutputTokens += u.outputTokens || 0;
|
|
94
|
-
|
|
89
|
+
observedCostUsd += u.observedCostUsd || 0;
|
|
95
90
|
if (u.source && u.source !== 'estimated') {
|
|
96
91
|
primarySource = u.source;
|
|
97
92
|
}
|
|
98
93
|
}
|
|
99
|
-
const
|
|
94
|
+
const hasObservedCost = observedCostUsd > 0;
|
|
95
|
+
let actualCostUsd = observedCostUsd;
|
|
96
|
+
let actualIsEstimated = !hasObservedUsage;
|
|
97
|
+
let actualPricingBasis;
|
|
98
|
+
if (!hasObservedCost && hasObservedUsage) {
|
|
99
|
+
actualCostUsd = usages.reduce((sum, usage) => sum + estimateTokenCostUsd(usage.inputTokens || 0, usage.outputTokens || 0, usage.model), 0);
|
|
100
|
+
actualIsEstimated = true;
|
|
101
|
+
actualPricingBasis = computeWeightedInputPricePerMillion(usages).pricingBasis;
|
|
102
|
+
}
|
|
103
|
+
else if (hasObservedCost) {
|
|
104
|
+
actualPricingBasis = 'observed-cursor-charged-cents';
|
|
105
|
+
}
|
|
100
106
|
const totalPotentialAvoided = stats.potentialAvoidedTokens + stats.checkpointReplayAvoided;
|
|
101
|
-
const
|
|
107
|
+
const avoidedPricing = estimateAvoidedContextCostUsd(totalPotentialAvoided, usages);
|
|
102
108
|
return {
|
|
103
109
|
actual: {
|
|
104
110
|
inputTokens: actualInputTokens,
|
|
105
111
|
outputTokens: actualOutputTokens,
|
|
106
|
-
costUsd: parseFloat(actualCostUsd.toFixed(
|
|
112
|
+
costUsd: parseFloat(actualCostUsd.toFixed(4)),
|
|
107
113
|
source: primarySource,
|
|
108
|
-
isEstimated:
|
|
114
|
+
isEstimated: actualIsEstimated,
|
|
115
|
+
pricingBasis: actualPricingBasis,
|
|
109
116
|
},
|
|
110
117
|
estimated: {
|
|
111
118
|
potentialContextAvoided: totalPotentialAvoided,
|
|
112
|
-
estimatedCostAvoidedUsd,
|
|
119
|
+
estimatedCostAvoidedUsd: avoidedPricing.costUsd,
|
|
113
120
|
isEstimated: true,
|
|
121
|
+
inputPricePerMillionUsd: avoidedPricing.pricing.inputPricePerMillionUsd,
|
|
122
|
+
pricingBasis: avoidedPricing.pricing.pricingBasis,
|
|
114
123
|
},
|
|
115
124
|
};
|
|
116
125
|
}
|
|
@@ -274,38 +283,95 @@ export async function importCursorUsageJson(jsonData, cwd) {
|
|
|
274
283
|
const records = Array.isArray(jsonData)
|
|
275
284
|
? jsonData
|
|
276
285
|
: (payload.records || payload.usage || [jsonData]);
|
|
286
|
+
const existingUsages = await getModelUsages(undefined, cwd);
|
|
287
|
+
const seenIds = new Set(existingUsages.map(u => u.id).filter(Boolean));
|
|
277
288
|
let importedCount = 0;
|
|
278
289
|
for (const r of records) {
|
|
290
|
+
const normalized = isCursorAdminApiEvent(r) ? normalizeCursorUsageEvent(r) : null;
|
|
291
|
+
if (normalized) {
|
|
292
|
+
if (seenIds.has(normalized.id))
|
|
293
|
+
continue;
|
|
294
|
+
seenIds.add(normalized.id);
|
|
295
|
+
await recordModelUsage(normalized.usage, cwd);
|
|
296
|
+
importedCount++;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const row = r;
|
|
300
|
+
const id = row.id
|
|
301
|
+
?? `json-${hashCsvRow(JSON.stringify(row))}`;
|
|
302
|
+
if (seenIds.has(id))
|
|
303
|
+
continue;
|
|
304
|
+
seenIds.add(id);
|
|
305
|
+
const tokenUsage = row.tokenUsage;
|
|
306
|
+
const chargedCents = Number(row.chargedCents ?? tokenUsage?.totalCents ?? 0);
|
|
307
|
+
const observedFromRow = Number(row.observedCostUsd ?? row.cost_usd ?? row.cost ?? 0);
|
|
308
|
+
const observedCostUsd = observedFromRow > 0
|
|
309
|
+
? observedFromRow
|
|
310
|
+
: chargedCents > 0
|
|
311
|
+
? centsToUsd(chargedCents)
|
|
312
|
+
: 0;
|
|
279
313
|
await recordModelUsage({
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
314
|
+
id,
|
|
315
|
+
taskId: (row.taskId || row.task_id || 'cursor-imported'),
|
|
316
|
+
sessionId: (row.sessionId || row.session_id),
|
|
317
|
+
agentId: (row.agentId || row.agent_id),
|
|
318
|
+
provider: (row.provider || 'cursor'),
|
|
319
|
+
model: (row.model || 'claude-3-5-sonnet'),
|
|
320
|
+
inputTokens: (row.inputTokens || row.input_tokens || tokenUsage?.inputTokens || 0),
|
|
321
|
+
outputTokens: (row.outputTokens || row.output_tokens || tokenUsage?.outputTokens || 0),
|
|
322
|
+
cachedInputTokens: (row.cachedInputTokens || row.cached_input_tokens || row.cacheReadTokens || tokenUsage?.cacheReadTokens || 0),
|
|
323
|
+
cacheWriteTokens: (row.cacheWriteTokens || row.cache_write_tokens || tokenUsage?.cacheWriteTokens || 0),
|
|
324
|
+
observedCostUsd,
|
|
325
|
+
source: row.source || 'cursor-admin-api',
|
|
326
|
+
createdAt: row.createdAt ? Number(row.createdAt) : undefined,
|
|
290
327
|
}, cwd);
|
|
291
328
|
importedCount++;
|
|
292
329
|
}
|
|
293
330
|
return importedCount;
|
|
294
331
|
}
|
|
332
|
+
async function persistCursorUsageEvents(events, cwd) {
|
|
333
|
+
const existingUsages = await getModelUsages(undefined, cwd);
|
|
334
|
+
const seenIds = new Set(existingUsages.map(u => u.id).filter(Boolean));
|
|
335
|
+
let importedCount = 0;
|
|
336
|
+
for (const event of events) {
|
|
337
|
+
if (seenIds.has(event.id))
|
|
338
|
+
continue;
|
|
339
|
+
seenIds.add(event.id);
|
|
340
|
+
await recordModelUsage(event.usage, cwd);
|
|
341
|
+
importedCount++;
|
|
342
|
+
}
|
|
343
|
+
return importedCount;
|
|
344
|
+
}
|
|
295
345
|
/**
|
|
296
|
-
* Fetch Cursor usage from Admin API
|
|
297
|
-
*
|
|
346
|
+
* Fetch Cursor usage from Admin API and import token-based events.
|
|
347
|
+
* Costs are reported in cents by Cursor (`chargedCents`) and converted to USD.
|
|
298
348
|
*/
|
|
299
|
-
export async function fetchCursorUsageFromAdminApi(
|
|
300
|
-
const
|
|
349
|
+
export async function fetchCursorUsageFromAdminApi(cwdOrOptions) {
|
|
350
|
+
const options = typeof cwdOrOptions === 'string'
|
|
351
|
+
? { cwd: cwdOrOptions }
|
|
352
|
+
: (cwdOrOptions ?? {});
|
|
353
|
+
const apiKey = options.apiKey ?? getCursorApiKey();
|
|
301
354
|
if (!apiKey) {
|
|
302
355
|
return 0;
|
|
303
356
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
357
|
+
const events = await fetchCursorUsagePages(options);
|
|
358
|
+
return persistCursorUsageEvents(events, options.cwd);
|
|
359
|
+
}
|
|
360
|
+
export async function syncCursorUsageFromAdminApi(cwdOrOptions) {
|
|
361
|
+
const options = typeof cwdOrOptions === 'string'
|
|
362
|
+
? { cwd: cwdOrOptions }
|
|
363
|
+
: (cwdOrOptions ?? {});
|
|
364
|
+
const apiKey = options.apiKey ?? getCursorApiKey();
|
|
365
|
+
if (!apiKey) {
|
|
366
|
+
return { importedCount: 0, totalEvents: 0 };
|
|
367
|
+
}
|
|
368
|
+
const events = await fetchCursorUsagePages(options);
|
|
369
|
+
const importedCount = await persistCursorUsageEvents(events, options.cwd);
|
|
370
|
+
return { importedCount, totalEvents: events.length };
|
|
371
|
+
}
|
|
372
|
+
export async function countCursorAdminImportedEvents(cwd) {
|
|
373
|
+
const usages = await getModelUsages(undefined, cwd);
|
|
374
|
+
return usages.filter(u => u.source === CURSOR_ADMIN_API_SOURCE).length;
|
|
309
375
|
}
|
|
310
376
|
/**
|
|
311
377
|
* Summarize agent scope overlap and governance rule overhead for Studio.
|
|
@@ -95,6 +95,20 @@ describe('context-telemetry-service', () => {
|
|
|
95
95
|
const cost = await getTaskCostStats(jsonTaskId, testCwd);
|
|
96
96
|
expect(cost.actual.inputTokens).toBe(2500);
|
|
97
97
|
});
|
|
98
|
+
it('imports Admin API JSON events with chargedCents and dedupes against sync IDs', async () => {
|
|
99
|
+
const adminEvent = {
|
|
100
|
+
timestamp: '1750979225854',
|
|
101
|
+
conversationId: 'conv-dedupe',
|
|
102
|
+
model: 'composer-2.5-standard',
|
|
103
|
+
chargedCents: 42.5,
|
|
104
|
+
tokenUsage: { inputTokens: 100, outputTokens: 50 },
|
|
105
|
+
};
|
|
106
|
+
expect(await importCursorUsageJson([adminEvent], testCwd)).toBe(1);
|
|
107
|
+
expect(await importCursorUsageJson([adminEvent], testCwd)).toBe(0);
|
|
108
|
+
const cost = await getTaskCostStats('conv-dedupe', testCwd);
|
|
109
|
+
expect(cost.actual.costUsd).toBeCloseTo(0.425, 3);
|
|
110
|
+
expect(cost.actual.isEstimated).toBe(false);
|
|
111
|
+
});
|
|
98
112
|
it('summarizes agent scope overlap from session file', async () => {
|
|
99
113
|
await fs.writeJson(path.join(testCwd, '.rigour', 'agent-session.json'), {
|
|
100
114
|
agents: [
|
|
@@ -127,4 +141,29 @@ describe('context-telemetry-service', () => {
|
|
|
127
141
|
expect(summary.compressionRatio).toBe(20);
|
|
128
142
|
expect(summary.replayTokensAvoided).toBe(9500);
|
|
129
143
|
});
|
|
144
|
+
it('marks observed cost from usages and estimates avoided cost by model', async () => {
|
|
145
|
+
const jsonTaskId = `${taskId}-composer-${Math.random().toString(36).slice(2)}`;
|
|
146
|
+
await importCursorUsageJson([{
|
|
147
|
+
id: `json-composer-${jsonTaskId}`,
|
|
148
|
+
taskId: jsonTaskId,
|
|
149
|
+
inputTokens: 2_000_000,
|
|
150
|
+
outputTokens: 0,
|
|
151
|
+
observedCostUsd: 1.25,
|
|
152
|
+
model: 'composer-2.5-standard',
|
|
153
|
+
source: 'cursor-admin-api',
|
|
154
|
+
}], testCwd);
|
|
155
|
+
await recordContextEvent({
|
|
156
|
+
taskId: jsonTaskId,
|
|
157
|
+
toolName: 'rigour_context_scope',
|
|
158
|
+
cacheStatus: 'miss',
|
|
159
|
+
candidateTokens: 3_000_000,
|
|
160
|
+
returnedTokens: 1_000_000,
|
|
161
|
+
}, testCwd);
|
|
162
|
+
const cost = await getTaskCostStats(jsonTaskId, testCwd);
|
|
163
|
+
expect(cost.actual.isEstimated).toBe(false);
|
|
164
|
+
expect(cost.actual.costUsd).toBe(1.25);
|
|
165
|
+
expect(cost.estimated.isEstimated).toBe(true);
|
|
166
|
+
expect(cost.estimated.inputPricePerMillionUsd).toBe(0.5);
|
|
167
|
+
expect(cost.estimated.estimatedCostAvoidedUsd).toBeGreaterThan(0);
|
|
168
|
+
});
|
|
130
169
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type NormalizedCursorUsageEvent } from './cursor-usage-normalizer.js';
|
|
2
|
+
export declare const CURSOR_FILTERED_USAGE_URL = "https://api.cursor.com/teams/filtered-usage-events";
|
|
3
|
+
export declare const DEFAULT_CURSOR_SYNC_DAYS = 30;
|
|
4
|
+
export declare const DEFAULT_CURSOR_PAGE_SIZE = 100;
|
|
5
|
+
export declare const MAX_CURSOR_SYNC_PAGES = 50;
|
|
6
|
+
export interface CursorUsageSyncOptions {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
startDate?: number;
|
|
10
|
+
endDate?: number;
|
|
11
|
+
pageSize?: number;
|
|
12
|
+
maxPages?: number;
|
|
13
|
+
fetchImpl?: typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
export declare class CursorUsageApiError extends Error {
|
|
16
|
+
readonly status: number;
|
|
17
|
+
constructor(status: number, message: string);
|
|
18
|
+
}
|
|
19
|
+
export declare function fetchCursorUsagePages(options?: CursorUsageSyncOptions): Promise<NormalizedCursorUsageEvent[]>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Admin API client for filtered usage events.
|
|
3
|
+
*/
|
|
4
|
+
import { getCursorApiKey } from '../settings.js';
|
|
5
|
+
import { normalizeCursorUsageEvent, parseCursorUsageApiResponse, } from './cursor-usage-normalizer.js';
|
|
6
|
+
export const CURSOR_FILTERED_USAGE_URL = 'https://api.cursor.com/teams/filtered-usage-events';
|
|
7
|
+
export const DEFAULT_CURSOR_SYNC_DAYS = 30;
|
|
8
|
+
export const DEFAULT_CURSOR_PAGE_SIZE = 100;
|
|
9
|
+
export const MAX_CURSOR_SYNC_PAGES = 50;
|
|
10
|
+
export class CursorUsageApiError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(status, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'CursorUsageApiError';
|
|
15
|
+
this.status = status;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function buildBasicAuthHeader(apiKey) {
|
|
19
|
+
const token = Buffer.from(`${apiKey}:`, 'utf8').toString('base64');
|
|
20
|
+
return `Basic ${token}`;
|
|
21
|
+
}
|
|
22
|
+
function defaultDateRange() {
|
|
23
|
+
const endDate = Date.now();
|
|
24
|
+
const startDate = endDate - DEFAULT_CURSOR_SYNC_DAYS * 24 * 60 * 60 * 1000;
|
|
25
|
+
return { startDate, endDate };
|
|
26
|
+
}
|
|
27
|
+
export async function fetchCursorUsagePages(options = {}) {
|
|
28
|
+
const apiKey = options.apiKey ?? getCursorApiKey();
|
|
29
|
+
if (!apiKey) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
33
|
+
if (!fetchImpl) {
|
|
34
|
+
throw new CursorUsageApiError(0, 'Fetch is not available in this environment');
|
|
35
|
+
}
|
|
36
|
+
const range = defaultDateRange();
|
|
37
|
+
const startDate = options.startDate ?? range.startDate;
|
|
38
|
+
const endDate = options.endDate ?? range.endDate;
|
|
39
|
+
const pageSize = options.pageSize ?? DEFAULT_CURSOR_PAGE_SIZE;
|
|
40
|
+
const maxPages = options.maxPages ?? MAX_CURSOR_SYNC_PAGES;
|
|
41
|
+
const events = [];
|
|
42
|
+
let page = 1;
|
|
43
|
+
let hasNextPage = true;
|
|
44
|
+
while (hasNextPage && page <= maxPages) {
|
|
45
|
+
const response = await fetchImpl(CURSOR_FILTERED_USAGE_URL, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
Authorization: buildBasicAuthHeader(apiKey),
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({ startDate, endDate, page, pageSize }),
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const detail = await response.text().catch(() => '');
|
|
55
|
+
const safeDetail = detail.replace(apiKey, '[REDACTED]').slice(0, 200);
|
|
56
|
+
throw new CursorUsageApiError(response.status, `Cursor Admin API request failed (${response.status}). ${safeDetail || 'Check API key scope and date range.'}`);
|
|
57
|
+
}
|
|
58
|
+
const json = await response.json();
|
|
59
|
+
const parsed = parseCursorUsageApiResponse(json);
|
|
60
|
+
for (const rawEvent of parsed.usageEvents ?? []) {
|
|
61
|
+
const normalized = normalizeCursorUsageEvent(rawEvent);
|
|
62
|
+
if (normalized) {
|
|
63
|
+
events.push(normalized);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
hasNextPage = Boolean(parsed.pagination?.hasNextPage);
|
|
67
|
+
page += 1;
|
|
68
|
+
}
|
|
69
|
+
if (hasNextPage) {
|
|
70
|
+
throw new CursorUsageApiError(413, `Cursor usage sync truncated after ${maxPages} pages. Narrow the date range or contact support.`);
|
|
71
|
+
}
|
|
72
|
+
return events;
|
|
73
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ModelUsage } from '../storage/context-telemetry.js';
|
|
2
|
+
export declare const CURSOR_ADMIN_API_SOURCE = "cursor-admin-api";
|
|
3
|
+
export interface NormalizedCursorUsageEvent {
|
|
4
|
+
id: string;
|
|
5
|
+
usage: ModelUsage;
|
|
6
|
+
}
|
|
7
|
+
export interface CursorUsageApiResponse {
|
|
8
|
+
totalUsageEventsCount?: number;
|
|
9
|
+
usageEvents?: unknown[];
|
|
10
|
+
pagination?: {
|
|
11
|
+
hasNextPage?: boolean;
|
|
12
|
+
currentPage?: number;
|
|
13
|
+
numPages?: number;
|
|
14
|
+
pageSize?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare function isCursorAdminApiEvent(value: unknown): value is Record<string, unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* Cursor reports costs in cents (`chargedCents`, `tokenUsage.totalCents`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function centsToUsd(cents: number): number;
|
|
22
|
+
export declare function buildCursorEventFingerprint(event: Record<string, unknown>): string;
|
|
23
|
+
export declare function normalizeCursorUsageEvent(raw: unknown): NormalizedCursorUsageEvent | null;
|
|
24
|
+
export declare function parseCursorUsageApiResponse(body: unknown): CursorUsageApiResponse;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse and normalize Cursor Admin API filtered usage events.
|
|
3
|
+
*/
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
export const CURSOR_ADMIN_API_SOURCE = 'cursor-admin-api';
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
export function isCursorAdminApiEvent(value) {
|
|
10
|
+
if (!isRecord(value))
|
|
11
|
+
return false;
|
|
12
|
+
return value.tokenUsage !== undefined
|
|
13
|
+
|| value.chargedCents !== undefined
|
|
14
|
+
|| (value.timestamp !== undefined && value.model !== undefined);
|
|
15
|
+
}
|
|
16
|
+
function readNumber(value, fallback = 0) {
|
|
17
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
18
|
+
return value;
|
|
19
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
20
|
+
const parsed = Number(value);
|
|
21
|
+
if (Number.isFinite(parsed))
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
function readString(value) {
|
|
27
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Cursor reports costs in cents (`chargedCents`, `tokenUsage.totalCents`).
|
|
31
|
+
*/
|
|
32
|
+
export function centsToUsd(cents) {
|
|
33
|
+
return parseFloat((cents / 100).toFixed(6));
|
|
34
|
+
}
|
|
35
|
+
export function buildCursorEventFingerprint(event) {
|
|
36
|
+
const tokenUsage = isRecord(event.tokenUsage) ? event.tokenUsage : {};
|
|
37
|
+
const payload = [
|
|
38
|
+
readString(event.timestamp) ?? '',
|
|
39
|
+
readString(event.userEmail) ?? '',
|
|
40
|
+
readString(event.conversationId) ?? '',
|
|
41
|
+
readString(event.model) ?? '',
|
|
42
|
+
readNumber(event.chargedCents),
|
|
43
|
+
readNumber(tokenUsage.inputTokens),
|
|
44
|
+
readNumber(tokenUsage.outputTokens),
|
|
45
|
+
readNumber(tokenUsage.cacheReadTokens),
|
|
46
|
+
readNumber(tokenUsage.cacheWriteTokens),
|
|
47
|
+
].join('|');
|
|
48
|
+
return createHash('sha256').update(payload).digest('hex').slice(0, 16);
|
|
49
|
+
}
|
|
50
|
+
export function normalizeCursorUsageEvent(raw) {
|
|
51
|
+
if (!isRecord(raw))
|
|
52
|
+
return null;
|
|
53
|
+
const model = readString(raw.model) ?? 'unknown-model';
|
|
54
|
+
const timestamp = readString(raw.timestamp);
|
|
55
|
+
const conversationId = readString(raw.conversationId);
|
|
56
|
+
const tokenUsage = isRecord(raw.tokenUsage) ? raw.tokenUsage : undefined;
|
|
57
|
+
const inputTokens = tokenUsage ? readNumber(tokenUsage.inputTokens) : 0;
|
|
58
|
+
const outputTokens = tokenUsage ? readNumber(tokenUsage.outputTokens) : 0;
|
|
59
|
+
const cacheReadTokens = tokenUsage ? readNumber(tokenUsage.cacheReadTokens) : 0;
|
|
60
|
+
const cacheWriteTokens = tokenUsage ? readNumber(tokenUsage.cacheWriteTokens) : 0;
|
|
61
|
+
const chargedCents = readNumber(raw.chargedCents);
|
|
62
|
+
const tokenTotalCents = tokenUsage ? readNumber(tokenUsage.totalCents) : 0;
|
|
63
|
+
const observedCostUsd = chargedCents > 0
|
|
64
|
+
? centsToUsd(chargedCents)
|
|
65
|
+
: tokenTotalCents > 0
|
|
66
|
+
? centsToUsd(tokenTotalCents)
|
|
67
|
+
: 0;
|
|
68
|
+
const fingerprint = buildCursorEventFingerprint(raw);
|
|
69
|
+
const createdAt = timestamp ? readNumber(timestamp, Date.now()) : Date.now();
|
|
70
|
+
return {
|
|
71
|
+
id: `cursor-api-${fingerprint}`,
|
|
72
|
+
usage: {
|
|
73
|
+
id: `cursor-api-${fingerprint}`,
|
|
74
|
+
taskId: conversationId ?? 'cursor-imported',
|
|
75
|
+
sessionId: conversationId,
|
|
76
|
+
provider: 'cursor',
|
|
77
|
+
model,
|
|
78
|
+
inputTokens,
|
|
79
|
+
outputTokens,
|
|
80
|
+
cachedInputTokens: cacheReadTokens,
|
|
81
|
+
cacheWriteTokens,
|
|
82
|
+
observedCostUsd,
|
|
83
|
+
source: CURSOR_ADMIN_API_SOURCE,
|
|
84
|
+
createdAt,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function parseCursorUsageApiResponse(body) {
|
|
89
|
+
if (!isRecord(body)) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
totalUsageEventsCount: readNumber(body.totalUsageEventsCount),
|
|
94
|
+
usageEvents: Array.isArray(body.usageEvents) ? body.usageEvents : [],
|
|
95
|
+
pagination: isRecord(body.pagination) ? {
|
|
96
|
+
hasNextPage: Boolean(body.pagination.hasNextPage),
|
|
97
|
+
currentPage: readNumber(body.pagination.currentPage, 1),
|
|
98
|
+
numPages: readNumber(body.pagination.numPages, 1),
|
|
99
|
+
pageSize: readNumber(body.pagination.pageSize, 0),
|
|
100
|
+
} : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
vi.mock('../storage/db.js', () => ({
|
|
6
|
+
isSQLiteAvailable: () => false,
|
|
7
|
+
openDatabase: async () => null,
|
|
8
|
+
DB_PATH: '/tmp/rigour-test.db',
|
|
9
|
+
RIGOUR_DIR: '/tmp/.rigour',
|
|
10
|
+
}));
|
|
11
|
+
import { fetchCursorUsageFromAdminApi, syncCursorUsageFromAdminApi, getTaskCostStats, } from './context-telemetry-service.js';
|
|
12
|
+
import { getModelUsages } from '../storage/context-telemetry.js';
|
|
13
|
+
import { centsToUsd, normalizeCursorUsageEvent } from './cursor-usage-normalizer.js';
|
|
14
|
+
import { CURSOR_FILTERED_USAGE_URL } from './cursor-usage-client.js';
|
|
15
|
+
function buildUsageEvent(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
timestamp: '1750979225854',
|
|
18
|
+
userEmail: 'dev@example.com',
|
|
19
|
+
conversationId: 'conv-1',
|
|
20
|
+
model: 'composer-2.5-standard',
|
|
21
|
+
isTokenBasedCall: true,
|
|
22
|
+
tokenUsage: {
|
|
23
|
+
inputTokens: 100,
|
|
24
|
+
outputTokens: 50,
|
|
25
|
+
cacheWriteTokens: 10,
|
|
26
|
+
cacheReadTokens: 20,
|
|
27
|
+
totalCents: 12.5,
|
|
28
|
+
},
|
|
29
|
+
chargedCents: 15.25,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
describe('cursor admin usage sync', () => {
|
|
34
|
+
const testCwd = path.join(os.tmpdir(), `rigour-cursor-sync-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
35
|
+
const apiKey = 'test-cursor-admin-key';
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
await fs.ensureDir(path.join(testCwd, '.rigour'));
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await fs.remove(testCwd);
|
|
41
|
+
vi.restoreAllMocks();
|
|
42
|
+
});
|
|
43
|
+
it('imports a single-page response with basic auth header', async () => {
|
|
44
|
+
const fetchImpl = vi.fn(async (_url, init) => ({
|
|
45
|
+
ok: true,
|
|
46
|
+
status: 200,
|
|
47
|
+
json: async () => ({
|
|
48
|
+
usageEvents: [buildUsageEvent()],
|
|
49
|
+
pagination: { hasNextPage: false, currentPage: 1 },
|
|
50
|
+
}),
|
|
51
|
+
}));
|
|
52
|
+
const imported = await fetchCursorUsageFromAdminApi({
|
|
53
|
+
cwd: testCwd,
|
|
54
|
+
apiKey,
|
|
55
|
+
fetchImpl,
|
|
56
|
+
});
|
|
57
|
+
expect(imported).toBe(1);
|
|
58
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
59
|
+
const [, init] = fetchImpl.mock.calls[0];
|
|
60
|
+
expect(init.method).toBe('POST');
|
|
61
|
+
expect(init.headers.Authorization).toBe(`Basic ${Buffer.from(`${apiKey}:`, 'utf8').toString('base64')}`);
|
|
62
|
+
expect(init.headers['Content-Type']).toBe('application/json');
|
|
63
|
+
const body = JSON.parse(init.body);
|
|
64
|
+
expect(body.pageSize).toBeGreaterThan(0);
|
|
65
|
+
expect(body.startDate).toBeLessThan(body.endDate);
|
|
66
|
+
});
|
|
67
|
+
it('paginates until hasNextPage is false', async () => {
|
|
68
|
+
let page = 0;
|
|
69
|
+
const fetchImpl = vi.fn(async () => {
|
|
70
|
+
page += 1;
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
status: 200,
|
|
74
|
+
json: async () => ({
|
|
75
|
+
usageEvents: [buildUsageEvent({ conversationId: `conv-${page}` })],
|
|
76
|
+
pagination: { hasNextPage: page < 2, currentPage: page },
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
const result = await syncCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
|
|
81
|
+
expect(result.importedCount).toBe(2);
|
|
82
|
+
expect(result.totalEvents).toBe(2);
|
|
83
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
84
|
+
});
|
|
85
|
+
it('converts cents to USD and stores token fields', async () => {
|
|
86
|
+
const normalized = normalizeCursorUsageEvent(buildUsageEvent());
|
|
87
|
+
expect(normalized?.usage.observedCostUsd).toBe(centsToUsd(15.25));
|
|
88
|
+
expect(normalized?.usage.inputTokens).toBe(100);
|
|
89
|
+
expect(normalized?.usage.outputTokens).toBe(50);
|
|
90
|
+
expect(normalized?.usage.cachedInputTokens).toBe(20);
|
|
91
|
+
expect(normalized?.usage.cacheWriteTokens).toBe(10);
|
|
92
|
+
expect(normalized?.usage.source).toBe('cursor-admin-api');
|
|
93
|
+
});
|
|
94
|
+
it('deduplicates repeated syncs', async () => {
|
|
95
|
+
const fetchImpl = vi.fn(async () => ({
|
|
96
|
+
ok: true,
|
|
97
|
+
status: 200,
|
|
98
|
+
json: async () => ({
|
|
99
|
+
usageEvents: [buildUsageEvent()],
|
|
100
|
+
pagination: { hasNextPage: false },
|
|
101
|
+
}),
|
|
102
|
+
}));
|
|
103
|
+
const first = await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
|
|
104
|
+
const second = await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
|
|
105
|
+
expect(first).toBe(1);
|
|
106
|
+
expect(second).toBe(0);
|
|
107
|
+
const usages = await getModelUsages(undefined, testCwd);
|
|
108
|
+
expect(usages.filter(u => u.source === 'cursor-admin-api')).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
it('surfaces actionable non-2xx errors without exposing the API key', async () => {
|
|
111
|
+
const fetchImpl = vi.fn(async () => ({
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 401,
|
|
114
|
+
text: async () => `Unauthorized for key ${apiKey}`,
|
|
115
|
+
}));
|
|
116
|
+
await expect(fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl })).rejects.toThrow(/401/);
|
|
117
|
+
await expect(fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl })).rejects.not.toThrow(apiKey);
|
|
118
|
+
});
|
|
119
|
+
it('skips malformed events and handles partial fields', async () => {
|
|
120
|
+
const fetchImpl = vi.fn(async () => ({
|
|
121
|
+
ok: true,
|
|
122
|
+
status: 200,
|
|
123
|
+
json: async () => ({
|
|
124
|
+
usageEvents: [
|
|
125
|
+
null,
|
|
126
|
+
{ model: 'composer-2.5-fast', chargedCents: 5 },
|
|
127
|
+
buildUsageEvent(),
|
|
128
|
+
],
|
|
129
|
+
pagination: { hasNextPage: false },
|
|
130
|
+
}),
|
|
131
|
+
}));
|
|
132
|
+
const imported = await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
|
|
133
|
+
expect(imported).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
it('marks observed cost stats from imported admin API events', async () => {
|
|
136
|
+
const fetchImpl = vi.fn(async () => ({
|
|
137
|
+
ok: true,
|
|
138
|
+
status: 200,
|
|
139
|
+
json: async () => ({
|
|
140
|
+
usageEvents: [buildUsageEvent({ conversationId: 'cost-task' })],
|
|
141
|
+
pagination: { hasNextPage: false },
|
|
142
|
+
}),
|
|
143
|
+
}));
|
|
144
|
+
await fetchCursorUsageFromAdminApi({ cwd: testCwd, apiKey, fetchImpl });
|
|
145
|
+
const cost = await getTaskCostStats('cost-task', testCwd);
|
|
146
|
+
expect(cost.actual.isEstimated).toBe(false);
|
|
147
|
+
expect(cost.actual.costUsd).toBeGreaterThan(0);
|
|
148
|
+
expect(cost.actual.pricingBasis).toBe('observed-cursor-charged-cents');
|
|
149
|
+
});
|
|
150
|
+
it('targets the filtered usage events endpoint', () => {
|
|
151
|
+
expect(CURSOR_FILTERED_USAGE_URL).toBe('https://api.cursor.com/teams/filtered-usage-events');
|
|
152
|
+
});
|
|
153
|
+
it('errors when pagination exceeds the defensive page limit', async () => {
|
|
154
|
+
let page = 0;
|
|
155
|
+
const fetchImpl = vi.fn(async () => {
|
|
156
|
+
page += 1;
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
status: 200,
|
|
160
|
+
json: async () => ({
|
|
161
|
+
usageEvents: [buildUsageEvent({ conversationId: `conv-page-${page}` })],
|
|
162
|
+
pagination: { hasNextPage: true, currentPage: page },
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
await expect(fetchCursorUsageFromAdminApi({
|
|
167
|
+
cwd: testCwd,
|
|
168
|
+
apiKey,
|
|
169
|
+
fetchImpl,
|
|
170
|
+
maxPages: 2,
|
|
171
|
+
})).rejects.toThrow(/truncated after 2 pages/);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-aware input pricing for context cost telemetry.
|
|
3
|
+
* Observed Cursor costs always take precedence; these rates estimate when absent.
|
|
4
|
+
*/
|
|
5
|
+
import type { ModelUsage } from '../storage/context-telemetry.js';
|
|
6
|
+
export interface ModelPricingTier {
|
|
7
|
+
label: string;
|
|
8
|
+
inputPerMillionUsd: number;
|
|
9
|
+
outputPerMillionUsd: number;
|
|
10
|
+
}
|
|
11
|
+
/** Per-million-token USD rates. Input rates drive avoided-context estimates. */
|
|
12
|
+
export declare const MODEL_PRICING: Record<string, ModelPricingTier>;
|
|
13
|
+
/** Conservative fallback when model cannot be resolved (USD per 1M input tokens). */
|
|
14
|
+
export declare const FALLBACK_INPUT_PRICE_PER_MILLION_USD = 3;
|
|
15
|
+
export declare const FALLBACK_OUTPUT_PRICE_PER_MILLION_USD = 15;
|
|
16
|
+
/**
|
|
17
|
+
* Normalize model identifiers from Cursor, CLI flags, or imports.
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeModelKey(model: string | undefined): string;
|
|
20
|
+
export declare function getModelPricing(model: string | undefined): ModelPricingTier | undefined;
|
|
21
|
+
export declare function getInputPricePerMillionUsd(model: string | undefined): number;
|
|
22
|
+
export declare function getOutputPricePerMillionUsd(model: string | undefined): number;
|
|
23
|
+
export interface WeightedInputPricing {
|
|
24
|
+
inputPricePerMillionUsd: number;
|
|
25
|
+
pricingBasis: string;
|
|
26
|
+
isEstimated: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Input-token-weighted effective input price across observed model usages.
|
|
30
|
+
*/
|
|
31
|
+
export declare function computeWeightedInputPricePerMillion(usages: ModelUsage[]): WeightedInputPricing;
|
|
32
|
+
export declare function estimateTokenCostUsd(inputTokens: number, outputTokens: number, model: string | undefined): number;
|
|
33
|
+
export declare function estimateAvoidedContextCostUsd(avoidedInputTokens: number, usages: ModelUsage[]): {
|
|
34
|
+
costUsd: number;
|
|
35
|
+
pricing: WeightedInputPricing;
|
|
36
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-aware input pricing for context cost telemetry.
|
|
3
|
+
* Observed Cursor costs always take precedence; these rates estimate when absent.
|
|
4
|
+
*/
|
|
5
|
+
/** Per-million-token USD rates. Input rates drive avoided-context estimates. */
|
|
6
|
+
export const MODEL_PRICING = {
|
|
7
|
+
'composer-2.5-standard': {
|
|
8
|
+
label: 'Composer 2.5 Standard',
|
|
9
|
+
inputPerMillionUsd: 0.5,
|
|
10
|
+
outputPerMillionUsd: 2.5,
|
|
11
|
+
},
|
|
12
|
+
'composer-2.5-fast': {
|
|
13
|
+
label: 'Composer 2.5 Fast',
|
|
14
|
+
inputPerMillionUsd: 3.0,
|
|
15
|
+
outputPerMillionUsd: 15.0,
|
|
16
|
+
},
|
|
17
|
+
'claude-3-5-sonnet': {
|
|
18
|
+
label: 'Claude 3.5 Sonnet',
|
|
19
|
+
inputPerMillionUsd: 3.0,
|
|
20
|
+
outputPerMillionUsd: 15.0,
|
|
21
|
+
},
|
|
22
|
+
'gpt-4o': {
|
|
23
|
+
label: 'GPT-4o',
|
|
24
|
+
inputPerMillionUsd: 2.5,
|
|
25
|
+
outputPerMillionUsd: 10.0,
|
|
26
|
+
},
|
|
27
|
+
'gpt-4o-mini': {
|
|
28
|
+
label: 'GPT-4o Mini',
|
|
29
|
+
inputPerMillionUsd: 0.15,
|
|
30
|
+
outputPerMillionUsd: 0.6,
|
|
31
|
+
},
|
|
32
|
+
'o1-preview': {
|
|
33
|
+
label: 'o1 Preview',
|
|
34
|
+
inputPerMillionUsd: 15.0,
|
|
35
|
+
outputPerMillionUsd: 60.0,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
/** Conservative fallback when model cannot be resolved (USD per 1M input tokens). */
|
|
39
|
+
export const FALLBACK_INPUT_PRICE_PER_MILLION_USD = 3.0;
|
|
40
|
+
export const FALLBACK_OUTPUT_PRICE_PER_MILLION_USD = 15.0;
|
|
41
|
+
const MODEL_ALIASES = {
|
|
42
|
+
'composer-2.5': 'composer-2.5-standard',
|
|
43
|
+
'composer-25-standard': 'composer-2.5-standard',
|
|
44
|
+
'composer-25-fast': 'composer-2.5-fast',
|
|
45
|
+
'composer-2-5-standard': 'composer-2.5-standard',
|
|
46
|
+
'composer-2-5-fast': 'composer-2.5-fast',
|
|
47
|
+
'composer25standard': 'composer-2.5-standard',
|
|
48
|
+
'composer25fast': 'composer-2.5-fast',
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Normalize model identifiers from Cursor, CLI flags, or imports.
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeModelKey(model) {
|
|
54
|
+
if (!model)
|
|
55
|
+
return 'unknown';
|
|
56
|
+
const lowered = model.trim().toLowerCase();
|
|
57
|
+
const compact = lowered
|
|
58
|
+
.replace(/[_\s]+/g, '-')
|
|
59
|
+
.replace(/[^a-z0-9.-]/g, '')
|
|
60
|
+
.replace(/-+/g, '-');
|
|
61
|
+
if (MODEL_ALIASES[compact]) {
|
|
62
|
+
return MODEL_ALIASES[compact];
|
|
63
|
+
}
|
|
64
|
+
if (compact.includes('composer') && compact.includes('fast')) {
|
|
65
|
+
return 'composer-2.5-fast';
|
|
66
|
+
}
|
|
67
|
+
if (compact.includes('composer')) {
|
|
68
|
+
return 'composer-2.5-standard';
|
|
69
|
+
}
|
|
70
|
+
if (compact.includes('claude') && compact.includes('sonnet')) {
|
|
71
|
+
return 'claude-3-5-sonnet';
|
|
72
|
+
}
|
|
73
|
+
if (compact.includes('gpt-4o-mini') || compact.includes('gpt4omini')) {
|
|
74
|
+
return 'gpt-4o-mini';
|
|
75
|
+
}
|
|
76
|
+
if (compact.includes('gpt-4o') || compact.includes('gpt4o')) {
|
|
77
|
+
return 'gpt-4o';
|
|
78
|
+
}
|
|
79
|
+
if (compact.includes('o1-preview') || compact.includes('o1preview')) {
|
|
80
|
+
return 'o1-preview';
|
|
81
|
+
}
|
|
82
|
+
return compact;
|
|
83
|
+
}
|
|
84
|
+
export function getModelPricing(model) {
|
|
85
|
+
const key = normalizeModelKey(model);
|
|
86
|
+
return MODEL_PRICING[key];
|
|
87
|
+
}
|
|
88
|
+
export function getInputPricePerMillionUsd(model) {
|
|
89
|
+
return getModelPricing(model)?.inputPerMillionUsd ?? FALLBACK_INPUT_PRICE_PER_MILLION_USD;
|
|
90
|
+
}
|
|
91
|
+
export function getOutputPricePerMillionUsd(model) {
|
|
92
|
+
return getModelPricing(model)?.outputPerMillionUsd ?? FALLBACK_OUTPUT_PRICE_PER_MILLION_USD;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Input-token-weighted effective input price across observed model usages.
|
|
96
|
+
*/
|
|
97
|
+
export function computeWeightedInputPricePerMillion(usages) {
|
|
98
|
+
const withInput = usages.filter(u => (u.inputTokens || 0) > 0 && u.model);
|
|
99
|
+
if (withInput.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
inputPricePerMillionUsd: FALLBACK_INPUT_PRICE_PER_MILLION_USD,
|
|
102
|
+
pricingBasis: 'conservative-fallback-unknown-model',
|
|
103
|
+
isEstimated: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
let weightedSum = 0;
|
|
107
|
+
let totalInput = 0;
|
|
108
|
+
const modelsUsed = new Set();
|
|
109
|
+
for (const usage of withInput) {
|
|
110
|
+
const tokens = usage.inputTokens || 0;
|
|
111
|
+
const price = getInputPricePerMillionUsd(usage.model);
|
|
112
|
+
weightedSum += price * tokens;
|
|
113
|
+
totalInput += tokens;
|
|
114
|
+
modelsUsed.add(normalizeModelKey(usage.model));
|
|
115
|
+
}
|
|
116
|
+
const effective = totalInput > 0 ? weightedSum / totalInput : FALLBACK_INPUT_PRICE_PER_MILLION_USD;
|
|
117
|
+
const basis = modelsUsed.size === 1
|
|
118
|
+
? `model:${Array.from(modelsUsed)[0]}`
|
|
119
|
+
: `mixed-model-weighted:${Array.from(modelsUsed).join(',')}`;
|
|
120
|
+
return {
|
|
121
|
+
inputPricePerMillionUsd: effective,
|
|
122
|
+
pricingBasis: basis,
|
|
123
|
+
isEstimated: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function estimateTokenCostUsd(inputTokens, outputTokens, model) {
|
|
127
|
+
const inputPrice = getInputPricePerMillionUsd(model);
|
|
128
|
+
const outputPrice = getOutputPricePerMillionUsd(model);
|
|
129
|
+
const inputCost = (inputTokens / 1_000_000) * inputPrice;
|
|
130
|
+
const outputCost = (outputTokens / 1_000_000) * outputPrice;
|
|
131
|
+
return parseFloat((inputCost + outputCost).toFixed(4));
|
|
132
|
+
}
|
|
133
|
+
export function estimateAvoidedContextCostUsd(avoidedInputTokens, usages) {
|
|
134
|
+
const pricing = computeWeightedInputPricePerMillion(usages);
|
|
135
|
+
const costUsd = parseFloat(((avoidedInputTokens / 1_000_000) * pricing.inputPricePerMillionUsd).toFixed(4));
|
|
136
|
+
return { costUsd, pricing };
|
|
137
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { normalizeModelKey, getInputPricePerMillionUsd, computeWeightedInputPricePerMillion, estimateAvoidedContextCostUsd, estimateTokenCostUsd, FALLBACK_INPUT_PRICE_PER_MILLION_USD, } from './model-pricing.js';
|
|
3
|
+
describe('model-pricing', () => {
|
|
4
|
+
it('resolves Composer 2.5 Standard input pricing', () => {
|
|
5
|
+
expect(getInputPricePerMillionUsd('Composer 2.5 Standard')).toBe(0.5);
|
|
6
|
+
expect(normalizeModelKey('composer-2-5-standard')).toBe('composer-2.5-standard');
|
|
7
|
+
});
|
|
8
|
+
it('resolves Composer 2.5 Fast input pricing', () => {
|
|
9
|
+
expect(getInputPricePerMillionUsd('composer-2.5-fast')).toBe(3.0);
|
|
10
|
+
expect(normalizeModelKey('Composer 2.5 Fast')).toBe('composer-2.5-fast');
|
|
11
|
+
});
|
|
12
|
+
it('normalizes aliases with spaces and hyphens', () => {
|
|
13
|
+
expect(normalizeModelKey('Composer_2.5 FAST')).toBe('composer-2.5-fast');
|
|
14
|
+
expect(normalizeModelKey('composer 25 standard')).toBe('composer-2.5-standard');
|
|
15
|
+
});
|
|
16
|
+
it('uses conservative fallback for unknown models', () => {
|
|
17
|
+
expect(getInputPricePerMillionUsd('totally-unknown-model')).toBe(FALLBACK_INPUT_PRICE_PER_MILLION_USD);
|
|
18
|
+
});
|
|
19
|
+
it('computes mixed-model input-token-weighted pricing', () => {
|
|
20
|
+
const usages = [
|
|
21
|
+
{ source: 'cursor-admin-api', model: 'composer-2.5-standard', inputTokens: 1000, outputTokens: 0 },
|
|
22
|
+
{ source: 'cursor-admin-api', model: 'composer-2.5-fast', inputTokens: 3000, outputTokens: 0 },
|
|
23
|
+
];
|
|
24
|
+
const weighted = computeWeightedInputPricePerMillion(usages);
|
|
25
|
+
expect(weighted.inputPricePerMillionUsd).toBeCloseTo(2.375, 3);
|
|
26
|
+
expect(weighted.pricingBasis).toContain('mixed-model-weighted');
|
|
27
|
+
});
|
|
28
|
+
it('prefers observed cost when estimating actual spend is unnecessary', () => {
|
|
29
|
+
const observed = 4.25;
|
|
30
|
+
expect(observed).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
it('estimates actual token cost when observed cost is absent', () => {
|
|
33
|
+
const estimated = estimateTokenCostUsd(1_000_000, 0, 'composer-2.5-standard');
|
|
34
|
+
expect(estimated).toBe(0.5);
|
|
35
|
+
});
|
|
36
|
+
it('estimates avoided context using input pricing only', () => {
|
|
37
|
+
const usages = [
|
|
38
|
+
{ source: 'cursor-admin-api', model: 'composer-2.5-standard', inputTokens: 100, outputTokens: 0 },
|
|
39
|
+
];
|
|
40
|
+
const { costUsd, pricing } = estimateAvoidedContextCostUsd(2_000_000, usages);
|
|
41
|
+
expect(pricing.inputPricePerMillionUsd).toBe(0.5);
|
|
42
|
+
expect(costUsd).toBe(1.0);
|
|
43
|
+
});
|
|
44
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"description": "AI-native quality gate engine with local Bayesian learning. AST analysis, drift detection, Fix Packet generation, and agent self-healing across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://rigour.run",
|
|
@@ -66,11 +66,11 @@
|
|
|
66
66
|
"@xenova/transformers": "^2.17.2",
|
|
67
67
|
"sqlite3": "^5.1.7",
|
|
68
68
|
"openai": "^4.104.0",
|
|
69
|
-
"@rigour-labs/brain-darwin-arm64": "5.
|
|
70
|
-
"@rigour-labs/brain-
|
|
71
|
-
"@rigour-labs/brain-linux-arm64": "5.
|
|
72
|
-
"@rigour-labs/brain-
|
|
73
|
-
"@rigour-labs/brain-
|
|
69
|
+
"@rigour-labs/brain-darwin-arm64": "5.4.0",
|
|
70
|
+
"@rigour-labs/brain-darwin-x64": "5.4.0",
|
|
71
|
+
"@rigour-labs/brain-linux-arm64": "5.4.0",
|
|
72
|
+
"@rigour-labs/brain-linux-x64": "5.4.0",
|
|
73
|
+
"@rigour-labs/brain-win-x64": "5.4.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@types/fs-extra": "^11.0.4",
|