From 175fb14082c31db220ed8920b8d9e73a45ac7931 Mon Sep 17 00:00:00 2001 From: yanghuiqi <318673409@qq.com> Date: Wed, 2 Sep 2026 16:38:40 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9ACursor++=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E4=BE=9B=E5=BA=94=E5=95=86=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E7=9A=84=20BYOK=20=E6=88=90=E6=9C=AC=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cursor++/CHANGELOG.md | 9 + Cursor++/src/extension.ts | 9 +- Cursor++/src/server/config/paths.ts | 6 +- Cursor++/src/server/config/providersStore.ts | 1 + Cursor++/src/server/data/defaults.ts | 29 ++ Cursor++/src/server/database/sqlite.ts | 37 ++ Cursor++/src/server/handlers/llm/gemini.ts | 7 +- .../server/handlers/llm/providerRuntime.ts | 3 +- .../src/server/tests/usageCalculator.test.ts | 102 ++++++ .../src/server/tests/usageInstrument.test.ts | 162 +++++++++ Cursor++/src/server/tests/usageStore.test.ts | 258 ++++++++++++++ Cursor++/src/server/usage/calculator.ts | 117 +++++++ Cursor++/src/server/usage/events.ts | 15 + Cursor++/src/server/usage/instrument.ts | 141 ++++++++ Cursor++/src/server/usage/settings.ts | 95 +++++ Cursor++/src/server/usage/store.ts | 328 ++++++++++++++++++ Cursor++/src/server/usage/types.ts | 196 +++++++++++ Cursor++/src/ui/components/layout.tsx | 13 + Cursor++/src/ui/components/model-card.tsx | 53 +++ Cursor++/src/ui/components/styles.ts | 38 ++ Cursor++/src/ui/components/usage.tsx | 148 ++++++++ Cursor++/src/ui/panel-provider.ts | 68 ++++ Cursor++/src/ui/webview/app.ts | 178 ++++++++++ 23 files changed, 2008 insertions(+), 5 deletions(-) create mode 100644 Cursor++/src/server/tests/usageCalculator.test.ts create mode 100644 Cursor++/src/server/tests/usageInstrument.test.ts create mode 100644 Cursor++/src/server/tests/usageStore.test.ts create mode 100644 Cursor++/src/server/usage/calculator.ts create mode 100644 Cursor++/src/server/usage/events.ts create mode 100644 Cursor++/src/server/usage/instrument.ts create mode 100644 Cursor++/src/server/usage/settings.ts create mode 100644 Cursor++/src/server/usage/store.ts create mode 100644 Cursor++/src/server/usage/types.ts create mode 100644 Cursor++/src/ui/components/usage.tsx diff --git a/Cursor++/CHANGELOG.md b/Cursor++/CHANGELOG.md index 48e9d11..16eafdd 100644 --- a/Cursor++/CHANGELOG.md +++ b/Cursor++/CHANGELOG.md @@ -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 diff --git a/Cursor++/src/extension.ts b/Cursor++/src/extension.ts index 64e0118..d3b0083 100644 --- a/Cursor++/src/extension.ts +++ b/Cursor++/src/extension.ts @@ -10,6 +10,7 @@ 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 { PanelProvider } from './ui/panel-provider' import { getState, onStateChange, probeByokServer, refreshState, setFileLogState } from './ui/state' import { startUpdateCheck, stopUpdateCheck } from './update-check' @@ -508,10 +509,12 @@ export async function activate(context: vscode.ExtensionContext) { // 确保配置文件存在 —— 即使 server 未启动,面板也能读写 await ensureRoutesFile() await ensureProvidersFile() + ensureUsageSettingsFile() // 文件监听: 其他实例修改配置时自动同步状态 + UI startRoutesWatcher() startProvidersWatcher() + startUsageSettingsWatcher() const disposeRoutesWatch = onRoutesChange(async () => { await refreshState() renderStatusBar() @@ -522,7 +525,10 @@ export async function activate(context: vscode.ExtensionContext) { await refreshState() bumpRefreshSignal() }) - context.subscriptions.push({ dispose: disposeRoutesWatch }, { dispose: disposeProvidersWatch }) + const disposeUsageWatch = onUsageSettingsChange(async () => { + await refreshState() + }) + context.subscriptions.push({ dispose: disposeRoutesWatch }, { dispose: disposeProvidersWatch }, { dispose: disposeUsageWatch }) // 初始化状态 await refreshState() @@ -568,6 +574,7 @@ export async function deactivate() { closeLogFileStream() stopRoutesWatcher() stopProvidersWatcher() + stopUsageSettingsWatcher() await stopServer() if (outputChannel) outputChannel.dispose() diff --git a/Cursor++/src/server/config/paths.ts b/Cursor++/src/server/config/paths.ts index cf36515..38a8c6a 100644 --- a/Cursor++/src/server/config/paths.ts +++ b/Cursor++/src/server/config/paths.ts @@ -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) @@ -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)。 diff --git a/Cursor++/src/server/config/providersStore.ts b/Cursor++/src/server/config/providersStore.ts index 2299782..c2de5aa 100644 --- a/Cursor++/src/server/config/providersStore.ts +++ b/Cursor++/src/server/config/providersStore.ts @@ -58,6 +58,7 @@ function withFallback(loaded: Partial | 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, diff --git a/Cursor++/src/server/data/defaults.ts b/Cursor++/src/server/data/defaults.ts index 539a00d..2182af4 100644 --- a/Cursor++/src/server/data/defaults.ts +++ b/Cursor++/src/server/data/defaults.ts @@ -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' @@ -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) */ @@ -266,6 +277,24 @@ export const DEFAULT_PROVIDERS: ProvidersConfig = { providers: [], } +export interface UsageSettingsConfig { + $schemaVersion: number + currency: 'CNY' | 'USD' + range: 'today' | '7d' | '14d' | '30d' + filterCustomized?: boolean + selectedProviderIds: string[] + selectedModelKeys: string[] +} + +export const DEFAULT_USAGE_SETTINGS: UsageSettingsConfig = { + $schemaVersion: 1, + currency: 'CNY', + range: 'today', + filterCustomized: false, + selectedProviderIds: [], + selectedModelKeys: [], +} + 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' diff --git a/Cursor++/src/server/database/sqlite.ts b/Cursor++/src/server/database/sqlite.ts index 6b16c1b..496ee1c 100644 --- a/Cursor++/src/server/database/sqlite.ts +++ b/Cursor++/src/server/database/sqlite.ts @@ -324,6 +324,39 @@ async function initializeSchema(database: AsyncDatabase): Promise { 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 列 ── @@ -410,6 +443,10 @@ export function getAgentDatabase(): AsyncDatabase { return db } +export function isAgentDatabaseReady(): boolean { + return db !== null +} + export async function closeAgentDatabase(): Promise { if (!db) return diff --git a/Cursor++/src/server/handlers/llm/gemini.ts b/Cursor++/src/server/handlers/llm/gemini.ts index 89049cd..f69d2b1 100644 --- a/Cursor++/src/server/handlers/llm/gemini.ts +++ b/Cursor++/src/server/handlers/llm/gemini.ts @@ -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; @@ -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; @@ -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 }, }; } } diff --git a/Cursor++/src/server/handlers/llm/providerRuntime.ts b/Cursor++/src/server/handlers/llm/providerRuntime.ts index 9d4bc4b..66a63a8 100644 --- a/Cursor++/src/server/handlers/llm/providerRuntime.ts +++ b/Cursor++/src/server/handlers/llm/providerRuntime.ts @@ -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[]; @@ -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; diff --git a/Cursor++/src/server/tests/usageCalculator.test.ts b/Cursor++/src/server/tests/usageCalculator.test.ts new file mode 100644 index 0000000..8a667db --- /dev/null +++ b/Cursor++/src/server/tests/usageCalculator.test.ts @@ -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') + }) +}) diff --git a/Cursor++/src/server/tests/usageInstrument.test.ts b/Cursor++/src/server/tests/usageInstrument.test.ts new file mode 100644 index 0000000..1bd6feb --- /dev/null +++ b/Cursor++/src/server/tests/usageInstrument.test.ts @@ -0,0 +1,162 @@ +import type { ProviderEntry } from '../data/defaults' +import type { LLMProvider, LLMStreamEvent, LLMStreamRequest } from '../handlers/llm/types' +import { unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { loadProviders, setProvidersForTests } from '../config/providersStore' +import { getAgentDatabase, resetAgentDatabaseForTests } from '../database/sqlite' +import { instrumentProvider, instrumentProviderEntry } from '../usage/instrument' +import { resetUsageSettingsCacheForTests } from '../usage/settings' + +class FakeProvider implements LLMProvider { + readonly name = 'anthropic' + constructor(private readonly events: LLMStreamEvent[]) {} + async* stream(_request: LLMStreamRequest): AsyncIterable { + for (const event of this.events) + yield event + } +} + +describe('instrumentProvider', () => { + it('records every done event including tool_use rounds', async () => { + const recorded: Array<{ stopReason: string, inputTokens: number }> = [] + const provider = instrumentProvider(new FakeProvider([ + { type: 'text_delta', text: 'hi' }, + { type: 'done', stopReason: 'tool_use', usage: { inputTokens: 10, outputTokens: 2 } }, + { type: 'done', stopReason: 'end_turn', usage: { inputTokens: 20, outputTokens: 4, cacheReadTokens: 3 } }, + ]), { + resolveContext: () => ({ + providerId: 'p1', + providerName: 'Personal', + providerType: 'anthropic', + modelId: 'm1', + apiModel: 'claude', + displayName: 'Claude', + pricing: { + inputCostPerMillion: '1', + outputCostPerMillion: '1', + cacheReadCostPerMillion: '0.1', + cacheCreationCostPerMillion: '0', + costMultiplier: '1', + }, + }), + record: async (entry) => { + recorded.push({ stopReason: entry.stopReason, inputTokens: entry.usage.inputTokens }) + }, + }) + + const events: LLMStreamEvent[] = [] + for await (const event of provider.stream({ model: 'claude', messages: [] })) + events.push(event) + + expect(events).toHaveLength(3) + expect(recorded).toEqual([ + { stopReason: 'tool_use', inputTokens: 10 }, + { stopReason: 'end_turn', inputTokens: 20 }, + ]) + }) + + it('still yields events if recording throws', async () => { + const provider = instrumentProvider(new FakeProvider([ + { type: 'done', stopReason: 'end_turn', usage: { inputTokens: 1, outputTokens: 1 } }, + ]), { + resolveContext: () => ({ + providerId: 'p1', + providerName: 'Personal', + providerType: 'anthropic', + modelId: 'm1', + apiModel: 'claude', + displayName: 'Claude', + pricing: { + inputCostPerMillion: '0', + outputCostPerMillion: '0', + cacheReadCostPerMillion: '0', + cacheCreationCostPerMillion: '0', + costMultiplier: '1', + }, + }), + record: async () => { + throw new Error('db down') + }, + }) + + const events: LLMStreamEvent[] = [] + for await (const event of provider.stream({ model: 'claude', messages: [] })) + events.push(event) + expect(events).toHaveLength(1) + }) +}) + +describe('instrumentProviderEntry live pricing', () => { + let tmpDbPath = '' + let previousProviders = loadProviders() + + beforeEach(async () => { + previousProviders = JSON.parse(JSON.stringify(loadProviders())) + tmpDbPath = join(tmpdir(), `.tmp-usage-inst-${Date.now()}-${Math.random().toString(36).slice(2)}.db`) + process.env.BYOK_AGENT_DB_PATH = tmpDbPath + await resetAgentDatabaseForTests() + resetUsageSettingsCacheForTests() + }) + + afterEach(async () => { + setProvidersForTests(previousProviders) + await resetAgentDatabaseForTests() + delete process.env.BYOK_AGENT_DB_PATH + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${tmpDbPath}${suffix}`) + } + catch {} + } + }) + + it('prices from the live providers store instead of the cached provider snapshot', async () => { + const staleEntry: ProviderEntry = { + id: 'personal-glm', + name: '个人-glm', + type: 'anthropic', + baseUrl: '', + auth: { kind: 'apiKey', value: 'test-key' }, + models: [{ + id: 'flash', + apiModel: 'z-ai/glm-5.3-flash', + displayName: 'flash', + thinking: false, + }], + } + setProvidersForTests({ + $schemaVersion: 1, + providers: [{ + ...staleEntry, + models: [{ + id: 'flash', + apiModel: 'z-ai/glm-5.3-flash', + displayName: 'flash', + thinking: false, + inputCostPerMillion: '3', + outputCostPerMillion: '15', + cacheReadCostPerMillion: '0', + cacheCreationCostPerMillion: '0', + costMultiplier: '1', + }], + }], + }) + + const provider = instrumentProviderEntry(new FakeProvider([ + { type: 'done', stopReason: 'end_turn', usage: { inputTokens: 1_000_000, outputTokens: 0 } }, + ]), staleEntry) + + for await (const _event of provider.stream({ model: 'z-ai/glm-5.3-flash', messages: [] })) { + // drain + } + + const rows = await getAgentDatabase().all<{ total_cost_micros: string, unpriced: number }>( + 'SELECT total_cost_micros, unpriced FROM usage_logs', + ) + expect(rows).toHaveLength(1) + expect(rows[0].unpriced).toBe(0) + expect(rows[0].total_cost_micros).toBe('3000000') + }) +}) diff --git a/Cursor++/src/server/tests/usageStore.test.ts b/Cursor++/src/server/tests/usageStore.test.ts new file mode 100644 index 0000000..771a415 --- /dev/null +++ b/Cursor++/src/server/tests/usageStore.test.ts @@ -0,0 +1,258 @@ +import type { UsageLogRecord } from '../usage/types' +import { unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resetAgentDatabaseForTests } from '../database/sqlite' +import { queryUsageDashboard, recordUsageLog } from '../usage/store' + +let tmpDbPath = '' + +beforeEach(async () => { + tmpDbPath = join(tmpdir(), `.tmp-usage-${Date.now()}-${Math.random().toString(36).slice(2)}.db`) + process.env.BYOK_AGENT_DB_PATH = tmpDbPath + await resetAgentDatabaseForTests() +}) + +afterEach(async () => { + await resetAgentDatabaseForTests() + delete process.env.BYOK_AGENT_DB_PATH + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(`${tmpDbPath}${suffix}`) + } + catch {} + } +}) + +function log(partial: Partial): UsageLogRecord { + return { + requestId: partial.requestId ?? `req-${Math.random().toString(36).slice(2, 8)}`, + providerId: partial.providerId ?? 'provider-alpha', + providerName: partial.providerName ?? 'Provider Alpha', + providerType: partial.providerType ?? 'anthropic', + modelId: partial.modelId ?? 'model-flash', + apiModel: partial.apiModel ?? 'vendor/model-x', + displayName: partial.displayName ?? 'Model X', + conversationId: partial.conversationId, + inputTokens: partial.inputTokens ?? 1000, + outputTokens: partial.outputTokens ?? 200, + cacheReadTokens: partial.cacheReadTokens ?? 0, + cacheWriteTokens: partial.cacheWriteTokens ?? 0, + inputCostMicros: partial.inputCostMicros ?? 3_000n, + outputCostMicros: partial.outputCostMicros ?? 3_000n, + cacheReadCostMicros: partial.cacheReadCostMicros ?? 0n, + cacheCreationCostMicros: partial.cacheCreationCostMicros ?? 0n, + totalCostMicros: partial.totalCostMicros ?? 6_000n, + currency: partial.currency ?? 'CNY', + unpriced: partial.unpriced ?? 0, + status: partial.status ?? 'ok', + errorMessage: partial.errorMessage, + durationMs: partial.durationMs ?? 12, + createdAt: partial.createdAt ?? Date.now(), + } +} + +describe('usage store', () => { + it('aggregates selected providers and models only', async () => { + const now = Date.now() + await recordUsageLog(log({ + requestId: 'a', + providerId: 'provider-alpha', + modelId: 'flash', + displayName: 'flash', + totalCostMicros: 10_000n, + createdAt: now, + })) + await recordUsageLog(log({ + requestId: 'b', + providerId: 'provider-alpha', + modelId: 'kimi', + displayName: 'kimi', + totalCostMicros: 20_000n, + createdAt: now, + })) + await recordUsageLog(log({ + requestId: 'c', + providerId: 'provider-beta', + providerName: 'Provider Beta', + modelId: 'gpt', + displayName: 'gpt', + totalCostMicros: 99_000n, + createdAt: now, + })) + + const dashboard = await queryUsageDashboard({ + $schemaVersion: 1, + currency: 'CNY', + range: 'today', + filterCustomized: true, + selectedProviderIds: ['provider-alpha'], + selectedModelKeys: ['provider-alpha::flash'], + }) + + expect(dashboard.summary.requestCount).toBe(1) + expect(dashboard.summary.totalCostMicros).toBe(10_000n) + expect(dashboard.summary.totalCostFormatted).toBe('¥0.010000') + expect(dashboard.recent).toHaveLength(1) + expect(dashboard.recent[0].displayName).toBe('flash') + expect(dashboard.providers.find(p => p.id === 'provider-alpha')?.selected).toBe(true) + expect(dashboard.providers.find(p => p.id === 'provider-beta')?.selected).toBe(false) + expect(dashboard.models.find(m => m.modelId === 'flash')?.selected).toBe(true) + }) + + it('treats empty selection as all providers', async () => { + await recordUsageLog(log({ requestId: 'a', providerId: 'p1', modelId: 'm1', totalCostMicros: 1_000n })) + await recordUsageLog(log({ requestId: 'b', providerId: 'p2', providerName: 'p2', modelId: 'm2', totalCostMicros: 2_000n })) + const dashboard = await queryUsageDashboard({ + $schemaVersion: 1, + currency: 'CNY', + range: 'today', + selectedProviderIds: [], + selectedModelKeys: [], + }) + expect(dashboard.summary.requestCount).toBe(2) + expect(dashboard.summary.totalCostMicros).toBe(3_000n) + }) + + it('treats empty selection as none after the user has customized filters', async () => { + await recordUsageLog(log({ + requestId: 'a', + providerId: 'provider-alpha', + modelId: 'flash', + totalCostMicros: 10_000n, + })) + await recordUsageLog(log({ + requestId: 'b', + providerId: 'provider-beta', + providerName: 'Provider Beta', + modelId: 'gpt', + totalCostMicros: 99_000n, + })) + + const dashboard = await queryUsageDashboard({ + $schemaVersion: 1, + currency: 'CNY', + range: 'today', + filterCustomized: true, + selectedProviderIds: [], + selectedModelKeys: [], + }) + + expect(dashboard.summary.requestCount).toBe(0) + expect(dashboard.summary.totalCostMicros).toBe(0n) + expect(dashboard.recent).toHaveLength(0) + expect(dashboard.providers.every(provider => !provider.selected)).toBe(true) + }) + + it('includes all models of a rechecked provider even if their keys were previously excluded', async () => { + await recordUsageLog(log({ + requestId: 'a', + providerId: 'provider-alpha', + modelId: 'flash', + displayName: 'flash', + totalCostMicros: 10_000n, + })) + await recordUsageLog(log({ + requestId: 'b', + providerId: 'provider-alpha', + modelId: 'kimi', + displayName: 'kimi', + totalCostMicros: 20_000n, + })) + await recordUsageLog(log({ + requestId: 'c', + providerId: 'provider-beta', + providerName: 'Provider Beta', + modelId: 'gpt', + displayName: 'gpt', + totalCostMicros: 99_000n, + })) + + const dashboard = await queryUsageDashboard({ + $schemaVersion: 1, + currency: 'CNY', + range: 'today', + filterCustomized: true, + selectedProviderIds: ['provider-alpha'], + selectedModelKeys: ['provider-beta::gpt'], + }) + + expect(dashboard.summary.requestCount).toBe(2) + expect(dashboard.summary.totalCostMicros).toBe(30_000n) + expect(dashboard.recent.map(item => item.displayName).sort()).toEqual(['flash', 'kimi']) + expect(dashboard.models.find(model => model.modelId === 'kimi')?.selected).toBe(true) + }) + + it('rolls up daily buckets across the selected range with zero-filled gaps', async () => { + const now = Date.now() + const yesterday = now - 24 * 60 * 60 * 1000 + await recordUsageLog(log({ + requestId: 'a', + inputTokens: 1000, + outputTokens: 200, + totalCostMicros: 10_000n, + createdAt: now, + })) + await recordUsageLog(log({ + requestId: 'b', + status: 'error', + inputTokens: 100, + outputTokens: 0, + totalCostMicros: 1_000n, + createdAt: yesterday, + })) + + const dashboard = await queryUsageDashboard({ + $schemaVersion: 1, + currency: 'CNY', + range: '7d', + selectedProviderIds: [], + selectedModelKeys: [], + }) + + expect(dashboard.daily).toHaveLength(7) + expect(dashboard.daily[6].date).toBe(dashboard.daily.at(-1)?.date) + const today = dashboard.daily.at(-1) + expect(today?.requestCount).toBe(1) + expect(today?.okCount).toBe(1) + expect(today?.totalCostMicros).toBe(10_000n) + const errorDay = dashboard.daily[5] + expect(errorDay.requestCount).toBe(1) + expect(errorDay.okCount).toBe(0) + expect(errorDay.totalCostMicros).toBe(1_000n) + expect(dashboard.daily.slice(0, 5).every(day => day.requestCount === 0)).toBe(true) + }) + + it('keeps daily buckets aligned with the view filter', async () => { + const now = Date.now() + await recordUsageLog(log({ + requestId: 'a', + providerId: 'provider-alpha', + modelId: 'flash', + totalCostMicros: 10_000n, + createdAt: now, + })) + await recordUsageLog(log({ + requestId: 'b', + providerId: 'provider-beta', + providerName: 'Provider Beta', + modelId: 'gpt', + totalCostMicros: 99_000n, + createdAt: now, + })) + + const dashboard = await queryUsageDashboard({ + $schemaVersion: 1, + currency: 'CNY', + range: 'today', + filterCustomized: true, + selectedProviderIds: ['provider-alpha'], + selectedModelKeys: [], + }) + + expect(dashboard.daily).toHaveLength(1) + expect(dashboard.daily[0].requestCount).toBe(1) + expect(dashboard.daily[0].totalCostMicros).toBe(10_000n) + }) +}) diff --git a/Cursor++/src/server/usage/calculator.ts b/Cursor++/src/server/usage/calculator.ts new file mode 100644 index 0000000..0662192 --- /dev/null +++ b/Cursor++/src/server/usage/calculator.ts @@ -0,0 +1,117 @@ +import type { ProviderType } from '../data/defaults' +import type { CostBreakdown, ModelPricing, NormalizedUsage, UsageCurrency } from './types' + +export const CACHE_INCLUSIVE_PROVIDER_TYPES: ReadonlySet = new Set([ + 'openai-chat', + 'openai-responses', + 'gemini', +]) + +const MICROS_PER_UNIT = 1_000_000n + +interface ScaledDecimal { + value: bigint + scale: number +} + +export function isCacheInclusiveProvider(providerType: string): boolean { + return CACHE_INCLUSIVE_PROVIDER_TYPES.has(providerType as ProviderType) +} + +export function getFreshInputTokens( + providerType: string, + inputTokens: number, + cacheReadTokens: number, + cacheWriteTokens: number, +): number { + if (!isCacheInclusiveProvider(providerType)) + return Math.max(0, inputTokens) + return Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) +} + +export function calculateUsageCost(params: { + providerType: string + usage: NormalizedUsage + pricing: ModelPricing +}): CostBreakdown { + const freshInputTokens = getFreshInputTokens( + params.providerType, + params.usage.inputTokens, + params.usage.cacheReadTokens, + params.usage.cacheWriteTokens, + ) + const inputMicros = tokensToMicros(freshInputTokens, params.pricing.inputCostPerMillion) + const outputMicros = tokensToMicros(params.usage.outputTokens, params.pricing.outputCostPerMillion) + const cacheReadMicros = tokensToMicros(params.usage.cacheReadTokens, params.pricing.cacheReadCostPerMillion) + const cacheCreationMicros = tokensToMicros(params.usage.cacheWriteTokens, params.pricing.cacheCreationCostPerMillion) + const baseTotal = inputMicros + outputMicros + cacheReadMicros + cacheCreationMicros + const totalMicros = applyMultiplier(baseTotal, params.pricing.costMultiplier) + const hasTokens = params.usage.inputTokens > 0 + || params.usage.outputTokens > 0 + || params.usage.cacheReadTokens > 0 + || params.usage.cacheWriteTokens > 0 + return { + inputMicros, + outputMicros, + cacheReadMicros, + cacheCreationMicros, + totalMicros, + unpriced: hasTokens && totalMicros === 0n, + freshInputTokens, + } +} + +export function isUnpricedUsage(result: Pick): boolean { + return result.unpriced +} + +export function formatCost(micros: bigint, currency: UsageCurrency): string { + const sign = currency === 'USD' ? '$' : '¥' + const negative = micros < 0n + const absolute = negative ? -micros : micros + const whole = absolute / MICROS_PER_UNIT + const fraction = (absolute % MICROS_PER_UNIT).toString().padStart(6, '0') + return `${negative ? '-' : ''}${sign}${whole.toString()}.${fraction}` +} + +function tokensToMicros(tokens: number, pricePerMillion: string): bigint { + const price = parseNonNegativeDecimal(pricePerMillion) + if (tokens <= 0 || price.value === 0n) + return 0n + // cost = tokens * price / 1e6 → micros = tokens * price + // price is value / 10^scale, so micros = tokens * value / 10^scale + const numerator = BigInt(tokens) * price.value + const denominator = pow10(price.scale) + return divRound(numerator, denominator) +} + +function applyMultiplier(micros: bigint, multiplier: string): bigint { + const parsed = parseNonNegativeDecimal(multiplier.trim() === '' ? '1' : multiplier) + if (parsed.value === 0n) + return 0n + if (parsed.value === 1n && parsed.scale === 0) + return micros + return divRound(micros * parsed.value, pow10(parsed.scale)) +} + +function parseNonNegativeDecimal(raw: string): ScaledDecimal { + const trimmed = String(raw ?? '').trim() + if (!trimmed || !/^\d+(?:\.\d+)?$/.test(trimmed)) + return { value: 0n, scale: 0 } + const [whole, fraction = ''] = trimmed.split('.') + return { + value: BigInt(`${whole}${fraction}` || '0'), + scale: fraction.length, + } +} + +function pow10(scale: number): bigint { + return 10n ** BigInt(scale) +} + +function divRound(numerator: bigint, denominator: bigint): bigint { + if (denominator === 0n) + return 0n + const half = denominator / 2n + return (numerator + half) / denominator +} diff --git a/Cursor++/src/server/usage/events.ts b/Cursor++/src/server/usage/events.ts new file mode 100644 index 0000000..126842f --- /dev/null +++ b/Cursor++/src/server/usage/events.ts @@ -0,0 +1,15 @@ +import { EventEmitter } from 'node:events' + +const usageEvents = new EventEmitter() +usageEvents.setMaxListeners(20) + +export function notifyUsageRecorded(): void { + usageEvents.emit('recorded') +} + +export function onUsageRecorded(listener: () => void): () => void { + usageEvents.on('recorded', listener) + return () => { + usageEvents.off('recorded', listener) + } +} diff --git a/Cursor++/src/server/usage/instrument.ts b/Cursor++/src/server/usage/instrument.ts new file mode 100644 index 0000000..4b77355 --- /dev/null +++ b/Cursor++/src/server/usage/instrument.ts @@ -0,0 +1,141 @@ +import type { ProviderEntry, ProviderModel } from '../data/defaults' +import type { LLMProvider, LLMStreamEvent, LLMStreamRequest, LLMUsage } from '../handlers/llm/types' +import type { ModelPricing } from './types' +import { getProvider } from '../config/providersStore' +import { logger } from '../logger' +import { calculateUsageCost } from './calculator' +import { notifyUsageRecorded } from './events' +import { loadUsageSettings } from './settings' +import { recordUsageLog } from './store' +import { normalizeUsage, pricingFromModel } from './types' + +export interface UsageRecordContext { + providerId: string + providerName: string + providerType: ProviderEntry['type'] + modelId: string + apiModel: string + displayName: string + pricing: ModelPricing +} + +export interface InstrumentedUsageEntry { + stopReason: string + usage: LLMUsage + durationMs: number + conversationId?: string +} + +export interface InstrumentHooks { + resolveContext: (request: LLMStreamRequest) => UsageRecordContext | null + record: (entry: InstrumentedUsageEntry & { context: UsageRecordContext }) => Promise +} + +export function instrumentProvider(provider: LLMProvider, hooks: InstrumentHooks): LLMProvider { + return { + name: provider.name, + async* stream(request: LLMStreamRequest): AsyncIterable { + const startedAt = Date.now() + try { + for await (const event of provider.stream(request)) { + if (event.type === 'done') { + const context = hooks.resolveContext(request) + if (context) { + try { + await hooks.record({ + context, + stopReason: event.stopReason, + usage: event.usage, + durationMs: Date.now() - startedAt, + conversationId: request.conversationId, + }) + } + catch (error) { + logger.warn({ error: (error as Error).message }, '[USAGE] failed to record stream usage') + } + } + } + yield event + } + } + catch (error) { + const context = hooks.resolveContext(request) + if (context) { + try { + await hooks.record({ + context, + stopReason: 'error', + usage: { inputTokens: 0, outputTokens: 0 }, + durationMs: Date.now() - startedAt, + conversationId: request.conversationId, + }) + } + catch (recordError) { + logger.warn({ error: (recordError as Error).message }, '[USAGE] failed to record error usage') + } + } + throw error + } + }, + } +} + +function findModel(models: ProviderModel[], requestModel: string): ProviderModel | undefined { + return models.find(item => item.apiModel === requestModel) + ?? models.find(item => item.id === requestModel) +} + +export function instrumentProviderEntry(provider: LLMProvider, entry: ProviderEntry): LLMProvider { + return instrumentProvider(provider, { + resolveContext(request) { + const liveEntry = getProvider(entry.id) ?? entry + const model = findModel(liveEntry.models, request.model) ?? findModel(entry.models, request.model) + if (!model) + return null + return { + providerId: liveEntry.id, + providerName: liveEntry.name, + providerType: liveEntry.type, + modelId: model.id, + apiModel: model.apiModel, + displayName: model.displayName || model.apiModel, + pricing: pricingFromModel(model), + } + }, + async record({ context, usage, durationMs, conversationId, stopReason }) { + const settings = loadUsageSettings() + const normalized = normalizeUsage(usage) + const cost = calculateUsageCost({ + providerType: context.providerType, + usage: normalized, + pricing: context.pricing, + }) + await recordUsageLog({ + requestId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`, + providerId: context.providerId, + providerName: context.providerName, + providerType: context.providerType, + modelId: context.modelId, + apiModel: context.apiModel, + displayName: context.displayName, + conversationId, + inputTokens: normalized.inputTokens, + outputTokens: normalized.outputTokens, + cacheReadTokens: normalized.cacheReadTokens, + cacheWriteTokens: normalized.cacheWriteTokens, + inputCostMicros: cost.inputMicros, + outputCostMicros: cost.outputMicros, + cacheReadCostMicros: cost.cacheReadMicros, + cacheCreationCostMicros: cost.cacheCreationMicros, + totalCostMicros: stopReason === 'error' ? 0n : cost.totalMicros, + currency: settings.currency, + unpriced: stopReason === 'error' ? 0 : (cost.unpriced ? 1 : 0), + status: stopReason === 'error' ? 'error' : 'ok', + errorMessage: stopReason === 'error' ? 'stream failed' : undefined, + durationMs, + createdAt: Date.now(), + }) + notifyUsageRecorded() + }, + }) +} diff --git a/Cursor++/src/server/usage/settings.ts b/Cursor++/src/server/usage/settings.ts new file mode 100644 index 0000000..534dcc1 --- /dev/null +++ b/Cursor++/src/server/usage/settings.ts @@ -0,0 +1,95 @@ +import type { UsageSettingsConfig } from '../data/defaults' +import type { UsageCurrency, UsageRangePreset, UsageSettings } from './types' +import { existsSync, unwatchFile, watchFile } from 'node:fs' +import { readJsonOrNull, withSerial, writeJsonAtomic } from '../config/atomic' +import { getUsageSettingsFilePath } from '../config/paths' +import { DEFAULT_USAGE_SETTINGS } from '../data/defaults' +import { logger } from '../logger' + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +let cache: UsageSettings | null = null + +function withFallback(loaded: Partial | null): UsageSettings { + const currency: UsageCurrency = loaded?.currency === 'USD' ? 'USD' : 'CNY' + const range: UsageRangePreset = loaded?.range === '7d' || loaded?.range === '14d' || loaded?.range === '30d' + ? loaded.range + : 'today' + return { + $schemaVersion: 1, + currency, + range, + filterCustomized: loaded?.filterCustomized === true, + selectedProviderIds: Array.isArray(loaded?.selectedProviderIds) ? loaded.selectedProviderIds.filter(id => typeof id === 'string') : [], + selectedModelKeys: Array.isArray(loaded?.selectedModelKeys) ? loaded.selectedModelKeys.filter(id => typeof id === 'string') : [], + } +} + +export function loadUsageSettings(): UsageSettings { + if (cache) + return cache + cache = withFallback(readJsonOrNull>(getUsageSettingsFilePath())) + return cache +} + +export async function updateUsageSettings(updater: (draft: UsageSettings) => void): Promise { + const path = getUsageSettingsFilePath() + return withSerial(path, () => { + const current = withFallback(readJsonOrNull>(path)) + updater(current) + writeJsonAtomic(path, current) + cache = current + return clone(cache) + }) +} + +export function resetUsageSettingsCacheForTests(): void { + cache = null +} + +let watching = false +const listeners: Array<() => void> = [] + +export function startUsageSettingsWatcher(): void { + if (watching) + return + const path = getUsageSettingsFilePath() + if (!existsSync(path)) + return + watchFile(path, { interval: 2000, persistent: false }, () => { + cache = withFallback(readJsonOrNull>(path)) + logger.info('[CFG] usage-settings.json changed, reloading') + for (const fn of listeners) + fn() + }) + watching = true +} + +export function stopUsageSettingsWatcher(): void { + if (!watching) + return + unwatchFile(getUsageSettingsFilePath()) + watching = false +} + +export function onUsageSettingsChange(fn: () => void): () => void { + listeners.push(fn) + return () => { + const idx = listeners.indexOf(fn) + if (idx >= 0) + listeners.splice(idx, 1) + } +} + +export function ensureUsageSettingsFile(): UsageSettings { + const path = getUsageSettingsFilePath() + if (!existsSync(path)) { + const seed = clone(DEFAULT_USAGE_SETTINGS) + writeJsonAtomic(path, seed) + cache = seed + return seed + } + return loadUsageSettings() +} diff --git a/Cursor++/src/server/usage/store.ts b/Cursor++/src/server/usage/store.ts new file mode 100644 index 0000000..08b4856 --- /dev/null +++ b/Cursor++/src/server/usage/store.ts @@ -0,0 +1,328 @@ +import type { + UsageCurrency, + UsageDailyStat, + UsageDashboard, + UsageHeroSummary, + UsageLogRecord, + UsageLogRow, + UsageModelStat, + UsageProviderStat, + UsageRangePreset, + UsageRecentItem, + UsageSettings, +} from './types' +import { loadProviders } from '../config/providersStore' +import { getAgentDatabase } from '../database/sqlite' +import { logger } from '../logger' +import { formatCost, getFreshInputTokens } from './calculator' +import { modelUsageKey } from './types' + +function startOfLocalDay(now = Date.now()): number { + const date = new Date(now) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + +function rangeStart(range: UsageRangePreset, now = Date.now()): number { + if (range === 'today') + return startOfLocalDay(now) + // Anchor on local midnight so "7 days" covers exactly 7 full calendar days + // and the daily trend buckets stay one-per-day with no partial first day. + const days = range === '7d' ? 7 : range === '14d' ? 14 : 30 + return startOfLocalDay(now - (days - 1) * 24 * 60 * 60 * 1000) +} + +export async function recordUsageLog(record: UsageLogRecord): Promise { + try { + await getAgentDatabase().run( + `INSERT INTO usage_logs ( + request_id, provider_id, provider_name, provider_type, + model_id, api_model, display_name, conversation_id, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + input_cost_micros, output_cost_micros, cache_read_cost_micros, cache_creation_cost_micros, total_cost_micros, + currency, unpriced, status, error_message, duration_ms, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + record.requestId, + record.providerId, + record.providerName, + record.providerType, + record.modelId, + record.apiModel, + record.displayName, + record.conversationId ?? null, + record.inputTokens, + record.outputTokens, + record.cacheReadTokens, + record.cacheWriteTokens, + record.inputCostMicros.toString(), + record.outputCostMicros.toString(), + record.cacheReadCostMicros.toString(), + record.cacheCreationCostMicros.toString(), + record.totalCostMicros.toString(), + record.currency, + record.unpriced, + record.status, + record.errorMessage ?? null, + record.durationMs, + record.createdAt, + ], + ) + } + catch (error) { + logger.warn({ error: (error as Error).message, requestId: record.requestId }, '[USAGE] insert failed') + } +} + +function isProviderSelected(settings: UsageSettings, providerId: string): boolean { + if (!settings.filterCustomized) + return true + return settings.selectedProviderIds.includes(providerId) +} + +function isModelSelected(settings: UsageSettings, providerId: string, modelId: string): boolean { + if (!settings.filterCustomized) + return true + if (!isProviderSelected(settings, providerId)) + return false + const keysForProvider = settings.selectedModelKeys.filter(key => key.startsWith(`${providerId}::`)) + if (keysForProvider.length === 0) + return true + return settings.selectedModelKeys.includes(modelUsageKey(providerId, modelId)) +} + +function matchesUsageFilter(settings: UsageSettings, row: UsageLogRow): boolean { + if (!isProviderSelected(settings, row.provider_id)) + return false + return isModelSelected(settings, row.provider_id, row.model_id) +} + +export async function queryUsageDashboard(settings: UsageSettings): Promise { + const now = Date.now() + const start = rangeStart(settings.range, now) + const todayStart = startOfLocalDay(now) + const rows = await getAgentDatabase().all( + `SELECT * FROM usage_logs WHERE created_at >= ? AND currency = ? ORDER BY created_at DESC`, + [start, settings.currency], + ) + const todayRows = rows.filter(row => row.created_at >= todayStart) + const filtered = rows.filter(row => matchesUsageFilter(settings, row)) + const todayFiltered = todayRows.filter(row => matchesUsageFilter(settings, row)) + + return { + settings, + todayCostFormatted: formatCost(sumMicros(todayFiltered), settings.currency), + summary: summarize(filtered, settings.currency), + providers: buildProviderStats(rows, settings), + models: buildModelStats(rows, settings), + daily: buildDailyStats(filtered, start, now, settings.currency), + recent: filtered.slice(0, 30).map(toRecentItem(settings.currency)), + } +} + +/** + * Group view-filtered rows by local calendar day, filling days without usage + * with zero-cost buckets so the trend bars stay aligned with the time axis. + */ +function buildDailyStats(rows: UsageLogRow[], rangeStartMs: number, nowMs: number, currency: UsageCurrency): UsageDailyStat[] { + const byDay = new Map() + for (const row of rows) { + const date = formatDayStamp(row.created_at) + const current = byDay.get(date) ?? { + date, + requestCount: 0, + okCount: 0, + realTotalTokens: 0, + totalCostMicros: 0n, + totalCostFormatted: formatCost(0n, currency), + } + current.requestCount += 1 + if (row.status === 'ok') + current.okCount += 1 + current.realTotalTokens += getFreshInputTokens(row.provider_type, row.input_tokens, row.cache_read_tokens, row.cache_write_tokens) + + row.output_tokens + row.cache_write_tokens + row.cache_read_tokens + current.totalCostMicros += BigInt(row.total_cost_micros || '0') + current.totalCostFormatted = formatCost(current.totalCostMicros, currency) + byDay.set(date, current) + } + + const days: UsageDailyStat[] = [] + const cursor = new Date(startOfLocalDay(rangeStartMs)) + const lastDay = startOfLocalDay(nowMs) + while (cursor.getTime() <= lastDay) { + days.push(byDay.get(formatDayStamp(cursor.getTime())) ?? { + date: formatDayStamp(cursor.getTime()), + requestCount: 0, + okCount: 0, + realTotalTokens: 0, + totalCostMicros: 0n, + totalCostFormatted: formatCost(0n, currency), + }) + cursor.setDate(cursor.getDate() + 1) + } + return days +} + +function formatDayStamp(timestamp: number): string { + const date = new Date(timestamp) + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${month}-${day}` +} + +function summarize(rows: UsageLogRow[], currency: UsageCurrency): UsageHeroSummary { + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + let freshInputTokens = 0 + let unpricedCount = 0 + let okCount = 0 + let totalCostMicros = 0n + for (const row of rows) { + inputTokens += row.input_tokens + outputTokens += row.output_tokens + cacheReadTokens += row.cache_read_tokens + cacheWriteTokens += row.cache_write_tokens + freshInputTokens += getFreshInputTokens(row.provider_type, row.input_tokens, row.cache_read_tokens, row.cache_write_tokens) + totalCostMicros += BigInt(row.total_cost_micros || '0') + if (row.unpriced) + unpricedCount += 1 + if (row.status === 'ok') + okCount += 1 + } + const cacheDenom = freshInputTokens + cacheWriteTokens + cacheReadTokens + return { + requestCount: rows.length, + okCount, + totalCostMicros, + totalCostFormatted: formatCost(totalCostMicros, currency), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + freshInputTokens, + realTotalTokens: freshInputTokens + outputTokens + cacheWriteTokens + cacheReadTokens, + cacheHitRate: cacheDenom > 0 ? cacheReadTokens / cacheDenom : 0, + unpricedCount, + } +} + +function buildProviderStats(rows: UsageLogRow[], settings: UsageSettings): UsageProviderStat[] { + const configured = loadProviders().providers + const byId = new Map() + for (const provider of configured) { + byId.set(provider.id, { + id: provider.id, + name: provider.name, + type: provider.type, + selected: isProviderSelected(settings, provider.id), + requestCount: 0, + totalCostMicros: 0n, + totalCostFormatted: formatCost(0n, settings.currency), + }) + } + for (const row of rows) { + const current = byId.get(row.provider_id) ?? { + id: row.provider_id, + name: row.provider_name, + type: row.provider_type, + selected: isProviderSelected(settings, row.provider_id), + requestCount: 0, + totalCostMicros: 0n, + totalCostFormatted: formatCost(0n, settings.currency), + } + current.requestCount += 1 + current.totalCostMicros += BigInt(row.total_cost_micros || '0') + current.totalCostFormatted = formatCost(current.totalCostMicros, settings.currency) + byId.set(row.provider_id, current) + } + return [...byId.values()].sort((a, b) => Number(b.totalCostMicros - a.totalCostMicros) || a.name.localeCompare(b.name)) +} + +function buildModelStats(rows: UsageLogRow[], settings: UsageSettings): UsageModelStat[] { + const configured = loadProviders().providers + const byKey = new Map() + for (const provider of configured) { + for (const model of provider.models) { + const key = modelUsageKey(provider.id, model.id) + byKey.set(key, { + key, + providerId: provider.id, + providerName: provider.name, + modelId: model.id, + displayName: model.displayName || model.apiModel, + selected: isModelSelected(settings, provider.id, model.id), + requestCount: 0, + totalCostMicros: 0n, + totalCostFormatted: formatCost(0n, settings.currency), + }) + } + } + for (const row of rows) { + if (!isProviderSelected(settings, row.provider_id)) + continue + const key = modelUsageKey(row.provider_id, row.model_id) + const current = byKey.get(key) ?? { + key, + providerId: row.provider_id, + providerName: row.provider_name, + modelId: row.model_id, + displayName: row.display_name, + selected: isModelSelected(settings, row.provider_id, row.model_id), + requestCount: 0, + totalCostMicros: 0n, + totalCostFormatted: formatCost(0n, settings.currency), + } + current.requestCount += 1 + current.totalCostMicros += BigInt(row.total_cost_micros || '0') + current.totalCostFormatted = formatCost(current.totalCostMicros, settings.currency) + byKey.set(key, current) + } + return [...byKey.values()].sort((a, b) => Number(b.totalCostMicros - a.totalCostMicros) || a.displayName.localeCompare(b.displayName)) +} + +function toRecentItem(currency: UsageCurrency) { + return (row: UsageLogRow): UsageRecentItem => ({ + requestId: row.request_id, + providerName: row.provider_name, + displayName: row.display_name, + status: row.status, + totalCostFormatted: formatCost(BigInt(row.total_cost_micros || '0'), currency), + unpriced: row.unpriced === 1, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + cacheReadTokens: row.cache_read_tokens, + cacheWriteTokens: row.cache_write_tokens, + durationMs: row.duration_ms, + createdAt: row.created_at, + }) +} + +function sumMicros(rows: UsageLogRow[]): bigint { + return rows.reduce((sum, row) => sum + BigInt(row.total_cost_micros || '0'), 0n) +} + +export function serializeUsageDashboard(dashboard: UsageDashboard) { + return { + settings: dashboard.settings, + todayCostFormatted: dashboard.todayCostFormatted, + summary: { + ...dashboard.summary, + totalCostMicros: dashboard.summary.totalCostMicros.toString(), + }, + providers: dashboard.providers.map(provider => ({ + ...provider, + totalCostMicros: provider.totalCostMicros.toString(), + })), + models: dashboard.models.map(model => ({ + ...model, + totalCostMicros: model.totalCostMicros.toString(), + })), + daily: dashboard.daily.map(day => ({ + ...day, + totalCostMicros: day.totalCostMicros.toString(), + })), + recent: dashboard.recent, + } +} diff --git a/Cursor++/src/server/usage/types.ts b/Cursor++/src/server/usage/types.ts new file mode 100644 index 0000000..3f5f9f5 --- /dev/null +++ b/Cursor++/src/server/usage/types.ts @@ -0,0 +1,196 @@ +import type { ProviderType } from '../data/defaults' +import type { LLMUsage } from '../handlers/llm/types' + +export type UsageCurrency = 'CNY' | 'USD' + +export type UsageRangePreset = 'today' | '7d' | '14d' | '30d' + +export type UsageStatus = 'ok' | 'error' + +export interface ModelPricing { + inputCostPerMillion: string + outputCostPerMillion: string + cacheReadCostPerMillion: string + cacheCreationCostPerMillion: string + costMultiplier: string +} + +export interface NormalizedUsage { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +export interface CostBreakdown { + inputMicros: bigint + outputMicros: bigint + cacheReadMicros: bigint + cacheCreationMicros: bigint + totalMicros: bigint + unpriced: boolean + freshInputTokens: number +} + +export interface UsageSettings { + $schemaVersion: number + currency: UsageCurrency + range: UsageRangePreset + /** + * False + empty arrays = all providers/models. + * True + empty arrays = none (user unchecked everything). + * Newly added providers/models stay off until checked once this is true. + */ + filterCustomized?: boolean + selectedProviderIds: string[] + /** Keys are `${providerId}::${modelId}`. */ + selectedModelKeys: string[] +} + +export interface UsageLogRecord { + requestId: string + providerId: string + providerName: string + providerType: ProviderType + modelId: string + apiModel: string + displayName: string + conversationId?: string + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + inputCostMicros: bigint + outputCostMicros: bigint + cacheReadCostMicros: bigint + cacheCreationCostMicros: bigint + totalCostMicros: bigint + currency: UsageCurrency + unpriced: number + status: UsageStatus + errorMessage?: string + durationMs: number + createdAt: number +} + +export interface UsageLogRow { + request_id: string + provider_id: string + provider_name: string + provider_type: string + model_id: string + api_model: string + display_name: string + conversation_id: string | null + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + input_cost_micros: string + output_cost_micros: string + cache_read_cost_micros: string + cache_creation_cost_micros: string + total_cost_micros: string + currency: string + unpriced: number + status: string + error_message: string | null + duration_ms: number + created_at: number +} + +export interface UsageHeroSummary { + requestCount: number + okCount: number + totalCostMicros: bigint + totalCostFormatted: string + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + freshInputTokens: number + realTotalTokens: number + cacheHitRate: number + unpricedCount: number +} + +export interface UsageProviderStat { + id: string + name: string + type: string + selected: boolean + requestCount: number + totalCostMicros: bigint + totalCostFormatted: string +} + +export interface UsageModelStat { + key: string + providerId: string + providerName: string + modelId: string + displayName: string + selected: boolean + requestCount: number + totalCostMicros: bigint + totalCostFormatted: string +} + +export interface UsageRecentItem { + requestId: string + providerName: string + displayName: string + status: string + totalCostFormatted: string + unpriced: boolean + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + durationMs: number + createdAt: number +} + +/** Per-local-day rollup used by the daily cost trend bars. */ +export interface UsageDailyStat { + /** Local date, formatted as MM-DD. */ + date: string + requestCount: number + okCount: number + realTotalTokens: number + totalCostMicros: bigint + totalCostFormatted: string +} + +export interface UsageDashboard { + settings: UsageSettings + todayCostFormatted: string + summary: UsageHeroSummary + providers: UsageProviderStat[] + models: UsageModelStat[] + daily: UsageDailyStat[] + recent: UsageRecentItem[] +} + +export function normalizeUsage(usage?: LLMUsage): NormalizedUsage { + return { + inputTokens: Math.max(0, usage?.inputTokens ?? 0), + outputTokens: Math.max(0, usage?.outputTokens ?? 0), + cacheReadTokens: Math.max(0, usage?.cacheReadTokens ?? 0), + cacheWriteTokens: Math.max(0, usage?.cacheWriteTokens ?? 0), + } +} + +export function modelUsageKey(providerId: string, modelId: string): string { + return `${providerId}::${modelId}` +} + +export function pricingFromModel(model?: Partial | null): ModelPricing { + return { + inputCostPerMillion: model?.inputCostPerMillion ?? '0', + outputCostPerMillion: model?.outputCostPerMillion ?? '0', + cacheReadCostPerMillion: model?.cacheReadCostPerMillion ?? '0', + cacheCreationCostPerMillion: model?.cacheCreationCostPerMillion ?? '0', + costMultiplier: model?.costMultiplier ?? '1', + } +} diff --git a/Cursor++/src/ui/components/layout.tsx b/Cursor++/src/ui/components/layout.tsx index 544139b..dabe59d 100644 --- a/Cursor++/src/ui/components/layout.tsx +++ b/Cursor++/src/ui/components/layout.tsx @@ -11,6 +11,7 @@ import { WebToolsButton, WebToolsDialog } from './search-section' import { Server } from './server' import { styles } from './styles' import { ToastContainer } from './toast' +import { Usage } from './usage' function Layout({ webviewJs, codiconUri }: { webviewJs: string, codiconUri?: string }) { const codiconCss = codiconUri @@ -40,6 +41,18 @@ function Layout({ webviewJs, codiconUri }: { webviewJs: string, codiconUri?: str +

+ Usage + + + + +

+ +

Providers diff --git a/Cursor++/src/ui/components/model-card.tsx b/Cursor++/src/ui/components/model-card.tsx index 2698037..c92f59d 100644 --- a/Cursor++/src/ui/components/model-card.tsx +++ b/Cursor++/src/ui/components/model-card.tsx @@ -295,6 +295,59 @@ export function ModelCard() { +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ {/* Tooltip */}
diff --git a/Cursor++/src/ui/components/styles.ts b/Cursor++/src/ui/components/styles.ts index 103d385..940d35c 100644 --- a/Cursor++/src/ui/components/styles.ts +++ b/Cursor++/src/ui/components/styles.ts @@ -305,4 +305,42 @@ export const styles = /* css */ ` .toast-leave { animation: toast-out .15s ease-in; } @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(8px); } } + + .usage-panel { display: flex; flex-direction: column; gap: 8px; padding: 4px 0 8px; } + .usage-hero { padding: 8px 0 2px; } + .usage-today { font-size: 22px; font-weight: 650; letter-spacing: -0.3px; } + .usage-today-label { font-size: 10px; opacity: 0.6; margin-top: 2px; } + .usage-toolbar { display: flex; gap: 6px; align-items: center; } + .usage-toolbar select { width: auto; flex: 1; } + .usage-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } + .usage-metric { background: var(--vscode-editor-background); border: 1px solid var(--vscode-widget-border); border-radius: 4px; padding: 6px 8px; } + .usage-metric-label { display: block; font-size: 10px; opacity: 0.6; } + .usage-metric-value { font-size: 12px; font-weight: 600; } + .usage-trend { display: flex; flex-direction: column; gap: 2px; } + .usage-trend-bars { display: flex; align-items: flex-end; justify-content: center; gap: 2px; height: 36px; border-bottom: 1px solid var(--vscode-widget-border); } + .usage-trend-bar { flex: 1; max-width: 28px; min-width: 0; height: 100%; display: flex; align-items: flex-end; } + .usage-trend-fill { width: 100%; background: var(--vscode-charts-green, #4ec9b0); border-radius: 1px 1px 0 0; } + .usage-trend-axis { display: flex; justify-content: space-between; font-size: 9px; opacity: 0.5; font-variant-numeric: tabular-nums; } + .usage-unpriced { font-size: 10px; color: var(--vscode-errorForeground); } + .usage-section-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.4px; opacity: 0.7; margin-top: 4px; } + .usage-hint { font-size: 10px; opacity: 0.55; } + .usage-check { display: flex; align-items: center; gap: 6px; font-size: 11px; margin: 0; } + .usage-check input { width: auto; } + .usage-check-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .usage-check-sub { opacity: 0.55; } + .usage-check-cost { font-variant-numeric: tabular-nums; opacity: 0.8; } + .usage-provider { display: flex; flex-direction: column; } + .usage-provider-row { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 2px 0; } + .usage-provider-toggle { width: 12px; flex-shrink: 0; cursor: pointer; opacity: 0.6; font-size: 9px; text-align: center; user-select: none; } + .usage-models { margin: 1px 0 3px 20px; padding-left: 6px; border-left: 1px solid var(--vscode-widget-border); display: flex; flex-direction: column; gap: 1px; } + .usage-check-nested { font-size: 10.5px; } + .usage-row-time { opacity: 0.5; font-variant-numeric: tabular-nums; margin-right: 5px; } + .usage-show-more { background: none; border: none; color: var(--vscode-textLink-foreground); cursor: pointer; font-size: 10px; padding: 2px 0; text-align: left; } + .usage-empty { font-size: 11px; opacity: 0.55; padding: 4px 0; } + .usage-row { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; padding: 3px 0; border-bottom: 1px solid var(--vscode-widget-border); cursor: pointer; } + .usage-row:hover { background: var(--vscode-list-hoverBackground); } + .usage-row-main { flex: 1; min-width: 0; overflow: hidden; } + .usage-row-line { display: flex; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .usage-row-detail { display: flex; gap: 8px; font-size: 10px; opacity: 0.65; margin-top: 2px; font-variant-numeric: tabular-nums; white-space: nowrap; } + .usage-row-cost { font-variant-numeric: tabular-nums; white-space: nowrap; } ` diff --git a/Cursor++/src/ui/components/usage.tsx b/Cursor++/src/ui/components/usage.tsx new file mode 100644 index 0000000..c3c0b58 --- /dev/null +++ b/Cursor++/src/ui/components/usage.tsx @@ -0,0 +1,148 @@ +/** Usage dashboard in the Cursor++ sidebar */ +export function Usage() { + return ( +
+
+
+
Today (selected)
+
+ +
+ + + +
+ +
+
+ Cost + +
+
+ Requests + +
+
+ Success + +
+
+ Tokens + +
+
+ Cache hit + +
+
+ Cache write + +
+
+ +
+
+ +
+
+ + +
+
+ +
+ + {' unpriced requests — fill prices on model cards'} +
+ +
Providers
+
Click a name to expand its models. Unchecked providers stay recorded but are excluded from totals.
+ + + +
Recent
+ + + +
+ ) +} diff --git a/Cursor++/src/ui/panel-provider.ts b/Cursor++/src/ui/panel-provider.ts index d6af889..fa73303 100644 --- a/Cursor++/src/ui/panel-provider.ts +++ b/Cursor++/src/ui/panel-provider.ts @@ -19,6 +19,7 @@ import { bumpRefreshSignal } from '../server' import { searchCatalog } from '../server/config/catalogStore' import { updateProviders } from '../server/config/providersStore' import { resetProviderInstanceCache } from '../server/handlers/llm/providerRuntime' +import { onUsageRecorded } from '../server/usage/events' import { renderHtml } from './components/layout' import { getState, onStateChange, refreshState } from './state' @@ -40,6 +41,7 @@ export class PanelProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView private context: vscode.ExtensionContext private disposeStateListener?: vscode.Disposable + private disposeUsageListener?: () => void constructor(context: vscode.ExtensionContext) { this.context = context @@ -156,6 +158,32 @@ export class PanelProvider implements vscode.WebviewViewProvider { } break } + case 'loadUsage': + await this.postUsage() + break + case 'saveUsageSettings': { + try { + const { updateUsageSettings } = await import('../server/usage/settings') + await updateUsageSettings((draft) => { + if (msg.currency === 'USD' || msg.currency === 'CNY') + draft.currency = msg.currency + if (msg.range === 'today' || msg.range === '7d' || msg.range === '14d' || msg.range === '30d') + draft.range = msg.range + if (typeof msg.filterCustomized === 'boolean') + draft.filterCustomized = msg.filterCustomized + if (Array.isArray(msg.selectedProviderIds)) + draft.selectedProviderIds = msg.selectedProviderIds + if (Array.isArray(msg.selectedModelKeys)) + draft.selectedModelKeys = msg.selectedModelKeys + }) + await this.postUsage() + } + catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + this.view?.webview.postMessage({ type: 'toast', text: `Save usage settings failed: ${errMsg}`, level: 'error', duration: 6000 }) + } + break + } case 'saveWebTools': { try { const { updateWebTools } = await import('../server/config/searchConfigStore') @@ -218,9 +246,15 @@ export class PanelProvider implements vscode.WebviewViewProvider { this.disposeStateListener?.dispose() this.disposeStateListener = onStateChange(() => this.postState()) + this.disposeUsageListener?.() + this.disposeUsageListener = onUsageRecorded(() => { + void this.postUsage() + }) webviewView.onDidDispose(() => { this.disposeStateListener?.dispose() this.disposeStateListener = undefined + this.disposeUsageListener?.() + this.disposeUsageListener = undefined this.view = undefined }) } @@ -230,5 +264,39 @@ export class PanelProvider implements vscode.WebviewViewProvider { return const s = getState() this.view.webview.postMessage({ type: 'state', state: s }) + void this.postUsage() + } + + private async postUsage() { + if (!this.view) + return + try { + const { isAgentDatabaseReady } = await import('../server/database/sqlite') + const { loadUsageSettings } = await import('../server/usage/settings') + const { formatCost } = await import('../server/usage/calculator') + const { queryUsageDashboard, serializeUsageDashboard } = await import('../server/usage/store') + if (!isAgentDatabaseReady()) { + const settings = loadUsageSettings() + const zeroCost = formatCost(0n, settings.currency) + this.view.webview.postMessage({ + type: 'usage', + usage: { + settings, + todayCostFormatted: zeroCost, + summary: { requestCount: 0, realTotalTokens: 0, cacheHitRate: 0, unpricedCount: 0, totalCostFormatted: zeroCost }, + providers: [], + models: [], + recent: [], + }, + }) + return + } + const dashboard = await queryUsageDashboard(loadUsageSettings()) + this.view.webview.postMessage({ type: 'usage', usage: serializeUsageDashboard(dashboard) }) + } + catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + this.view.webview.postMessage({ type: 'toast', text: `Load usage failed: ${errMsg}`, level: 'error', duration: 4000 }) + } } } diff --git a/Cursor++/src/ui/webview/app.ts b/Cursor++/src/ui/webview/app.ts index a45befa..12c9813 100644 --- a/Cursor++/src/ui/webview/app.ts +++ b/Cursor++/src/ui/webview/app.ts @@ -85,6 +85,14 @@ export function initApp(Alpine: AlpineType) { webToolsOpen: false, webToolsTab: 'search' as 'search' | 'fetch', webTools: null as any, + usage: null as any, + usageOpen: false, + usageRange: 'today', + usageCurrency: 'CNY', + usageProviderExpanded: {} as Record, + usageShowAllProviders: false, + usageRecentLimit: 3, + usageRecentExpanded: {} as Record, isSearchProviderEnabled(type: string): boolean { return this.webTools?.search?.providers?.find((p: any) => p.type === type)?.enabled ?? false @@ -167,6 +175,165 @@ export function initApp(Alpine: AlpineType) { return out }, + get cacheHitLabel(): string { + const rate = this.usage?.summary?.cacheHitRate + if (typeof rate !== 'number') + return '—' + return `${Math.round(rate * 1000) / 10}%` + }, + + loadUsage() { + this.post('loadUsage') + }, + + toggleUsageOpen() { + this.usageOpen = !this.usageOpen + if (this.usageOpen) + this.loadUsage() + }, + + saveUsageSettings(options?: { customizeFilter?: boolean }) { + const selectedProviderIds = (this.usage?.providers || []).filter((p: any) => p.selected).map((p: any) => p.id) + const selectedModelKeys = (this.usage?.models || []).filter((m: any) => m.selected).map((m: any) => m.key) + this.post('saveUsageSettings', { + currency: this.usageCurrency, + range: this.usageRange, + filterCustomized: options?.customizeFilter ? true : this.usage?.settings?.filterCustomized, + selectedProviderIds, + selectedModelKeys, + }) + }, + + toggleUsageProvider(id: string, checked: boolean) { + const provider = (this.usage?.providers || []).find((p: any) => p.id === id) + if (provider) + provider.selected = checked + for (const model of this.usage?.models || []) { + if (model.providerId === id) + model.selected = checked + } + this.saveUsageSettings({ customizeFilter: true }) + }, + + toggleUsageModel(key: string, checked: boolean) { + const model = (this.usage?.models || []).find((m: any) => m.key === key) + if (model) { + model.selected = checked + if (checked) { + const provider = (this.usage?.providers || []).find((p: any) => p.id === model.providerId) + if (provider && !provider.selected) + provider.selected = true + } + } + this.saveUsageSettings({ customizeFilter: true }) + }, + + toggleUsageProviderExpanded(id: string) { + this.usageProviderExpanded[id] = !this.usageProviderExpanded[id] + }, + + usageModelsFor(providerId: string): any[] { + return (this.usage?.models || []).filter((m: any) => m.providerId === providerId) + }, + + get usageProvidersVisible(): any[] { + const all = this.usage?.providers || [] + if (this.usageShowAllProviders) + return all + return all.filter((p: any) => p.totalCostMicros !== '0') + }, + + get usageProvidersHiddenCount(): number { + return (this.usage?.providers || []).length - this.usageProvidersVisible.length + }, + + get usageHiddenProvidersLabel(): string { + if (this.usageShowAllProviders) + return 'Show fewer providers' + return `Show ${this.usageProvidersHiddenCount} providers with no usage` + }, + + get usageRecentToggleLabel(): string { + if (this.usageRecentLimit >= 30) + return 'Show less' + return `Show all (${this.usage?.recent?.length ?? 0})` + }, + + get usageRecentList(): any[] { + return (this.usage?.recent || []).slice(0, this.usageRecentLimit) + }, + + toggleUsageRecentExpanded(requestId: string) { + this.usageRecentExpanded[requestId] = !this.usageRecentExpanded[requestId] + }, + + get usageSuccessLabel(): string { + const summary = this.usage?.summary + if (!summary || !summary.requestCount) + return '—' + const percent = (summary.okCount / summary.requestCount) * 100 + return `${Math.round(percent * 10) / 10}%` + }, + + /** Per-day cost bars: precomputed heights + tooltip text for the template. */ + get usageDailyBars(): any[] { + const daily = this.usage?.daily || [] + let maxMicros = 0n + for (const day of daily) { + const cost = BigInt(day.totalCostMicros || '0') + if (cost > maxMicros) + maxMicros = cost + } + return daily.map((day: any) => { + const cost = BigInt(day.totalCostMicros || '0') + const heightPercent = maxMicros > 0n ? Number((cost * 100n) / maxMicros) : 0 + return { + date: day.date, + heightPercent: Math.max(day.requestCount > 0 && heightPercent === 0 ? 4 : heightPercent, 0), + title: `${day.date} · ${day.requestCount} req · ${day.totalCostFormatted}`, + } + }) + }, + + get usageTrendStartLabel(): string { + return (this.usage?.daily || [])[0]?.date || '' + }, + + get usageTrendEndLabel(): string { + const daily = this.usage?.daily || [] + return daily.length ? daily[daily.length - 1].date : '' + }, + + formatUsageDuration(durationMs: number): string { + if (!durationMs) + return '—' + if (durationMs < 1000) + return `${durationMs}ms` + return `${Math.round(durationMs / 100) / 10}s` + }, + + formatUsageTokens(count: number): string { + if (count >= 1_000_000) + return `${Math.round((count / 1_000_000) * 10) / 10}M` + if (count >= 1000) + return `${Math.round(count / 100) / 10}k` + return String(count ?? 0) + }, + + formatUsageTime(timestamp: number): string { + const date = new Date(timestamp) + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + }, + + formatUsageCost(formatted: string): string { + const symbol = (formatted || '').charAt(0) + const value = Number((formatted || '').slice(1)) + if (!Number.isFinite(value)) + return formatted + const rounded = value.toFixed(4) + return `${symbol}${rounded.endsWith('0') ? String(Number(rounded)) : rounded}` + }, + get serverLabel(): string { const s = this.state if (!s) @@ -893,6 +1060,17 @@ export function initApp(Alpine: AlpineType) { else if (msg?.type === 'toast') { s.toast(msg.text, msg.level || 'info', msg.duration ?? 4000) } + else if (msg?.type === 'usage') { + s.usage = msg.usage + if (msg.usage?.settings) { + s.usageRange = msg.usage.settings.range || 'today' + s.usageCurrency = msg.usage.settings.currency || 'CNY' + } + for (const provider of msg.usage?.providers || []) { + if (!(provider.id in s.usageProviderExpanded)) + s.usageProviderExpanded[provider.id] = provider.totalCostMicros !== '0' + } + } }) // 通知 extension 就绪 From b156b38dd3e64647f4e6b78d2b9f151953644bb8 Mon Sep 17 00:00:00 2001 From: yanghuiqi <318673409@qq.com> Date: Wed, 2 Sep 2026 17:02:55 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9ACursor++=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=8F=E5=AE=9E=E6=97=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=BB=8A=E6=97=A5=E7=94=A8=E9=87=8F=E4=B8=8E=E8=B4=B9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cursor++/package.json | 4 ++ Cursor++/src/extension.ts | 9 ++++ Cursor++/src/server/tests/usageStore.test.ts | 18 ++++++- Cursor++/src/server/usage/store.ts | 20 ++++++++ Cursor++/src/ui/panel-provider.ts | 13 ++++- Cursor++/src/ui/usage-statusbar.ts | 50 ++++++++++++++++++++ Cursor++/src/ui/webview/app.ts | 2 + 7 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 Cursor++/src/ui/usage-statusbar.ts diff --git a/Cursor++/package.json b/Cursor++/package.json index 24949cd..3cf15c3 100644 --- a/Cursor++/package.json +++ b/Cursor++/package.json @@ -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)" diff --git a/Cursor++/src/extension.ts b/Cursor++/src/extension.ts index d3b0083..2b989e2 100644 --- a/Cursor++/src/extension.ts +++ b/Cursor++/src/extension.ts @@ -13,6 +13,7 @@ import { getRoutesFilePath } from './server/routes' import { ensureUsageSettingsFile, onUsageSettingsChange, startUsageSettingsWatcher, stopUsageSettingsWatcher } from './server/usage/settings' import { PanelProvider } from './ui/panel-provider' import { getState, onStateChange, probeByokServer, refreshState, setFileLogState } from './ui/state' +import { refreshUsageStatusBar, registerUsageStatusBar } from './ui/usage-statusbar' import { startUpdateCheck, stopUpdateCheck } from './update-check' let outputChannel: vscode.LogOutputChannel @@ -466,6 +467,9 @@ export async function activate(context: vscode.ExtensionContext) { statusBarItem.show() context.subscriptions.push(statusBarItem) + // 用量状态栏 (今日费用 · 请求数), 点击打开 Usage 面板 + registerUsageStatusBar(context) + // 状态变化 → 刷新状态栏 context.subscriptions.push(onStateChange(() => renderStatusBar())) @@ -502,6 +506,10 @@ 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()), ) @@ -527,6 +535,7 @@ export async function activate(context: vscode.ExtensionContext) { }) const disposeUsageWatch = onUsageSettingsChange(async () => { await refreshState() + refreshUsageStatusBar() }) context.subscriptions.push({ dispose: disposeRoutesWatch }, { dispose: disposeProvidersWatch }, { dispose: disposeUsageWatch }) diff --git a/Cursor++/src/server/tests/usageStore.test.ts b/Cursor++/src/server/tests/usageStore.test.ts index 771a415..729e323 100644 --- a/Cursor++/src/server/tests/usageStore.test.ts +++ b/Cursor++/src/server/tests/usageStore.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resetAgentDatabaseForTests } from '../database/sqlite' -import { queryUsageDashboard, recordUsageLog } from '../usage/store' +import { queryTodaySummary, queryUsageDashboard, recordUsageLog } from '../usage/store' let tmpDbPath = '' @@ -255,4 +255,20 @@ describe('usage store', () => { expect(dashboard.daily[0].requestCount).toBe(1) expect(dashboard.daily[0].totalCostMicros).toBe(10_000n) }) + + it('summarizes today for the status bar per currency', async () => { + const now = Date.now() + await recordUsageLog(log({ requestId: 'a', totalCostMicros: 10_000n, createdAt: now })) + await recordUsageLog(log({ requestId: 'b', status: 'error', totalCostMicros: 2_500n, createdAt: now })) + + const summary = await queryTodaySummary('CNY') + expect(summary.requestCount).toBe(2) + expect(summary.okCount).toBe(1) + expect(summary.totalCostMicros).toBe(12_500n) + expect(summary.totalCostFormatted).toBe('¥0.012500') + + const usd = await queryTodaySummary('USD') + expect(usd.requestCount).toBe(0) + expect(usd.totalCostMicros).toBe(0n) + }) }) diff --git a/Cursor++/src/server/usage/store.ts b/Cursor++/src/server/usage/store.ts index 08b4856..b12a747 100644 --- a/Cursor++/src/server/usage/store.ts +++ b/Cursor++/src/server/usage/store.ts @@ -303,6 +303,26 @@ function sumMicros(rows: UsageLogRow[]): bigint { return rows.reduce((sum, row) => sum + BigInt(row.total_cost_micros || '0'), 0n) } +/** Lightweight today-only aggregate for the status bar (single SQL, no rows pulled). */ +export async function queryTodaySummary(currency: UsageCurrency): Promise<{ requestCount: number, okCount: number, totalCostMicros: bigint, totalCostFormatted: string }> { + const start = startOfLocalDay() + const rows = await getAgentDatabase().all<{ n: number, ok: number, cost: string | null }>( + `SELECT COUNT(*) AS n, + SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS ok, + SUM(CAST(total_cost_micros AS INTEGER)) AS cost + FROM usage_logs WHERE created_at >= ? AND currency = ?`, + [start, currency], + ) + const row = rows[0] + const totalCostMicros = BigInt(row?.cost ?? 0) + return { + requestCount: row?.n ?? 0, + okCount: row?.ok ?? 0, + totalCostMicros, + totalCostFormatted: formatCost(totalCostMicros, currency), + } +} + export function serializeUsageDashboard(dashboard: UsageDashboard) { return { settings: dashboard.settings, diff --git a/Cursor++/src/ui/panel-provider.ts b/Cursor++/src/ui/panel-provider.ts index fa73303..6631be8 100644 --- a/Cursor++/src/ui/panel-provider.ts +++ b/Cursor++/src/ui/panel-provider.ts @@ -259,6 +259,14 @@ export class PanelProvider implements vscode.WebviewViewProvider { }) } + private revealNext = false + + /** Status-bar entry point: refresh usage and ask the webview to expand the panel. */ + revealUsage() { + this.revealNext = true + void this.postUsage() + } + private postState() { if (!this.view) return @@ -270,6 +278,8 @@ export class PanelProvider implements vscode.WebviewViewProvider { private async postUsage() { if (!this.view) return + const reveal = this.revealNext + this.revealNext = false try { const { isAgentDatabaseReady } = await import('../server/database/sqlite') const { loadUsageSettings } = await import('../server/usage/settings') @@ -280,6 +290,7 @@ export class PanelProvider implements vscode.WebviewViewProvider { const zeroCost = formatCost(0n, settings.currency) this.view.webview.postMessage({ type: 'usage', + reveal, usage: { settings, todayCostFormatted: zeroCost, @@ -292,7 +303,7 @@ export class PanelProvider implements vscode.WebviewViewProvider { return } const dashboard = await queryUsageDashboard(loadUsageSettings()) - this.view.webview.postMessage({ type: 'usage', usage: serializeUsageDashboard(dashboard) }) + this.view.webview.postMessage({ type: 'usage', reveal, usage: serializeUsageDashboard(dashboard) }) } catch (err) { const errMsg = err instanceof Error ? err.message : String(err) diff --git a/Cursor++/src/ui/usage-statusbar.ts b/Cursor++/src/ui/usage-statusbar.ts new file mode 100644 index 0000000..3117141 --- /dev/null +++ b/Cursor++/src/ui/usage-statusbar.ts @@ -0,0 +1,50 @@ +/** + * Usage status-bar item — today cost / request count at a glance. + * + * Separate item from the BYOK toggle so users can hide it independently + * via the status-bar context menu. Refreshed on every usage record and + * on currency change; data comes from a single SQL aggregate. + */ +import type { ExtensionContext, StatusBarItem } from 'vscode' +import * as vscode from 'vscode' +import { onUsageRecorded } from '../server/usage/events' +import { loadUsageSettings } from '../server/usage/settings' +import { queryTodaySummary } from '../server/usage/store' + +let usageBarItem: StatusBarItem | null = null + +function formatBarCost(micros: bigint, currency: 'CNY' | 'USD'): string { + const symbol = currency === 'CNY' ? '\u00A5' : '$' + return `${symbol}${(Number(micros) / 1e6).toFixed(4)}` +} + +async function renderUsageBar() { + if (!usageBarItem) + return + try { + const settings = loadUsageSettings() + const summary = await queryTodaySummary(settings.currency) + usageBarItem.text = `${formatBarCost(summary.totalCostMicros, settings.currency)} · ${summary.requestCount} req` + usageBarItem.tooltip = `Cursor++ Usage — today (${settings.currency})\nCost ${summary.totalCostFormatted} · ${summary.requestCount} requests · ${summary.okCount} ok\n\nClick: open usage panel` + usageBarItem.show() + } + catch { + // agent DB not ready yet (e.g. server not started) — keep previous text + } +} + +export function registerUsageStatusBar(context: ExtensionContext): void { + usageBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99) + usageBarItem.command = 'cursor2plus.openUsage' + usageBarItem.show() + context.subscriptions.push(usageBarItem) + const disposeUsageListener = onUsageRecorded(() => { + void renderUsageBar() + }) + context.subscriptions.push({ dispose: disposeUsageListener }) + void renderUsageBar() +} + +export function refreshUsageStatusBar(): void { + void renderUsageBar() +} diff --git a/Cursor++/src/ui/webview/app.ts b/Cursor++/src/ui/webview/app.ts index 12c9813..24cd81d 100644 --- a/Cursor++/src/ui/webview/app.ts +++ b/Cursor++/src/ui/webview/app.ts @@ -1062,6 +1062,8 @@ export function initApp(Alpine: AlpineType) { } else if (msg?.type === 'usage') { s.usage = msg.usage + if (msg.reveal) + s.usageOpen = true if (msg.usage?.settings) { s.usageRange = msg.usage.settings.range || 'today' s.usageCurrency = msg.usage.settings.currency || 'CNY' From b5f4478aa0da1951a8169f20f1d2474097c82f0c Mon Sep 17 00:00:00 2001 From: yanghuiqi <318673409@qq.com> Date: Wed, 2 Sep 2026 17:45:22 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=8F=E4=B8=A4=E9=A1=B9=E6=94=AF=E6=8C=81=E5=9C=A8?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E5=8D=95=E7=8B=AC=E5=BC=80?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cursor++/src/extension.ts | 1 + Cursor++/src/ui/usage-statusbar.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/Cursor++/src/extension.ts b/Cursor++/src/extension.ts index 2b989e2..f7c3f47 100644 --- a/Cursor++/src/extension.ts +++ b/Cursor++/src/extension.ts @@ -463,6 +463,7 @@ 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) diff --git a/Cursor++/src/ui/usage-statusbar.ts b/Cursor++/src/ui/usage-statusbar.ts index 3117141..2e78a4c 100644 --- a/Cursor++/src/ui/usage-statusbar.ts +++ b/Cursor++/src/ui/usage-statusbar.ts @@ -35,6 +35,7 @@ async function renderUsageBar() { export function registerUsageStatusBar(context: ExtensionContext): void { usageBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99) + usageBarItem.name = 'Cursor++: Usage' usageBarItem.command = 'cursor2plus.openUsage' usageBarItem.show() context.subscriptions.push(usageBarItem) From 408a9962c9482a86a32d27be6e72ca0a4cdb33ec Mon Sep 17 00:00:00 2001 From: yanghuiqi <318673409@qq.com> Date: Wed, 2 Sep 2026 17:48:41 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=8F=E8=B4=B9=E7=94=A8=E5=8F=96=E6=95=B4=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=B8=94=E5=90=AF=E5=8A=A8=E5=8D=B3=E5=B8=B8=E9=A9=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cursor++/src/ui/usage-statusbar.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Cursor++/src/ui/usage-statusbar.ts b/Cursor++/src/ui/usage-statusbar.ts index 2e78a4c..63db25c 100644 --- a/Cursor++/src/ui/usage-statusbar.ts +++ b/Cursor++/src/ui/usage-statusbar.ts @@ -13,9 +13,21 @@ import { queryTodaySummary } from '../server/usage/store' let usageBarItem: StatusBarItem | null = null +function currencySymbol(currency: 'CNY' | 'USD'): string { + return currency === 'CNY' ? '\u00A5' : '$' +} + function formatBarCost(micros: bigint, currency: 'CNY' | 'USD'): string { - const symbol = currency === 'CNY' ? '\u00A5' : '$' - return `${symbol}${(Number(micros) / 1e6).toFixed(4)}` + return `${currencySymbol(currency)}${Math.round(Number(micros) / 1e6)}` +} + +function defaultBarText(): string { + try { + return `${formatBarCost(0n, loadUsageSettings().currency)} · 0 req` + } + catch { + return '\u00A50 · 0 req' + } } async function renderUsageBar() { @@ -29,7 +41,9 @@ async function renderUsageBar() { usageBarItem.show() } catch { - // agent DB not ready yet (e.g. server not started) — keep previous text + // agent DB not ready yet — show the placeholder instead of staying hidden + usageBarItem.text = defaultBarText() + usageBarItem.show() } } @@ -37,6 +51,7 @@ export function registerUsageStatusBar(context: ExtensionContext): void { usageBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99) usageBarItem.name = 'Cursor++: Usage' usageBarItem.command = 'cursor2plus.openUsage' + usageBarItem.text = defaultBarText() usageBarItem.show() context.subscriptions.push(usageBarItem) const disposeUsageListener = onUsageRecorded(() => { From 4c14b6ce8dab7969cff877625c6cdcb290cf0c26 Mon Sep 17 00:00:00 2001 From: yanghuiqi <318673409@qq.com> Date: Wed, 2 Sep 2026 17:55:04 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E4=BB=8A?= =?UTF-8?q?=E6=97=A5=E8=B4=B9=E7=94=A8=E5=B9=B6=E5=85=A5=20BYOK=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A0=8F=E5=B9=B6=E8=87=AA=E5=8A=A8=E6=B8=85?= =?UTF-8?q?=E7=90=86=E8=BF=87=E6=9C=9F=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cursor++/src/extension.ts | 13 ++-- Cursor++/src/server/tests/usageStore.test.ts | 20 +++++- Cursor++/src/server/usage/store.ts | 14 ++++ Cursor++/src/ui/usage-statusbar.ts | 75 +++++++++----------- 4 files changed, 75 insertions(+), 47 deletions(-) diff --git a/Cursor++/src/extension.ts b/Cursor++/src/extension.ts index f7c3f47..5c35cae 100644 --- a/Cursor++/src/extension.ts +++ b/Cursor++/src/extension.ts @@ -11,9 +11,10 @@ import { resetProviderInstanceCache } from './server/handlers/llm/providerRuntim 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 { refreshUsageStatusBar, registerUsageStatusBar } from './ui/usage-statusbar' +import { getUsageSuffix, getUsageTooltipLine, initUsageStatusBar, refreshUsageStatusBar } from './ui/usage-statusbar' import { startUpdateCheck, stopUpdateCheck } from './update-check' let outputChannel: vscode.LogOutputChannel @@ -358,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') @@ -468,8 +469,8 @@ export async function activate(context: vscode.ExtensionContext) { statusBarItem.show() context.subscriptions.push(statusBarItem) - // 用量状态栏 (今日费用 · 请求数), 点击打开 Usage 面板 - registerUsageStatusBar(context) + // 用量后缀挂在 BYOK 状态栏项上 (今日费用, 点击项仍是 BYOK 开关) + initUsageStatusBar(renderStatusBar) // 状态变化 → 刷新状态栏 context.subscriptions.push(onStateChange(() => renderStatusBar())) @@ -519,6 +520,8 @@ export async function activate(context: vscode.ExtensionContext) { await ensureRoutesFile() await ensureProvidersFile() ensureUsageSettingsFile() + // 清理超过保留期的用量明细, 防止 usage_logs 无限膨胀 + void pruneOldUsageLogs() // 文件监听: 其他实例修改配置时自动同步状态 + UI startRoutesWatcher() diff --git a/Cursor++/src/server/tests/usageStore.test.ts b/Cursor++/src/server/tests/usageStore.test.ts index 729e323..3bb9a90 100644 --- a/Cursor++/src/server/tests/usageStore.test.ts +++ b/Cursor++/src/server/tests/usageStore.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resetAgentDatabaseForTests } from '../database/sqlite' -import { queryTodaySummary, queryUsageDashboard, recordUsageLog } from '../usage/store' +import { pruneOldUsageLogs, queryTodaySummary, queryUsageDashboard, recordUsageLog } from '../usage/store' let tmpDbPath = '' @@ -271,4 +271,22 @@ describe('usage store', () => { expect(usd.requestCount).toBe(0) expect(usd.totalCostMicros).toBe(0n) }) + + it('prunes usage logs older than the retention window', async () => { + const now = Date.now() + await recordUsageLog(log({ requestId: 'old', totalCostMicros: 1_000n, createdAt: now - 91 * 24 * 60 * 60 * 1000 })) + await recordUsageLog(log({ requestId: 'new', totalCostMicros: 2_000n, createdAt: now })) + + await pruneOldUsageLogs(90) + + const dashboard = await queryUsageDashboard({ + $schemaVersion: 1, + currency: 'CNY', + range: '30d', + selectedProviderIds: [], + selectedModelKeys: [], + }) + expect(dashboard.summary.requestCount).toBe(1) + expect(dashboard.recent[0].requestId).toBe('new') + }) }) diff --git a/Cursor++/src/server/usage/store.ts b/Cursor++/src/server/usage/store.ts index b12a747..8401c9b 100644 --- a/Cursor++/src/server/usage/store.ts +++ b/Cursor++/src/server/usage/store.ts @@ -323,6 +323,20 @@ export async function queryTodaySummary(currency: UsageCurrency): Promise<{ requ } } +/** + * Delete usage logs older than the retention window (called once per + * activation) so the table stays small no matter how long the extension runs. + */ +export async function pruneOldUsageLogs(maxAgeDays = 90): Promise { + const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000 + try { + await getAgentDatabase().run('DELETE FROM usage_logs WHERE created_at < ?', [cutoff]) + } + catch (error) { + logger.warn({ error: (error as Error).message }, '[USAGE] prune failed') + } +} + export function serializeUsageDashboard(dashboard: UsageDashboard) { return { settings: dashboard.settings, diff --git a/Cursor++/src/ui/usage-statusbar.ts b/Cursor++/src/ui/usage-statusbar.ts index 63db25c..dbc21a6 100644 --- a/Cursor++/src/ui/usage-statusbar.ts +++ b/Cursor++/src/ui/usage-statusbar.ts @@ -1,66 +1,59 @@ /** - * Usage status-bar item — today cost / request count at a glance. + * Usage status-bar suffix — today cost appended to the BYOK item. * - * Separate item from the BYOK toggle so users can hide it independently - * via the status-bar context menu. Refreshed on every usage record and - * on currency change; data comes from a single SQL aggregate. + * Lives inside the existing BYOK status-bar item (no separate entry): + * `✓ BYOK ◉ ¥4` + * The precise cost stays available via the item tooltip. Refreshed on + * every usage record and on currency change; data comes from a single + * SQL aggregate. */ -import type { ExtensionContext, StatusBarItem } from 'vscode' -import * as vscode from 'vscode' import { onUsageRecorded } from '../server/usage/events' import { loadUsageSettings } from '../server/usage/settings' import { queryTodaySummary } from '../server/usage/store' -let usageBarItem: StatusBarItem | null = null +let rerenderBar: () => void = () => {} +let usageSuffix = '' +let usageTooltipLine = '' function currencySymbol(currency: 'CNY' | 'USD'): string { return currency === 'CNY' ? '\u00A5' : '$' } -function formatBarCost(micros: bigint, currency: 'CNY' | 'USD'): string { - return `${currencySymbol(currency)}${Math.round(Number(micros) / 1e6)}` -} - -function defaultBarText(): string { - try { - return `${formatBarCost(0n, loadUsageSettings().currency)} · 0 req` - } - catch { - return '\u00A50 · 0 req' - } -} - -async function renderUsageBar() { - if (!usageBarItem) - return +async function recompute() { try { const settings = loadUsageSettings() const summary = await queryTodaySummary(settings.currency) - usageBarItem.text = `${formatBarCost(summary.totalCostMicros, settings.currency)} · ${summary.requestCount} req` - usageBarItem.tooltip = `Cursor++ Usage — today (${settings.currency})\nCost ${summary.totalCostFormatted} · ${summary.requestCount} requests · ${summary.okCount} ok\n\nClick: open usage panel` - usageBarItem.show() + usageSuffix = ` ${currencySymbol(settings.currency)}${Math.round(Number(summary.totalCostMicros) / 1e6)}` + usageTooltipLine = `Today: ${summary.totalCostFormatted} · ${summary.requestCount} requests · ${summary.okCount} ok (${settings.currency})` } catch { - // agent DB not ready yet — show the placeholder instead of staying hidden - usageBarItem.text = defaultBarText() - usageBarItem.show() + // agent DB not ready yet — show no suffix instead of blocking the bar + usageSuffix = '' + usageTooltipLine = '' } + rerenderBar() } -export function registerUsageStatusBar(context: ExtensionContext): void { - usageBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99) - usageBarItem.name = 'Cursor++: Usage' - usageBarItem.command = 'cursor2plus.openUsage' - usageBarItem.text = defaultBarText() - usageBarItem.show() - context.subscriptions.push(usageBarItem) - const disposeUsageListener = onUsageRecorded(() => { - void renderUsageBar() +/** Attach today-cost suffix updates to the BYOK status-bar rerender cycle. */ +export function initUsageStatusBar(rerender: () => void): void { + rerenderBar = rerender + onUsageRecorded(() => { + void recompute() }) - context.subscriptions.push({ dispose: disposeUsageListener }) - void renderUsageBar() + void recompute() +} + +/** Suffix for statusBarItem.text, e.g. ` ¥4`. Empty while data is unavailable. */ +export function getUsageSuffix(): string { + return usageSuffix +} + +/** One-line today summary for the status-bar tooltip. Empty while unavailable. */ +export function getUsageTooltipLine(): string { + return usageTooltipLine } +/** Recompute suffix (e.g. after a currency switch) and refresh the bar. */ export function refreshUsageStatusBar(): void { - void renderUsageBar() + void recompute() } From 9b7209f5c5d1351e7bfaf3a93c9bc351035e72e6 Mon Sep 17 00:00:00 2001 From: yanghuiqi <318673409@qq.com> Date: Wed, 2 Sep 2026 17:58:08 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=A0=8F=E8=B4=B9=E7=94=A8=E5=90=8E=E7=BC=80=E5=9C=A8?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=88=9D=E6=9C=9F=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cursor++/src/extension.ts | 8 ++++++-- Cursor++/src/ui/usage-statusbar.ts | 26 +++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Cursor++/src/extension.ts b/Cursor++/src/extension.ts index 5c35cae..ba05869 100644 --- a/Cursor++/src/extension.ts +++ b/Cursor++/src/extension.ts @@ -472,8 +472,12 @@ export async function activate(context: vscode.ExtensionContext) { // 用量后缀挂在 BYOK 状态栏项上 (今日费用, 点击项仍是 BYOK 开关) initUsageStatusBar(renderStatusBar) - // 状态变化 → 刷新状态栏 - context.subscriptions.push(onStateChange(() => renderStatusBar())) + // 状态变化 → 刷新状态栏; server 就绪时同步刷新今日费用后缀 + context.subscriptions.push(onStateChange(() => { + renderStatusBar() + if (getState().server === 'local') + refreshUsageStatusBar() + })) // 侧边栏面板 const panelProvider = new PanelProvider(context) diff --git a/Cursor++/src/ui/usage-statusbar.ts b/Cursor++/src/ui/usage-statusbar.ts index dbc21a6..21de4dc 100644 --- a/Cursor++/src/ui/usage-statusbar.ts +++ b/Cursor++/src/ui/usage-statusbar.ts @@ -5,31 +5,50 @@ * `✓ BYOK ◉ ¥4` * The precise cost stays available via the item tooltip. Refreshed on * every usage record and on currency change; data comes from a single - * SQL aggregate. + * SQL aggregate. A failed first query (agent DB not initialized yet) + * schedules retries so the suffix appears without waiting for a request. */ import { onUsageRecorded } from '../server/usage/events' import { loadUsageSettings } from '../server/usage/settings' import { queryTodaySummary } from '../server/usage/store' +const RETRY_DELAY_MS = 10_000 +const RETRY_MAX = 6 + let rerenderBar: () => void = () => {} let usageSuffix = '' let usageTooltipLine = '' +let retryCount = 0 +let retryTimer: ReturnType | null = null function currencySymbol(currency: 'CNY' | 'USD'): string { return currency === 'CNY' ? '\u00A5' : '$' } +function scheduleRetry() { + if (retryTimer || retryCount >= RETRY_MAX) + return + retryCount += 1 + retryTimer = setTimeout(() => { + retryTimer = null + void recompute() + }, RETRY_DELAY_MS) +} + async function recompute() { try { const settings = loadUsageSettings() const summary = await queryTodaySummary(settings.currency) usageSuffix = ` ${currencySymbol(settings.currency)}${Math.round(Number(summary.totalCostMicros) / 1e6)}` usageTooltipLine = `Today: ${summary.totalCostFormatted} · ${summary.requestCount} requests · ${summary.okCount} ok (${settings.currency})` + retryCount = 0 } catch { - // agent DB not ready yet — show no suffix instead of blocking the bar + // agent DB not ready yet (server still starting) — clear the suffix + // and retry a few times so it appears without waiting for a request usageSuffix = '' usageTooltipLine = '' + scheduleRetry() } rerenderBar() } @@ -53,7 +72,8 @@ export function getUsageTooltipLine(): string { return usageTooltipLine } -/** Recompute suffix (e.g. after a currency switch) and refresh the bar. */ +/** Recompute suffix (e.g. after a currency switch or server start) and refresh the bar. */ export function refreshUsageStatusBar(): void { + retryCount = 0 void recompute() } From 03de473555ebf706cafe61ded4a6734c88811eed Mon Sep 17 00:00:00 2001 From: yanghuiqi <318673409@qq.com> Date: Wed, 2 Sep 2026 18:07:25 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E9=87=91=E9=A2=9D=E7=BB=9F=E4=B8=80=E5=9B=9B=E4=BD=8D?= =?UTF-8?q?=E5=B0=8F=E6=95=B0=E5=B9=B6=E8=81=94=E5=8A=A8=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cursor++/src/ui/components/usage.tsx | 13 ++++++++++--- Cursor++/src/ui/webview/app.ts | 12 +++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Cursor++/src/ui/components/usage.tsx b/Cursor++/src/ui/components/usage.tsx index c3c0b58..b9aab0b 100644 --- a/Cursor++/src/ui/components/usage.tsx +++ b/Cursor++/src/ui/components/usage.tsx @@ -3,8 +3,11 @@ export function Usage() { return (
-
-
Today (selected)
+
+
+ + +
@@ -24,7 +27,7 @@ export function Usage() {
Cost - +
Requests @@ -67,6 +70,10 @@ export function Usage() { {' unpriced requests — fill prices on model cards'}
+
+ No records for this currency and range. Bills are stored with the currency used at request time — try the other currency. +
+
Providers
Click a name to expand its models. Unchecked providers stay recorded but are excluded from totals.