@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.
@@ -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 (stub — requires API key in settings).
111
- * Returns number of records imported, or 0 if unavailable.
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(cwd?: string): Promise<number>;
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 actualCostUsd = 0;
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
- actualCostUsd += u.observedCostUsd || 0;
89
+ observedCostUsd += u.observedCostUsd || 0;
95
90
  if (u.source && u.source !== 'estimated') {
96
91
  primarySource = u.source;
97
92
  }
98
93
  }
99
- const pricePerM = DEFAULT_MODEL_PRICES['claude-3-5-sonnet'] || 3.0;
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 estimatedCostAvoidedUsd = parseFloat(((totalPotentialAvoided / 1_000_000) * pricePerM).toFixed(2));
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(2)),
112
+ costUsd: parseFloat(actualCostUsd.toFixed(4)),
107
113
  source: primarySource,
108
- isEstimated: !hasObservedUsage,
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
- taskId: (r.taskId || r.task_id || 'cursor-imported'),
281
- sessionId: (r.sessionId || r.session_id),
282
- agentId: (r.agentId || r.agent_id),
283
- provider: (r.provider || 'cursor'),
284
- model: (r.model || 'claude-3-5-sonnet'),
285
- inputTokens: (r.inputTokens || r.input_tokens || 0),
286
- outputTokens: (r.outputTokens || r.output_tokens || 0),
287
- cachedInputTokens: (r.cachedInputTokens || r.cached_input_tokens || 0),
288
- observedCostUsd: (r.observedCostUsd || r.cost_usd || r.cost || 0),
289
- source: 'cursor-admin-api',
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 (stub — requires API key in settings).
297
- * Returns number of records imported, or 0 if unavailable.
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(cwd) {
300
- const apiKey = getCursorApiKey();
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
- // Stub: Cursor Admin API integration pending official endpoint documentation.
305
- // When available, fetch usage records and delegate to importCursorUsageJson.
306
- void apiKey;
307
- void cwd;
308
- return 0;
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
+ });
@@ -23,6 +23,7 @@ export interface ModelUsage {
23
23
  inputTokens: number;
24
24
  outputTokens: number;
25
25
  cachedInputTokens?: number;
26
+ cacheWriteTokens?: number;
26
27
  observedCostUsd?: number;
27
28
  source: string;
28
29
  createdAt?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "5.3.3",
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.3.3",
70
- "@rigour-labs/brain-linux-x64": "5.3.3",
71
- "@rigour-labs/brain-linux-arm64": "5.3.3",
72
- "@rigour-labs/brain-win-x64": "5.3.3",
73
- "@rigour-labs/brain-darwin-x64": "5.3.3"
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",