mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
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:
commit
6c7ec09fa2
6 changed files with 362 additions and 33 deletions
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
262
tests/providers/cursor-real-tokens.test.ts
Normal file
262
tests/providers/cursor-real-tokens.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue