Merge pull request #576 from ozymandiashh/fix/cursor-real-tokens-review-fixes

fix: review fixes for PR #575 (cache version, model attribution, user msg join, tool classification)
This commit is contained in:
Resham Joshi 2026-06-30 18:01:04 +02:00 committed by GitHub
commit 6c7ec09fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 362 additions and 33 deletions

View file

@ -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'])

View file

@ -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

View file

@ -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<string, SnapshotEntry> = {
'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<string, ModelCosts>): Map<string, ModelCosts> {
for (const [name, raw] of Object.entries(BUILTIN_PRICE_OVERRIDES)) {
pricing.set(name, tupleToCosts(raw))
}
return pricing
}
function loadSnapshot(): Map<string, ModelCosts> {
const map = new Map<string, ModelCosts>()
for (const [name, raw] of Object.entries(snapshotData as unknown as Record<string, SnapshotEntry>)) {
@ -85,7 +104,7 @@ const fallbackCosts: Map<string, ModelCosts> = (() => {
return map
})()
let pricingCache: Map<string, ModelCosts> = loadSnapshot()
let pricingCache: Map<string, ModelCosts> = applyBuiltinPriceOverrides(loadSnapshot())
let sortedPricingKeys: string[] | null = null
let lowercasePricingIndex: Map<string, ModelCosts> | null = null
@ -202,7 +221,7 @@ function mergeSnapshotFallbacks(pricing: Map<string, ModelCosts>): Map<string, M
for (const [name, costs] of loadSnapshot()) {
if (!pricing.has(name)) pricing.set(name, costs)
}
return pricing
return applyBuiltinPriceOverrides(pricing)
}
export async function loadPricing(): Promise<void> {
@ -303,13 +322,9 @@ const BUILTIN_ALIASES: Record<string, string> = {
'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',

View file

@ -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<string,
try {
const rows = db.query<UserMsgRow>(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<string>()
// 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<string, string>()
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<string>()
// 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)

View file

@ -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

View file

@ -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<typeof createCursorProvider>, dbPath: string): Promise<ParsedProviderCall[]> {
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')
})
})