@rigour-labs/core 5.3.3 → 5.5.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/context/cache-engine.d.ts +25 -0
- package/dist/context/cache-engine.js +79 -1
- package/dist/context/cache-engine.test.js +37 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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/settings.d.ts +15 -2
- package/dist/settings.js +56 -6
- package/dist/storage/context-telemetry.d.ts +9 -0
- package/dist/storage/context-telemetry.js +61 -0
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/package.json +6 -6
|
@@ -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/dist/settings.d.ts
CHANGED
|
@@ -106,10 +106,23 @@ export declare function updateProviderKey(provider: string, apiKey: string): voi
|
|
|
106
106
|
*/
|
|
107
107
|
export declare function removeProviderKey(provider: string): void;
|
|
108
108
|
/**
|
|
109
|
-
* Get Cursor Admin API key
|
|
109
|
+
* Get Cursor Admin API key — prefer env over file-stored secret.
|
|
110
|
+
* Env: RIGOUR_CURSOR_API_KEY or CURSOR_ADMIN_API_KEY
|
|
110
111
|
*/
|
|
111
112
|
export declare function getCursorApiKey(): string | undefined;
|
|
112
113
|
/**
|
|
113
|
-
*
|
|
114
|
+
* Whether a Cursor vendor adapter key is available (env or file).
|
|
115
|
+
*/
|
|
116
|
+
export declare function isCursorApiKeyConfigured(): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Last-4 hint for UI status — never return the full key.
|
|
119
|
+
*/
|
|
120
|
+
export declare function getCursorApiKeyHint(): string | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* Persist Cursor Admin API key to settings (optional; prefer env in production).
|
|
114
123
|
*/
|
|
115
124
|
export declare function updateCursorApiKey(apiKey: string): void;
|
|
125
|
+
/**
|
|
126
|
+
* Remove file-stored Cursor Admin API key (env vars are untouched).
|
|
127
|
+
*/
|
|
128
|
+
export declare function removeCursorApiKey(): void;
|
package/dist/settings.js
CHANGED
|
@@ -45,10 +45,22 @@ export function saveSettings(settings) {
|
|
|
45
45
|
const settingsPath = getSettingsPath();
|
|
46
46
|
const settingsDir = path.dirname(settingsPath);
|
|
47
47
|
try {
|
|
48
|
-
// Ensure directory exists
|
|
48
|
+
// Ensure directory exists with owner-only access
|
|
49
49
|
fs.ensureDirSync(settingsDir);
|
|
50
|
+
try {
|
|
51
|
+
fs.chmodSync(settingsDir, 0o700);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// ignore chmod failures on platforms that do not support it
|
|
55
|
+
}
|
|
50
56
|
// Write with pretty formatting (2-space indent)
|
|
51
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
57
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
58
|
+
try {
|
|
59
|
+
fs.chmodSync(settingsPath, 0o600);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore chmod failures on platforms that do not support it
|
|
63
|
+
}
|
|
52
64
|
Logger.debug(`Settings saved to ${settingsPath}`);
|
|
53
65
|
}
|
|
54
66
|
catch (error) {
|
|
@@ -185,20 +197,58 @@ export function removeProviderKey(provider) {
|
|
|
185
197
|
}
|
|
186
198
|
}
|
|
187
199
|
/**
|
|
188
|
-
* Get Cursor Admin API key
|
|
200
|
+
* Get Cursor Admin API key — prefer env over file-stored secret.
|
|
201
|
+
* Env: RIGOUR_CURSOR_API_KEY or CURSOR_ADMIN_API_KEY
|
|
189
202
|
*/
|
|
190
203
|
export function getCursorApiKey() {
|
|
204
|
+
const fromEnv = process.env.RIGOUR_CURSOR_API_KEY?.trim() ||
|
|
205
|
+
process.env.CURSOR_ADMIN_API_KEY?.trim();
|
|
206
|
+
if (fromEnv)
|
|
207
|
+
return fromEnv;
|
|
191
208
|
const settings = loadSettings();
|
|
192
|
-
|
|
209
|
+
const fromFile = settings.cursor?.apiKey?.trim();
|
|
210
|
+
return fromFile || undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Whether a Cursor vendor adapter key is available (env or file).
|
|
214
|
+
*/
|
|
215
|
+
export function isCursorApiKeyConfigured() {
|
|
216
|
+
return Boolean(getCursorApiKey());
|
|
193
217
|
}
|
|
194
218
|
/**
|
|
195
|
-
*
|
|
219
|
+
* Last-4 hint for UI status — never return the full key.
|
|
220
|
+
*/
|
|
221
|
+
export function getCursorApiKeyHint() {
|
|
222
|
+
const key = getCursorApiKey();
|
|
223
|
+
if (!key || key.length < 4)
|
|
224
|
+
return undefined;
|
|
225
|
+
return `••••${key.slice(-4)}`;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Persist Cursor Admin API key to settings (optional; prefer env in production).
|
|
196
229
|
*/
|
|
197
230
|
export function updateCursorApiKey(apiKey) {
|
|
231
|
+
const trimmed = apiKey.trim();
|
|
232
|
+
if (!trimmed || trimmed.length > 512) {
|
|
233
|
+
throw new Error('Invalid Cursor Admin API key');
|
|
234
|
+
}
|
|
198
235
|
const settings = loadSettings();
|
|
199
236
|
if (!settings.cursor) {
|
|
200
237
|
settings.cursor = {};
|
|
201
238
|
}
|
|
202
|
-
settings.cursor.apiKey =
|
|
239
|
+
settings.cursor.apiKey = trimmed;
|
|
203
240
|
saveSettings(settings);
|
|
204
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Remove file-stored Cursor Admin API key (env vars are untouched).
|
|
244
|
+
*/
|
|
245
|
+
export function removeCursorApiKey() {
|
|
246
|
+
const settings = loadSettings();
|
|
247
|
+
if (settings.cursor?.apiKey) {
|
|
248
|
+
delete settings.cursor.apiKey;
|
|
249
|
+
if (Object.keys(settings.cursor).length === 0) {
|
|
250
|
+
delete settings.cursor;
|
|
251
|
+
}
|
|
252
|
+
saveSettings(settings);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -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;
|
|
@@ -61,6 +62,14 @@ export declare function recordModelUsage(usage: ModelUsage, cwd?: string): Promi
|
|
|
61
62
|
* Insert or update cache entry.
|
|
62
63
|
*/
|
|
63
64
|
export declare function setContextCacheRecord(record: ContextCacheRecord, cwd?: string): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* List cache records by type (and optional commit). Does not increment hit counts.
|
|
67
|
+
*/
|
|
68
|
+
export declare function listContextCacheRecords(opts: {
|
|
69
|
+
cacheType?: ContextCacheRecord['cacheType'];
|
|
70
|
+
commitSha?: string;
|
|
71
|
+
limit?: number;
|
|
72
|
+
}, cwd?: string): Promise<ContextCacheRecord[]>;
|
|
64
73
|
/**
|
|
65
74
|
* Fetch cached context record by cache key. Increments hit count.
|
|
66
75
|
*/
|