diff --git a/src/classifier.ts b/src/classifier.ts index ca7854b..a72612c 100644 --- a/src/classifier.ts +++ b/src/classifier.ts @@ -17,7 +17,7 @@ const URL_PATTERN = /https?:\/\/\S+/i export const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit']) const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool']) -export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool']) +export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool', 'Shell']) const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite']) const SEARCH_TOOLS = new Set(['WebSearch', 'WebFetch', 'ToolSearch']) diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index 7d84d26..47f2ebb 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -11,7 +11,11 @@ import type { ParsedProviderCall } from './providers/types.js' // router relies on those composer ids to bucket calls per project. // Version 2 caches contain `sessionId: 'unknown'` for every call and would // route everything to the orphan project, so we invalidate them. -const CURSOR_CACHE_VERSION = 4 +// Version 5: parseAgentKv was removed (it double-counted against bubbles); +// real context tokens from composerData.promptTokenBreakdown now drive +// input, and agentKv is used only for the tools/bash breakdown. Cached v4 +// results contain stale agentKv calls and lack the real token figures. +const CURSOR_CACHE_VERSION = 5 type ResultCache = { version?: number diff --git a/src/models.ts b/src/models.ts index 01c6004..9de6d16 100644 --- a/src/models.ts +++ b/src/models.ts @@ -39,6 +39,18 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 const WEB_SEARCH_COST = 0.01 const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6 +// Explicit USD/token prices that must override LiteLLM/cache data. Cursor +// publishes house-model rates under provider "Cursor" in USD per 1M tokens: +// composer-2/2.5: $0.50 input, $2.50 output, $0.20 cache read; composer-1.5: +// $3.50/$17.50/$0.35; composer-1: $1.25/$10/$0.125. Cursor publishes no +// separate cache-write rate for these, so cache write uses the input rate. +const BUILTIN_PRICE_OVERRIDES: Record = { + 'composer-2.5': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6], + 'composer-2': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6], + 'composer-1.5': [3.5e-6, 17.5e-6, 3.5e-6, 0.35e-6], + 'composer-1': [1.25e-6, 10e-6, 1.25e-6, 0.125e-6], +} + // Assemble a ModelCosts, applying the cache-cost heuristics (write = 1.25x // input, read = 0.1x input) when a source omits them. Shared by the bundled // tuple path (tupleToCosts) and the live LiteLLM path (parseLiteLLMEntry) so the @@ -65,6 +77,13 @@ function tupleToCosts(raw: SnapshotEntry): ModelCosts { return buildCosts(input, output, cacheWrite, cacheRead, fast) } +function applyBuiltinPriceOverrides(pricing: Map): Map { + for (const [name, raw] of Object.entries(BUILTIN_PRICE_OVERRIDES)) { + pricing.set(name, tupleToCosts(raw)) + } + return pricing +} + function loadSnapshot(): Map { const map = new Map() for (const [name, raw] of Object.entries(snapshotData as unknown as Record)) { @@ -85,7 +104,7 @@ const fallbackCosts: Map = (() => { return map })() -let pricingCache: Map = loadSnapshot() +let pricingCache: Map = applyBuiltinPriceOverrides(loadSnapshot()) let sortedPricingKeys: string[] | null = null let lowercasePricingIndex: Map | null = null @@ -202,7 +221,7 @@ function mergeSnapshotFallbacks(pricing: Map): Map { @@ -303,13 +322,9 @@ const BUILTIN_ALIASES: Record = { 'claude-opus-4-7-thinking-high': 'claude-opus-4-7', 'claude-4.5-haiku': 'claude-haiku-4-5', 'claude-4.6-haiku': 'claude-haiku-4-5', - // Cursor's house models have no LiteLLM pricing entry. composer-1 is - // sonnet-4.5-class per Cursor docs; composer-2 and composer-2.5 are built on - // Sonnet 4.6 per cursor.com/blog/composer-2. - 'composer-1': 'claude-sonnet-4-5', - 'composer-1.5': 'claude-sonnet-4-5', - 'composer-2': 'claude-sonnet-4-6', - 'composer-2.5': 'claude-sonnet-4-6', + // Cursor house composer models use Cursor-published rates in + // BUILTIN_PRICE_OVERRIDES; keep them out of this alias map so they do not + // inherit Claude Sonnet proxy pricing. // Cursor's "fast" routing variant of GPT-5 is the same model behind a // lower-latency endpoint; price as base GPT-5 until LiteLLM tracks it. 'gpt-5-fast': 'gpt-5', diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index d9b5d36..3d3ae54 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -322,7 +322,7 @@ const AGENTKV_QUERY = ` const USER_MESSAGES_QUERY = ` SELECT - json_extract(value, '$.conversationId') as conversation_id, + key as bubble_key, json_extract(value, '$.createdAt') as created_at, CAST(substr(json_extract(value, '$.text'), 1, 500) AS BLOB) as text FROM cursorDiskKV @@ -378,7 +378,7 @@ function validateSchema(db: SqliteDatabase): boolean { } } -type UserMsgRow = { conversation_id: string; created_at: string; text: Uint8Array | string } +type UserMsgRow = { bubble_key: string; created_at: string; text: Uint8Array | string } /// Per-conversation user-message buffer. We pop messages in arrival order via /// the `pos` cursor — a previous implementation called Array.shift() which is @@ -394,13 +394,16 @@ function buildUserMessageMap(db: SqliteDatabase, timeFloor: string): Map(USER_MESSAGES_QUERY, [timeFloor]) for (const row of rows) { - if (!row.conversation_id || !row.text) continue + // Extract the composerId from the bubble key, matching parseBubbles(). + // The JSON `conversationId` field is empty in current Cursor builds. + const composerId = parseComposerIdFromKey(row.bubble_key) + if (!composerId || !row.text) continue const text = blobToText(row.text) - const existing = map.get(row.conversation_id) + const existing = map.get(composerId) if (existing) { existing.messages.push(text) } else { - map.set(row.conversation_id, { messages: [text], pos: 0 }) + map.set(composerId, { messages: [text], pos: 0 }) } } } catch {} @@ -459,9 +462,12 @@ function scanBubblesPaged( return { rows: collected, truncated } } -// Cursor leaves the per-bubble tokenCount at {0,0} on current builds; the only -// real input figure on disk is the conversation's context size, which Cursor -// records in composerData.promptTokenBreakdown (the in-app context meter). +// Cursor leaves the per-bubble tokenCount at {0,0} on current builds. The only +// real input figure on disk is the latest context-window snapshot, which Cursor +// records in composerData.promptTokenBreakdown.totalUsedTokens or +// contextTokensUsed (the in-app context meter). This is not cumulative per-turn, +// so local SQLite undercounts admin-console usage; parity requires the opt-in +// Cursor Admin API: POST api.cursor.com/teams/filtered-usage-events. // Keyed by composerId so parseBubbles can credit it to the right conversation. const COMPOSER_TOKENS_QUERY = ` SELECT @@ -560,6 +566,22 @@ function parseBubbles( // multi-turn chat does not multiply the snapshot across every bubble. const creditedComposers = new Set() + // Build a composerId -> model map from assistant bubbles. User bubbles + // (type=1) carry no modelInfo, so when we credit real input tokens onto a + // user bubble we need the conversation's actual model for pricing. + const composerModel = new Map() + try { + const modelRows = db.query<{ bubble_key: string; model: string | null }>(` + SELECT key as bubble_key, json_extract(value, '$.modelInfo.modelName') as model + FROM cursorDiskKV + WHERE key LIKE 'bubbleId:%' AND json_extract(value, '$.modelInfo.modelName') IS NOT NULL + `) + for (const r of modelRows) { + const cid = parseComposerIdFromKey(r.bubble_key) + if (cid && r.model && !composerModel.has(cid)) composerModel.set(cid, r.model) + } + } catch { /* best-effort */ } + // The bubble timestamp lives inside the JSON value (no index), so the date // filter forces a full JSON decode per row. Multi-GB Cursor DBs (500k+ // bubbles) were producing 30s+ parse stalls, so the scan is bounded. The old @@ -624,10 +646,14 @@ function parseBubbles( // real input (its first turn), so they are counted exactly once. let creditedHere = false - // Current Cursor leaves tokenCount at {0,0}. Use the conversation's real - // context size (promptTokenBreakdown) for input, credited once per - // conversation, and the reply text for output. Fall back to the - // visible-text estimate only when no breakdown was recorded (older builds). + // Current Cursor leaves tokenCount at {0,0}. Use the latest local + // context-window snapshot for input, credited once per conversation; it is + // not cumulative per-turn, so it undercounts Cursor Admin console totals. + // Output is a reply-text estimate, and cache tokens are server-side only + // (0 on disk). Admin-console parity requires POST + // api.cursor.com/teams/filtered-usage-events. + // Fall back to the visible-text estimate only when no breakdown was + // recorded (older builds). if (inputTokens === 0 && outputTokens === 0) { const textLen = row.text_length ?? 0 if (row.bubble_type === 1) { @@ -658,8 +684,12 @@ function parseBubbles( if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) - const pricingModel = resolveModel(row.model) - const displayModel = modelForDisplay(row.model) + // User bubbles (type=1) carry no modelInfo, so when real input tokens + // are credited onto them, fall back to the conversation's model (found + // on the assistant bubble) for pricing and display. + const effectiveModel = row.model ?? composerModel.get(conversationId) ?? null + const pricingModel = resolveModel(effectiveModel) + const displayModel = modelForDisplay(effectiveModel) const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0) @@ -774,10 +804,11 @@ function createParser( // seenKeys is not mutated by calls that the workspace filter is // about to drop. Cross-source dedup happens at yield time. const localSeen = new Set() - // promptTokenBreakdown carries Cursor's real per-conversation input - // count, so it supersedes the old agentKv content-char estimate, - // which double-counted against the bubble stream. parseAgentKv is - // kept for the tools/bash breakdown in a follow-up. + // Real per-conversation input tokens from + // composerData.promptTokenBreakdown supersedes the old agentKv + // content-char estimate, which double-counted against the bubble + // stream. agentKv is now used only for the tools/bash breakdown + // via loadAgentToolsByComposer(). const composerInput = loadComposerInputTokens(db) const agentTools = loadAgentToolsByComposer(db) const { calls: bubbleCalls } = parseBubbles(db, localSeen, timeFloor, composerInput, agentTools) diff --git a/tests/models.test.ts b/tests/models.test.ts index 8621ab3..967d766 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -455,10 +455,7 @@ describe('Cursor model variants resolve to pricing', () => { // Haiku family ['claude-4.5-haiku', 'claude-haiku-4-5'], ['claude-4.6-haiku', 'claude-haiku-4-5'], - // Cursor house models - ['composer-1', 'claude-sonnet-4-5'], - ['composer-1.5', 'claude-sonnet-4-5'], - ['composer-2', 'claude-sonnet-4-6'], + // Cursor auto proxy ['cursor-auto', 'claude-sonnet-4-5'], // OpenAI variants Cursor emits ['gpt-5', 'gpt-5'], @@ -487,6 +484,26 @@ describe('Cursor model variants resolve to pricing', () => { } }) +describe('Cursor house model pricing', () => { + const cases: Array<[string, { input: number; output: number; cacheWrite: number; cacheRead: number }]> = [ + ['composer-2.5', { input: 0.5, output: 2.5, cacheWrite: 0.5, cacheRead: 0.2 }], + ['composer-2', { input: 0.5, output: 2.5, cacheWrite: 0.5, cacheRead: 0.2 }], + ['composer-1.5', { input: 3.5, output: 17.5, cacheWrite: 3.5, cacheRead: 0.35 }], + ['composer-1', { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 }], + ] + + for (const [model, rates] of cases) { + it(`${model} uses Cursor-published rates instead of Claude Sonnet proxy pricing`, () => { + const costs = getModelCosts(model) + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBeCloseTo(rates.input * 1e-6, 12) + expect(costs!.outputCostPerToken).toBeCloseTo(rates.output * 1e-6, 12) + expect(costs!.cacheWriteCostPerToken).toBeCloseTo(rates.cacheWrite * 1e-6, 12) + expect(costs!.cacheReadCostPerToken).toBeCloseTo(rates.cacheRead * 1e-6, 12) + }) + } +}) + // Regression: LiteLLM ships `snowflake/claude-4-opus` ($5/M, a gateway rate), // which the bundler strips to a bare `claude-4-opus` snapshot key. Without the // alias-precedence guard in getModelCosts, that bare reseller key shadows the diff --git a/tests/providers/cursor-real-tokens.test.ts b/tests/providers/cursor-real-tokens.test.ts new file mode 100644 index 0000000..f2e9bac --- /dev/null +++ b/tests/providers/cursor-real-tokens.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' + +import { + createCursorProvider, + clearCursorWorkspaceMapCache, +} from '../../src/providers/cursor.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +const skipReason = isSqliteAvailable() + ? null + : 'node:sqlite not available — needs Node 22+; skipping' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cursor-tokens-test-')) + clearCursorWorkspaceMapCache() +}) + +afterEach(async () => { + clearCursorWorkspaceMapCache() + await rm(tmpDir, { recursive: true, force: true }) +}) + +function buildDb(fn: (db: { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +}) => void): string { + const dbPath = join(tmpDir, 'state.vscdb') + writeFile(dbPath, '') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)') + db.exec('CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)') + fn(db) + db.close() + return dbPath +} + +function insertBubble(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + composerId: string + bubbleUuid: string + type: 1 | 2 + text: string + model?: string + inputTokens?: number + outputTokens?: number + createdAt?: string + requestId?: string + codeBlocks?: string +}): void { + const key = `bubbleId:${opts.composerId}:${opts.bubbleUuid}` + const value = JSON.stringify({ + type: opts.type, + conversationId: '', + createdAt: opts.createdAt ?? new Date().toISOString(), + tokenCount: { + inputTokens: opts.inputTokens ?? 0, + outputTokens: opts.outputTokens ?? 0, + }, + modelInfo: opts.model ? { modelName: opts.model } : undefined, + text: opts.text, + codeBlocks: opts.codeBlocks ?? '[]', + requestId: opts.requestId, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +function insertComposerData(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + composerId: string + totalUsedTokens?: number | null + contextTokensUsed?: number | null +}): void { + const key = `composerData:${opts.composerId}` + const breakdown = opts.totalUsedTokens !== undefined + ? { totalUsedTokens: opts.totalUsedTokens } + : {} + const value = JSON.stringify({ + promptTokenBreakdown: breakdown, + contextTokensUsed: opts.contextTokensUsed ?? undefined, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +function insertAgentKv(db: { + prepare(sql: string): { run(...params: unknown[]): void } +}, opts: { + blobId: string + role: string + content: unknown + requestId?: string +}): void { + const key = `agentKv:blob:${opts.blobId}` + const value = JSON.stringify({ + role: opts.role, + content: opts.content, + providerOptions: opts.requestId + ? { cursor: { requestId: opts.requestId } } + : undefined, + }) + db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value) +} + +async function collectCalls(provider: ReturnType, dbPath: string): Promise { + const source = { path: dbPath, project: 'test', provider: 'cursor' as const } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe.skipIf(skipReason !== null)('cursor real context tokens (#575)', () => { + it('credits composerData.promptTokenBreakdown.totalUsedTokens as input', async () => { + const composerId = 'aaaa1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 50000 }) + insertBubble(db, { + composerId, bubbleUuid: 'b1', type: 1, text: 'user prompt', + inputTokens: 0, outputTokens: 0, + }) + insertBubble(db, { + composerId, bubbleUuid: 'b2', type: 2, text: 'assistant reply', + model: 'claude-4.6-sonnet', inputTokens: 0, outputTokens: 0, + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + // User bubble gets the real context; assistant gets text estimate. + const userCall = calls.find(c => c.inputTokens === 50000) + expect(userCall).toBeDefined() + expect(userCall!.inputTokens).toBe(50000) + }) + + it('credits real input tokens once per conversation, not per bubble', async () => { + const composerId = 'bbbb1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 30000 }) + // Multiple user bubbles in the same conversation + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'turn 1' }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply 1', model: 'gpt-5' }) + insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'turn 2' }) + insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'reply 2', model: 'gpt-5' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + // Exactly one call should have 30000 input tokens + const credited = calls.filter(c => c.inputTokens === 30000) + expect(credited.length).toBe(1) + }) + + it('falls back to text estimation when no composerData exists', async () => { + const composerId = 'cccc1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + // No composerData row + insertBubble(db, { + composerId, bubbleUuid: 'b1', type: 1, text: 'hello world this is a test', + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const userCall = calls.find(c => c.inputTokens > 0) + expect(userCall).toBeDefined() + // text length 25 / 4 = 7 tokens + expect(userCall!.inputTokens).toBe(Math.ceil('hello world this is a test'.length / 4)) + }) + + it('uses contextTokensUsed when totalUsedTokens is null', async () => { + const composerId = 'dddd1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: null, contextTokensUsed: 42000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply', model: 'gpt-5' }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const credited = calls.find(c => c.inputTokens === 42000) + expect(credited).toBeDefined() + }) + + it('attributes aggregated agentKv tools once in a multi-bubble conversation', async () => { + const composerId = 'eeee1111-2222-3333-4444-555566667777' + const requestId = 'req-001' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 10000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'do stuff', requestId }) + insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'doing stuff', model: 'gpt-5' }) + insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'do more stuff', requestId: 'req-002' }) + insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'doing more stuff', model: 'gpt-5' }) + // agentKv with tool calls + insertAgentKv(db, { + blobId: 'akv-1', role: 'user', + content: [{ type: 'text', text: 'do stuff' }], + requestId, + }) + insertAgentKv(db, { + blobId: 'akv-2', role: 'assistant', + content: [ + { type: 'tool-call', toolName: 'Read', args: {} }, + { type: 'tool-call', toolName: 'Shell', args: { command: 'npm test' } }, + ], + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const callWithTools = calls.find(c => c.tools.length > 0) + expect(callWithTools).toBeDefined() + expect(callWithTools!.tools).toContain('Read') + expect(callWithTools!.tools).toContain('Shell') + expect(callWithTools!.bashCommands).toContain('npm test') + + const allTools = calls.flatMap(c => c.tools) + const allBashCommands = calls.flatMap(c => c.bashCommands) + expect(allTools.filter(t => t === 'Read').length).toBe(1) + expect(allTools.filter(t => t === 'Shell').length).toBe(1) + expect(allBashCommands.filter(cmd => cmd === 'npm test').length).toBe(1) + }) + + it('uses conversation model for pricing when input is on a user bubble', async () => { + const composerId = 'ffff1111-2222-3333-4444-555566667777' + const dbPath = buildDb((db) => { + insertComposerData(db, { composerId, totalUsedTokens: 100000 }) + insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' }) + insertBubble(db, { + composerId, bubbleUuid: 'b2', type: 2, text: 'reply', + model: 'claude-4.5-opus-high-thinking', + }) + }) + + const provider = createCursorProvider(dbPath) + const calls = await collectCalls(provider, dbPath) + + const creditedCall = calls.find(c => c.inputTokens === 100000) + expect(creditedCall).toBeDefined() + // Should NOT be cursor-auto (the fallback for user bubbles without model) + expect(creditedCall!.model).not.toBe('cursor-auto') + // Should be the conversation's actual model + expect(creditedCall!.model).toBe('claude-4.5-opus-high-thinking') + }) +})