Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cursor++/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to the Cursor++ BYOK extension are documented here.

Format follows [Keep a Changelog](http://keepachangelog.com/).

## [Unreleased]

### Added

- BYOK usage dashboard: four-bucket token accounting, per-model prices, CNY/USD display,
and provider/model filters in the Cursor++ sidebar. Tool rounds and auto-summarize are recorded.
Unchecking every provider shows zero instead of falling back to all; rechecking a provider
includes all of its models. Costs use live model prices, not the cached provider snapshot.

## [0.0.7]

### Added
Expand Down
4 changes: 4 additions & 0 deletions Cursor++/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
"command": "cursor2plus.openSettings",
"title": "Cursor++: Open Settings"
},
{
"command": "cursor2plus.openUsage",
"title": "Cursor++: Open Usage Panel"
},
{
"command": "cursor2plus.toggleFileLog",
"title": "Cursor++: Toggle File Logging (debug)"
Expand Down
34 changes: 29 additions & 5 deletions Cursor++/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import { isLikelyWindowsMsvcMissing, preflightSupermarkdown, setSupermarkdownNat
import { resetProviderInstanceCache } from './server/handlers/llm/providerRuntime'
import { initLogger } from './server/logger'
import { getRoutesFilePath } from './server/routes'
import { ensureUsageSettingsFile, onUsageSettingsChange, startUsageSettingsWatcher, stopUsageSettingsWatcher } from './server/usage/settings'
import { pruneOldUsageLogs } from './server/usage/store'
import { PanelProvider } from './ui/panel-provider'
import { getState, onStateChange, probeByokServer, refreshState, setFileLogState } from './ui/state'
import { getUsageSuffix, getUsageTooltipLine, initUsageStatusBar, refreshUsageStatusBar } from './ui/usage-statusbar'
import { startUpdateCheck, stopUpdateCheck } from './update-check'

let outputChannel: vscode.LogOutputChannel
Expand Down Expand Up @@ -356,8 +359,8 @@ function renderStatusBar() {
? 'BYOK ON — using local providers.json'
: 'BYOK OFF — passing through to official Cursor'

statusBarItem.text = `${serverIcon} BYOK ${byokGlyph}`
statusBarItem.tooltip = `${serverTip}\n${byokTip}\n\nClick: toggle BYOK Mode`
statusBarItem.text = `${serverIcon} BYOK ${byokGlyph}${getUsageSuffix()}`
statusBarItem.tooltip = `${serverTip}\n${byokTip}${getUsageTooltipLine() ? `\n${getUsageTooltipLine()}` : ''}\n\nClick: toggle BYOK Mode`
statusBarItem.backgroundColor = s.byokMode
? undefined
: new vscode.ThemeColor('statusBarItem.warningBackground')
Expand Down Expand Up @@ -461,12 +464,20 @@ export async function activate(context: vscode.ExtensionContext) {

// 状态栏 (BYOK Mode 切换按钮)
statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100)
statusBarItem.name = 'Cursor++: BYOK'
statusBarItem.command = 'cursor2plus.toggleByok'
statusBarItem.show()
context.subscriptions.push(statusBarItem)

// 状态变化 → 刷新状态栏
context.subscriptions.push(onStateChange(() => renderStatusBar()))
// 用量后缀挂在 BYOK 状态栏项上 (今日费用, 点击项仍是 BYOK 开关)
initUsageStatusBar(renderStatusBar)

// 状态变化 → 刷新状态栏; server 就绪时同步刷新今日费用后缀
context.subscriptions.push(onStateChange(() => {
renderStatusBar()
if (getState().server === 'local')
refreshUsageStatusBar()
}))

// 侧边栏面板
const panelProvider = new PanelProvider(context)
Expand Down Expand Up @@ -501,17 +512,25 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('cursor2plus.openSettings', () => {
vscode.commands.executeCommand('cursor2plus.panel.focus')
}),
vscode.commands.registerCommand('cursor2plus.openUsage', () => {
void vscode.commands.executeCommand('cursor2plus.panel.focus')
panelProvider.revealUsage()
}),
vscode.commands.registerCommand('cursor2plus.toggleFileLog', () => toggleFileLog(context)),
vscode.commands.registerCommand('cursor2plus.openLogFile', () => openLogFile()),
)

// 确保配置文件存在 —— 即使 server 未启动,面板也能读写
await ensureRoutesFile()
await ensureProvidersFile()
ensureUsageSettingsFile()
// 清理超过保留期的用量明细, 防止 usage_logs 无限膨胀
void pruneOldUsageLogs()

// 文件监听: 其他实例修改配置时自动同步状态 + UI
startRoutesWatcher()
startProvidersWatcher()
startUsageSettingsWatcher()
const disposeRoutesWatch = onRoutesChange(async () => {
await refreshState()
renderStatusBar()
Expand All @@ -522,7 +541,11 @@ export async function activate(context: vscode.ExtensionContext) {
await refreshState()
bumpRefreshSignal()
})
context.subscriptions.push({ dispose: disposeRoutesWatch }, { dispose: disposeProvidersWatch })
const disposeUsageWatch = onUsageSettingsChange(async () => {
await refreshState()
refreshUsageStatusBar()
})
context.subscriptions.push({ dispose: disposeRoutesWatch }, { dispose: disposeProvidersWatch }, { dispose: disposeUsageWatch })

// 初始化状态
await refreshState()
Expand Down Expand Up @@ -568,6 +591,7 @@ export async function deactivate() {
closeLogFileStream()
stopRoutesWatcher()
stopProvidersWatcher()
stopUsageSettingsWatcher()
await stopServer()
if (outputChannel)
outputChannel.dispose()
Expand Down
6 changes: 5 additions & 1 deletion Cursor++/src/server/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import { homedir } from 'node:os'
import { join } from 'node:path'
import { CCURSOR_DIR_NAME, DB_FILE_NAME, KNOWLEDGE_BASE_FILE_NAME, MANAGED_SKILLS_FILE_NAME, MODELS_CATALOG_FILE_NAME, PROVIDERS_FILE_NAME, ROUTES_FILE_NAME, WEB_TOOLS_FILE_NAME } from '../data/defaults'
import { CCURSOR_DIR_NAME, DB_FILE_NAME, KNOWLEDGE_BASE_FILE_NAME, MANAGED_SKILLS_FILE_NAME, MODELS_CATALOG_FILE_NAME, PROVIDERS_FILE_NAME, ROUTES_FILE_NAME, USAGE_SETTINGS_FILE_NAME, WEB_TOOLS_FILE_NAME } from '../data/defaults'

export function getCcursorDir(): string {
return join(homedir(), CCURSOR_DIR_NAME)
Expand All @@ -23,6 +23,10 @@ export function getDatabaseFilePath(): string {
return join(getCcursorDir(), DB_FILE_NAME)
}

export function getUsageSettingsFilePath(): string {
return join(getCcursorDir(), USAGE_SETTINGS_FILE_NAME)
}

/**
* KnowledgeBase items 持久化路径.
* 对应 Cursor 设置页里的 "User Rules"(客户端 knowledgeBaseService.items)。
Expand Down
1 change: 1 addition & 0 deletions Cursor++/src/server/config/providersStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function withFallback(loaded: Partial<ProvidersConfig> | null): ProvidersConfig
return {
$schemaVersion: loaded.$schemaVersion ?? DEFAULT_PROVIDERS.$schemaVersion,
providers: loaded.providers.map(p => ({
...p,
id: p.id,
name: p.name ?? p.id,
type: p.type,
Expand Down
31 changes: 31 additions & 0 deletions Cursor++/src/server/data/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const CCURSOR_DIR_NAME = '.ccursor'
export const ROUTES_FILE_NAME = 'routes.json'
export const PROVIDERS_FILE_NAME = 'providers.json'
export const DB_FILE_NAME = 'cursor.db'
export const USAGE_SETTINGS_FILE_NAME = 'usage-settings.json'
export const KNOWLEDGE_BASE_FILE_NAME = 'knowledge-base.json'

export const DEFAULT_HOST = '127.0.0.1'
Expand Down Expand Up @@ -173,6 +174,16 @@ export interface ProviderModel {
noMaxTokens?: boolean
supportsSandboxing?: boolean
defaultOn?: boolean
/** 输入单价,当前货币 / 百万 token */
inputCostPerMillion?: string
/** 输出单价,当前货币 / 百万 token */
outputCostPerMillion?: string
/** 缓存命中单价,当前货币 / 百万 token */
cacheReadCostPerMillion?: string
/** 缓存写入单价,当前货币 / 百万 token */
cacheCreationCostPerMillion?: string
/** 成本乘数,默认 1 */
costMultiplier?: string
/** Fast 模式 — OpenAI: service_tier=priority / Anthropic: fast-mode beta */
fastMode?: boolean
/** 模型选择器里 hover 显示的 markdown tooltip (非 max mode) */
Expand Down Expand Up @@ -266,6 +277,26 @@ export const DEFAULT_PROVIDERS: ProvidersConfig = {
providers: [],
}

export interface UsageSettingsConfig {
$schemaVersion: number
currency: 'CNY' | 'USD'
range: 'today' | '7d' | '14d' | '30d' | 'month'
filterCustomized?: boolean
selectedProviderIds: string[]
selectedModelKeys: string[]
statusBarScope?: 'today' | 'month'
}

export const DEFAULT_USAGE_SETTINGS: UsageSettingsConfig = {
$schemaVersion: 1,
currency: 'CNY',
range: 'today',
filterCustomized: false,
selectedProviderIds: [],
selectedModelKeys: [],
statusBarScope: 'month',
}

export const MODELS_CATALOG_FILE_NAME = 'models-catalog.json'
export const WEB_TOOLS_FILE_NAME = 'web-tools.json'
export const MANAGED_SKILLS_FILE_NAME = 'managed-skills.json'
Expand Down
37 changes: 37 additions & 0 deletions Cursor++/src/server/database/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,39 @@ async function initializeSchema(database: AsyncDatabase): Promise<void> {

CREATE INDEX IF NOT EXISTS idx_conversation_summaries_lookup
ON conversation_summaries(conversation_id, kind, updated_at DESC);

CREATE TABLE IF NOT EXISTS usage_logs (
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
provider_name TEXT NOT NULL,
provider_type TEXT NOT NULL,
model_id TEXT NOT NULL,
api_model TEXT NOT NULL,
display_name TEXT NOT NULL,
conversation_id TEXT,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_micros TEXT NOT NULL DEFAULT '0',
output_cost_micros TEXT NOT NULL DEFAULT '0',
cache_read_cost_micros TEXT NOT NULL DEFAULT '0',
cache_creation_cost_micros TEXT NOT NULL DEFAULT '0',
total_cost_micros TEXT NOT NULL DEFAULT '0',
currency TEXT NOT NULL DEFAULT 'CNY',
unpriced INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'ok',
error_message TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_usage_logs_created_at
ON usage_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_usage_logs_provider_model
ON usage_logs(provider_id, model_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_usage_logs_currency_created
ON usage_logs(currency, created_at DESC);
`)

// ── Schema 迁移: conversation_checkpoints 新增 kind 列 ──
Expand Down Expand Up @@ -410,6 +443,10 @@ export function getAgentDatabase(): AsyncDatabase {
return db
}

export function isAgentDatabaseReady(): boolean {
return db !== null
}

export async function closeAgentDatabase(): Promise<void> {
if (!db)
return
Expand Down
7 changes: 5 additions & 2 deletions Cursor++/src/server/handlers/llm/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class GeminiProvider implements LLMProvider {

let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let sawToolCalls = false;
let syntheticToolCallCounter = 0;
let wasThinking = false;
Expand All @@ -99,7 +100,9 @@ export class GeminiProvider implements LLMProvider {
for await (const chunk of response) {
if (chunk.usageMetadata) {
inputTokens = chunk.usageMetadata.promptTokenCount ?? 0;
outputTokens = chunk.usageMetadata.candidatesTokenCount ?? 0;
outputTokens = (chunk.usageMetadata.candidatesTokenCount ?? 0)
+ (chunk.usageMetadata.thoughtsTokenCount ?? 0);
cacheReadTokens = chunk.usageMetadata.cachedContentTokenCount ?? 0;
}

const parts = chunk.candidates?.[0]?.content?.parts;
Expand Down Expand Up @@ -141,7 +144,7 @@ export class GeminiProvider implements LLMProvider {
yield {
type: 'done',
stopReason: sawToolCalls ? 'tool_use' : 'end_turn',
usage: { inputTokens, outputTokens },
usage: { inputTokens, outputTokens, cacheReadTokens },
};
}
}
3 changes: 2 additions & 1 deletion Cursor++/src/server/handlers/llm/providerRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import type { SemanticTurn } from './semanticConversation';
import { llmMessageToStoredMessage } from './storedTranscript';
import { filterToolsForMode } from '../agent/toolkit/types';
import { instrumentProviderEntry } from '../../usage/instrument';

export interface PreparedProviderConversation {
normalizedMessages: LLMMessage[];
Expand Down Expand Up @@ -87,7 +88,7 @@ function instantiateProvider(entry: ProviderEntry): LLMProvider {
function getProviderForEntry(entry: ProviderEntry): LLMProvider {
let inst = providerInstances.get(entry.id);
if (!inst) {
inst = instantiateProvider(entry);
inst = instrumentProviderEntry(instantiateProvider(entry), entry);
providerInstances.set(entry.id, inst);
}
return inst;
Expand Down
102 changes: 102 additions & 0 deletions Cursor++/src/server/tests/usageCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ModelPricing } from '../usage/types'
import { describe, expect, it } from 'vitest'
import {
CACHE_INCLUSIVE_PROVIDER_TYPES,
calculateUsageCost,
formatCost,
getFreshInputTokens,
isCacheInclusiveProvider,
isUnpricedUsage,
} from '../usage/calculator'

const priced: ModelPricing = {
inputCostPerMillion: '3',
outputCostPerMillion: '15',
cacheReadCostPerMillion: '0.3',
cacheCreationCostPerMillion: '3.75',
costMultiplier: '1',
}

describe('usage calculator', () => {
it('treats openai and gemini input as cache-inclusive', () => {
expect(CACHE_INCLUSIVE_PROVIDER_TYPES.has('openai-chat')).toBe(true)
expect(CACHE_INCLUSIVE_PROVIDER_TYPES.has('openai-responses')).toBe(true)
expect(CACHE_INCLUSIVE_PROVIDER_TYPES.has('gemini')).toBe(true)
expect(isCacheInclusiveProvider('anthropic')).toBe(false)
})

it('does not subtract cache from anthropic fresh input', () => {
expect(getFreshInputTokens('anthropic', 1000, 400, 100)).toBe(1000)
})

it('subtracts cache read and write from openai-style input', () => {
expect(getFreshInputTokens('openai-responses', 1000, 400, 100)).toBe(500)
expect(getFreshInputTokens('gemini', 100, 200, 0)).toBe(0)
})

it('prices anthropic four buckets without double-counting cache', () => {
const result = calculateUsageCost({
providerType: 'anthropic',
usage: {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 2_000_000,
cacheWriteTokens: 1_000_000,
},
pricing: priced,
})
// 1M*3 + 1M*15 + 2M*0.3 + 1M*3.75 = 3+15+0.6+3.75 = 22.35
expect(result.totalMicros).toBe(22_350_000n)
expect(result.unpriced).toBe(false)
expect(formatCost(result.totalMicros, 'CNY')).toBe('¥22.350000')
})

it('prices openai input after removing cached tokens', () => {
const result = calculateUsageCost({
providerType: 'openai-chat',
usage: {
inputTokens: 2_000_000,
outputTokens: 0,
cacheReadTokens: 1_000_000,
cacheWriteTokens: 0,
},
pricing: {
...priced,
outputCostPerMillion: '0',
cacheCreationCostPerMillion: '0',
},
})
// fresh 1M * 3 + cacheRead 1M * 0.3 = 3.3
expect(result.totalMicros).toBe(3_300_000n)
})

it('applies multiplier only to the total', () => {
const result = calculateUsageCost({
providerType: 'anthropic',
usage: { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
pricing: { ...priced, costMultiplier: '1.1' },
})
expect(result.totalMicros).toBe(3_300_000n)
})

it('marks token usage with zero prices as unpriced', () => {
const result = calculateUsageCost({
providerType: 'anthropic',
usage: { inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
pricing: {
inputCostPerMillion: '0',
outputCostPerMillion: '0',
cacheReadCostPerMillion: '0',
cacheCreationCostPerMillion: '0',
costMultiplier: '1',
},
})
expect(result.totalMicros).toBe(0n)
expect(result.unpriced).toBe(true)
expect(isUnpricedUsage(result)).toBe(true)
})

it('formats usd with a dollar sign', () => {
expect(formatCost(1_500_000n, 'USD')).toBe('$1.500000')
})
})
Loading