diff --git a/src/README.md b/src/README.md index ad08cfe..484b25c 100644 --- a/src/README.md +++ b/src/README.md @@ -42,7 +42,9 @@ Contains pricing information for AI models, including input and output token cos "model-name": { "inputCostPerMillion": 1.75, "outputCostPerMillion": 14.0, - "category": "Model category" + "category": "Model category", + "tier": "standard|premium|unknown", + "multiplier": 1 } } } diff --git a/src/extension.ts b/src/extension.ts index 3c90241..6f82f50 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,6 +26,8 @@ interface ModelPricing { inputCostPerMillion: number; outputCostPerMillion: number; category?: string; + tier?: 'standard' | 'premium' | 'unknown'; + multiplier?: number; displayNames?: string[]; } @@ -79,6 +81,13 @@ interface SessionUsageAnalysis { modeUsage: ModeUsage; contextReferences: ContextReferenceUsage; mcpTools: McpToolUsage; + modelSwitching: { + uniqueModels: string[]; + modelCount: number; + switchCount: number; + tiers: { standard: string[]; premium: string[]; unknown: string[] }; + hasMixedTiers: boolean; + }; } interface ToolCallUsage { @@ -108,8 +117,22 @@ interface McpToolUsage { byTool: { [toolName: string]: number }; } +interface ModelSwitchingAnalysis { + modelsPerSession: number[]; // Array of unique model counts per session + totalSessions: number; + averageModelsPerSession: number; + maxModelsPerSession: number; + minModelsPerSession: number; + switchingFrequency: number; // % of sessions with >1 model + standardModels: string[]; // Unique standard models used + premiumModels: string[]; // Unique premium models used + unknownModels: string[]; // Unique models with unknown tier + mixedTierSessions: number; // Sessions using both standard and premium +} + interface UsageAnalysisStats { today: UsageAnalysisPeriod; + last30Days: UsageAnalysisPeriod; month: UsageAnalysisPeriod; lastUpdated: Date; } @@ -120,6 +143,7 @@ interface UsageAnalysisPeriod { modeUsage: ModeUsage; contextReferences: ContextReferenceUsage; mcpTools: McpToolUsage; + modelSwitching: ModelSwitchingAnalysis; } // Detailed session file information for diagnostics view @@ -169,6 +193,9 @@ interface SessionLogData { } class CopilotTokenTracker implements vscode.Disposable { + // Cache version - increment this when making changes that require cache invalidation + private static readonly CACHE_VERSION = 8; // Skip sessions with 0 models in avg calculation (2026-02-02) + private diagnosticsPanel?: vscode.WebviewPanel; // Tracks whether the diagnostics panel has already received its session files private diagnosticsHasLoadedFiles: boolean = false; @@ -195,13 +222,15 @@ class CopilotTokenTracker implements vscode.Disposable { private co2Per1kTokens = 0.2; // gCO2e per 1000 tokens, a rough estimate private co2AbsorptionPerTreePerYear = 21000; // grams of CO2 per tree per year private waterUsagePer1kTokens = 0.3; // liters of water per 1000 tokens, based on data center usage estimates + private _cacheHits = 0; // Counter for cache hits during usage analysis + private _cacheMisses = 0; // Counter for cache misses during usage analysis // Model pricing data - loaded from modelPricing.json // Reference: OpenAI API Pricing (https://openai.com/api/pricing/) - Retrieved December 2025 // Reference: Anthropic Claude Pricing (https://www.anthropic.com/pricing) - Standard rates // Note: GitHub Copilot uses these models but pricing may differ from direct API usage // These are reference prices for cost estimation purposes only - private modelPricing: { [key: string]: ModelPricing } = modelPricingData.pricing; + private modelPricing: { [key: string]: ModelPricing } = modelPricingData.pricing as { [key: string]: ModelPricing }; // Helper method to get repository URL from package.json private getRepositoryUrl(): string { @@ -333,6 +362,14 @@ class CopilotTokenTracker implements vscode.Disposable { // Persistent cache storage methods private loadCacheFromStorage(): void { try { + // Check cache version first + const storedVersion = this.context.globalState.get('sessionFileCacheVersion'); + if (storedVersion !== CopilotTokenTracker.CACHE_VERSION) { + this.log(`Cache version mismatch (stored: ${storedVersion}, current: ${CopilotTokenTracker.CACHE_VERSION}). Clearing cache.`); + this.sessionFileCache = new Map(); + return; + } + const cacheData = this.context.globalState.get>('sessionFileCache'); if (cacheData) { this.sessionFileCache = new Map(Object.entries(cacheData)); @@ -352,7 +389,8 @@ class CopilotTokenTracker implements vscode.Disposable { // Convert Map to plain object for storage const cacheData = Object.fromEntries(this.sessionFileCache); await this.context.globalState.update('sessionFileCache', cacheData); - this.log(`Saved ${this.sessionFileCache.size} cached session files to storage`); + await this.context.globalState.update('sessionFileCacheVersion', CopilotTokenTracker.CACHE_VERSION); + this.log(`Saved ${this.sessionFileCache.size} cached session files to storage (version ${CopilotTokenTracker.CACHE_VERSION})`); } catch (error) { this.error('Error saving cache to storage:', error); } @@ -926,8 +964,13 @@ class CopilotTokenTracker implements vscode.Disposable { private async calculateUsageAnalysisStats(): Promise { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const last30DaysStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + this.log('🔍 [Usage Analysis] Starting calculation...'); + this._cacheHits = 0; // Reset cache hit counter + this._cacheMisses = 0; // Reset cache miss counter + const emptyPeriod = (): UsageAnalysisPeriod => ({ sessions: 0, toolCalls: { total: 0, byTool: {} }, @@ -941,26 +984,49 @@ class CopilotTokenTracker implements vscode.Disposable { terminal: 0, vscode: 0 }, - mcpTools: { total: 0, byServer: {}, byTool: {} } + mcpTools: { total: 0, byServer: {}, byTool: {} }, + modelSwitching: { + modelsPerSession: [], + totalSessions: 0, + averageModelsPerSession: 0, + maxModelsPerSession: 0, + minModelsPerSession: 0, + switchingFrequency: 0, + standardModels: [], + premiumModels: [], + unknownModels: [], + mixedTierSessions: 0 + } }); const todayStats = emptyPeriod(); + const last30DaysStats = emptyPeriod(); const monthStats = emptyPeriod(); try { const sessionFiles = await this.getCopilotSessionFiles(); - this.log(`Processing ${sessionFiles.length} session files for usage analysis`); + this.log(`🔍 [Usage Analysis] Processing ${sessionFiles.length} session files`); + let processed = 0; + const progressInterval = Math.max(1, Math.floor(sessionFiles.length / 20)); // Log every 5% + for (const sessionFile of sessionFiles) { try { const fileStats = fs.statSync(sessionFile); - if (fileStats.mtime >= monthStart) { + // Check if file is within the last 30 days (widest range) + if (fileStats.mtime >= last30DaysStart) { const analysis = await this.getUsageAnalysisFromSessionCached(sessionFile, fileStats.mtime.getTime()); - // Add to month stats - monthStats.sessions++; - this.mergeUsageAnalysis(monthStats, analysis); + // Add to last 30 days stats + last30DaysStats.sessions++; + this.mergeUsageAnalysis(last30DaysStats, analysis); + + // Add to month stats if modified this calendar month + if (fileStats.mtime >= monthStart) { + monthStats.sessions++; + this.mergeUsageAnalysis(monthStats, analysis); + } // Add to today stats if modified today if (fileStats.mtime >= todayStart) { @@ -968,16 +1034,26 @@ class CopilotTokenTracker implements vscode.Disposable { this.mergeUsageAnalysis(todayStats, analysis); } } + + processed++; + if (processed % progressInterval === 0) { + this.log(`🔍 [Usage Analysis] Progress: ${processed}/${sessionFiles.length} files (${Math.round(processed/sessionFiles.length*100)}%)`); + } } catch (fileError) { this.warn(`Error processing session file ${sessionFile} for usage analysis: ${fileError}`); + processed++; } } } catch (error) { this.error('Error calculating usage analysis stats:', error); } + // Log cache statistics + this.log(`🔍 [Usage Analysis] Cache stats: ${this._cacheHits} hits, ${this._cacheMisses} misses`); + return { today: todayStats, + last30Days: last30DaysStats, month: monthStats, lastUpdated: now }; @@ -1015,14 +1091,65 @@ class CopilotTokenTracker implements vscode.Disposable { for (const [tool, count] of Object.entries(analysis.mcpTools.byTool)) { period.mcpTools.byTool[tool] = (period.mcpTools.byTool[tool] || 0) + count; } + + // Merge model switching data + // Ensure modelSwitching exists (backward compatibility with old cache) + if (!analysis.modelSwitching) { + analysis.modelSwitching = { + uniqueModels: [], + modelCount: 0, + switchCount: 0, + tiers: { standard: [], premium: [], unknown: [] }, + hasMixedTiers: false + }; + } + + // Only count sessions with at least 1 model detected for model switching stats + // Sessions without detected models (modelCount === 0) should not affect the average + if (analysis.modelSwitching.modelCount > 0) { + period.modelSwitching.totalSessions++; + period.modelSwitching.modelsPerSession.push(analysis.modelSwitching.modelCount); + + // Track unique models by tier + for (const model of analysis.modelSwitching.tiers.standard) { + if (!period.modelSwitching.standardModels.includes(model)) { + period.modelSwitching.standardModels.push(model); + } + } + for (const model of analysis.modelSwitching.tiers.premium) { + if (!period.modelSwitching.premiumModels.includes(model)) { + period.modelSwitching.premiumModels.push(model); + } + } + for (const model of analysis.modelSwitching.tiers.unknown) { + if (!period.modelSwitching.unknownModels.includes(model)) { + period.modelSwitching.unknownModels.push(model); + } + } + + // Count sessions with mixed tiers + if (analysis.modelSwitching.hasMixedTiers) { + period.modelSwitching.mixedTierSessions++; + } + + // Calculate aggregate statistics + if (period.modelSwitching.modelsPerSession.length > 0) { + const counts = period.modelSwitching.modelsPerSession; + period.modelSwitching.averageModelsPerSession = counts.reduce((a, b) => a + b, 0) / counts.length; + period.modelSwitching.maxModelsPerSession = Math.max(...counts); + period.modelSwitching.minModelsPerSession = Math.min(...counts); + period.modelSwitching.switchingFrequency = (counts.filter(c => c > 1).length / counts.length) * 100; + } + } } private async countInteractionsInSession(sessionFile: string): Promise { try { const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); - // Handle .jsonl files (Copilot CLI format and VS Code incremental format) - if (sessionFile.endsWith('.jsonl')) { + // Handle .jsonl files OR .json files with JSONL content (Copilot CLI format and VS Code incremental format) + const isJsonlContent = sessionFile.endsWith('.jsonl') || this.isJsonlContent(fileContent); + if (isJsonlContent) { const lines = fileContent.trim().split('\n'); let interactions = 0; for (const line of lines) { @@ -1066,12 +1193,16 @@ class CopilotTokenTracker implements vscode.Disposable { private async getModelUsageFromSession(sessionFile: string): Promise { const modelUsage: ModelUsage = {}; + const fileName = sessionFile.split(/[/\\]/).pop() || sessionFile; try { const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); - // Handle .jsonl files (Copilot CLI format and VS Code incremental format) - if (sessionFile.endsWith('.jsonl')) { + // Detect JSONL content: either by extension or by content analysis + const isJsonlContent = sessionFile.endsWith('.jsonl') || this.isJsonlContent(fileContent); + + // Handle .jsonl files OR .json files with JSONL content (Copilot CLI format and VS Code incremental format) + if (isJsonlContent) { const lines = fileContent.trim().split('\n'); // Default model for CLI sessions - they may not specify the model per event let defaultModel = 'gpt-4o'; @@ -1081,14 +1212,25 @@ class CopilotTokenTracker implements vscode.Disposable { try { const event = JSON.parse(line); - // Handle VS Code incremental format - extract model from session header - if (event.kind === 0 && event.v?.inputState?.selectedModel?.metadata?.id) { - defaultModel = event.v.inputState.selectedModel.metadata.id; + // Handle VS Code incremental format - extract model from session header (kind: 0) + // The schema has v.selectedModel.identifier or v.selectedModel.metadata.id + if (event.kind === 0) { + const modelId = event.v?.selectedModel?.identifier || + event.v?.selectedModel?.metadata?.id || + // Legacy fallback: older Copilot Chat session logs stored selectedModel under v.inputState. + // This is kept for backward compatibility so we can still read existing logs from those versions. + event.v?.inputState?.selectedModel?.metadata?.id; + if (modelId) { + defaultModel = modelId.replace(/^copilot\//, ''); + } } - // Handle model changes (kind: 1 with selectedModel update) - if (event.kind === 1 && event.k?.includes('selectedModel') && event.v?.metadata?.id) { - defaultModel = event.v.metadata.id; + // Handle model changes (kind: 2 with selectedModel update, NOT kind: 1 which is delete) + if (event.kind === 2 && event.k?.[0] === 'selectedModel') { + const modelId = event.v?.identifier || event.v?.metadata?.id; + if (modelId) { + defaultModel = modelId.replace(/^copilot\//, ''); + } } const model = event.model || defaultModel; @@ -1110,8 +1252,39 @@ class CopilotTokenTracker implements vscode.Disposable { // Handle VS Code incremental format (kind: 2 with requests) if (event.kind === 2 && event.k?.[0] === 'requests' && Array.isArray(event.v)) { for (const request of event.v) { + // Extract request-level modelId if available + let requestModel = model; + if (request.modelId) { + requestModel = request.modelId.replace(/^copilot\//, ''); + } else if (request.result?.metadata?.modelId) { + requestModel = request.result.metadata.modelId.replace(/^copilot\//, ''); + } else if (request.result?.details) { + // Parse model from details string like "Claude Opus 4.5 • 3x" + requestModel = this.getModelFromRequest(request); + } + + if (!modelUsage[requestModel]) { + modelUsage[requestModel] = { inputTokens: 0, outputTokens: 0 }; + } + if (request.message?.text) { - modelUsage[model].inputTokens += this.estimateTokensFromText(request.message.text, model); + modelUsage[requestModel].inputTokens += this.estimateTokensFromText(request.message.text, requestModel); + } + // Also process message.parts if available + if (request.message?.parts && Array.isArray(request.message.parts)) { + for (const part of request.message.parts) { + if (part.text && part.text !== request.message?.text) { + modelUsage[requestModel].inputTokens += this.estimateTokensFromText(part.text, requestModel); + } + } + } + // Process response items if present in the request + if (request.response && Array.isArray(request.response)) { + for (const responseItem of request.response) { + if (responseItem.value) { + modelUsage[requestModel].outputTokens += this.estimateTokensFromText(responseItem.value, requestModel); + } + } } } } @@ -1190,14 +1363,22 @@ class CopilotTokenTracker implements vscode.Disposable { terminal: 0, vscode: 0 }, - mcpTools: { total: 0, byServer: {}, byTool: {} } + mcpTools: { total: 0, byServer: {}, byTool: {} }, + modelSwitching: { + uniqueModels: [], + modelCount: 0, + switchCount: 0, + tiers: { standard: [], premium: [], unknown: [] }, + hasMixedTiers: false + } }; try { const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); - // Handle .jsonl files (Copilot CLI format and VS Code incremental format) - if (sessionFile.endsWith('.jsonl')) { + // Handle .jsonl files OR .json files with JSONL content (Copilot CLI format and VS Code incremental format) + const isJsonlContent = sessionFile.endsWith('.jsonl') || this.isJsonlContent(fileContent); + if (isJsonlContent) { const lines = fileContent.trim().split('\n'); let sessionMode = 'ask'; // Default mode @@ -1288,6 +1469,8 @@ class CopilotTokenTracker implements vscode.Disposable { // Skip malformed lines } } + // Calculate model switching for JSONL files before returning + await this.calculateModelSwitching(sessionFile, analysis); return analysis; } @@ -1409,9 +1592,77 @@ class CopilotTokenTracker implements vscode.Disposable { this.warn(`Error analyzing session usage from ${sessionFile}: ${error}`); } + // Calculate model switching statistics from session + await this.calculateModelSwitching(sessionFile, analysis); + return analysis; } + /** + * Calculate model switching statistics for a session file. + * This method updates the analysis.modelSwitching field in place. + */ + private async calculateModelSwitching(sessionFile: string, analysis: SessionUsageAnalysis): Promise { + try { + // Use non-cached method to avoid circular dependency + // (getSessionFileDataCached -> analyzeSessionUsage -> getModelUsageFromSessionCached -> getSessionFileDataCached) + const modelUsage = await this.getModelUsageFromSession(sessionFile); + const modelCount = modelUsage ? Object.keys(modelUsage).length : 0; + + // Skip if modelUsage is undefined or empty (not a valid session file) + if (!modelUsage || modelCount === 0) { + return; + } + + // Get unique models from this session + const uniqueModels = Object.keys(modelUsage); + analysis.modelSwitching.uniqueModels = uniqueModels; + analysis.modelSwitching.modelCount = uniqueModels.length; + + // Classify models by tier + const standardModels: string[] = []; + const premiumModels: string[] = []; + const unknownModels: string[] = []; + + for (const model of uniqueModels) { + const tier = this.getModelTier(model); + if (tier === 'standard') { + standardModels.push(model); + } else if (tier === 'premium') { + premiumModels.push(model); + } else { + unknownModels.push(model); + } + } + + analysis.modelSwitching.tiers = { standard: standardModels, premium: premiumModels, unknown: unknownModels }; + analysis.modelSwitching.hasMixedTiers = standardModels.length > 0 && premiumModels.length > 0; + + // Count model switches by examining request sequence (for JSON files only - not JSONL) + const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); + const isJsonlContent = sessionFile.endsWith('.jsonl') || this.isJsonlContent(fileContent); + if (!isJsonlContent) { + const sessionContent = JSON.parse(fileContent); + if (sessionContent.requests && Array.isArray(sessionContent.requests)) { + let previousModel: string | null = null; + let switchCount = 0; + + for (const request of sessionContent.requests) { + const currentModel = this.getModelFromRequest(request); + if (previousModel && currentModel !== previousModel) { + switchCount++; + } + previousModel = currentModel; + } + + analysis.modelSwitching.switchCount = switchCount; + } + } + } catch (error) { + this.warn(`Error calculating model switching for ${sessionFile}: ${error}`); + } + } + /** * Analyze text for context references like #file, #selection, @workspace */ @@ -1464,9 +1715,11 @@ class CopilotTokenTracker implements vscode.Disposable { // Check if we have valid cached data const cached = this.getCachedSessionData(sessionFilePath); if (cached && cached.mtime === mtime) { + this._cacheHits++; return cached; } + this._cacheMisses++; // Cache miss - read and process the file once to get all data const tokens = await this.estimateTokensFromSession(sessionFilePath); const interactions = await this.countInteractionsInSession(sessionFilePath); @@ -1502,7 +1755,7 @@ class CopilotTokenTracker implements vscode.Disposable { private async getUsageAnalysisFromSessionCached(sessionFile: string, mtime: number): Promise { const sessionData = await this.getSessionFileDataCached(sessionFile, mtime); - return sessionData.usageAnalysis || { + const analysis = sessionData.usageAnalysis || { toolCalls: { total: 0, byTool: {} }, modeUsage: { ask: 0, edit: 0, agent: 0 }, contextReferences: { @@ -1514,8 +1767,28 @@ class CopilotTokenTracker implements vscode.Disposable { terminal: 0, vscode: 0 }, - mcpTools: { total: 0, byServer: {}, byTool: {} } + mcpTools: { total: 0, byServer: {}, byTool: {} }, + modelSwitching: { + uniqueModels: [], + modelCount: 0, + switchCount: 0, + tiers: { standard: [], premium: [], unknown: [] }, + hasMixedTiers: false + } }; + + // Ensure modelSwitching field exists for backward compatibility with old cache + if (!analysis.modelSwitching) { + analysis.modelSwitching = { + uniqueModels: [], + modelCount: 0, + switchCount: 0, + tiers: { standard: [], premium: [], unknown: [] }, + hasMixedTiers: false + }; + } + + return analysis; } /** @@ -1557,8 +1830,9 @@ class CopilotTokenTracker implements vscode.Disposable { try { const fileContent = await fs.promises.readFile(sessionFile, 'utf8'); - // Handle .jsonl files (Copilot CLI format and VS Code incremental format) - if (sessionFile.endsWith('.jsonl')) { + // Handle .jsonl files OR .json files with JSONL content (Copilot CLI format and VS Code incremental format) + const isJsonlContent = sessionFile.endsWith('.jsonl') || this.isJsonlContent(fileContent); + if (isJsonlContent) { const lines = fileContent.trim().split('\n'); const timestamps: number[] = []; @@ -2296,6 +2570,10 @@ class CopilotTokenTracker implements vscode.Disposable { if (entry.isDirectory()) { this.scanDirectoryForSessionFiles(fullPath, sessionFiles); } else if (entry.name.endsWith('.json') || entry.name.endsWith('.jsonl')) { + // Skip known non-session files (embeddings, indexes, etc.) + if (this.isNonSessionFile(entry.name)) { + continue; + } // Only add files that look like session files (have reasonable content) try { const stats = fs.statSync(fullPath); @@ -2312,12 +2590,29 @@ class CopilotTokenTracker implements vscode.Disposable { } } + /** + * Check if a filename is a known non-session file that should be excluded + */ + private isNonSessionFile(filename: string): boolean { + const nonSessionFilePatterns = [ + 'embeddings', // commandEmbeddings.json, settingEmbeddings.json + 'index', // index files + 'cache', // cache files + 'preferences', + 'settings', + 'config' + ]; + const lowerFilename = filename.toLowerCase(); + return nonSessionFilePatterns.some(pattern => lowerFilename.includes(pattern)); + } + private async estimateTokensFromSession(sessionFilePath: string): Promise { try { const fileContent = await fs.promises.readFile(sessionFilePath, 'utf8'); - // Handle .jsonl files (each line is a separate JSON object) - if (sessionFilePath.endsWith('.jsonl')) { + // Handle .jsonl files OR .json files with JSONL content (each line is a separate JSON object) + const isJsonlContent = sessionFilePath.endsWith('.jsonl') || this.isJsonlContent(fileContent); + if (isJsonlContent) { return this.estimateTokensFromJsonlSession(fileContent); } @@ -2408,9 +2703,15 @@ class CopilotTokenTracker implements vscode.Disposable { } private getModelFromRequest(request: any): string { - // Try to determine model from request metadata + // Try to determine model from request metadata (most reliable source) + // First check the top-level modelId field (VS Code format) + if (request.modelId) { + // Remove "copilot/" prefix if present + return request.modelId.replace(/^copilot\//, ''); + } + if (request.result && request.result.metadata && request.result.metadata.modelId) { - return request.result.metadata.modelId; + return request.result.metadata.modelId.replace(/^copilot\//, ''); } // Build a lookup map from display names to model IDs from modelPricing.json @@ -2438,6 +2739,47 @@ class CopilotTokenTracker implements vscode.Disposable { return 'gpt-4'; // default } + /** + * Detect if file content is JSONL format (multiple JSON objects, one per line) + * This handles cases where .json files actually contain JSONL content + */ + private isJsonlContent(content: string): boolean { + const trimmed = content.trim(); + // JSONL typically has multiple lines, each starting with { and ending with } + if (!trimmed.includes('\n')) { + return false; // Single line - not JSONL + } + const lines = trimmed.split('\n').filter(l => l.trim()); + if (lines.length < 2) { + return false; // Need multiple lines for JSONL + } + // Check if first two non-empty lines look like separate JSON objects + const firstLine = lines[0].trim(); + const secondLine = lines[1].trim(); + return firstLine.startsWith('{') && firstLine.endsWith('}') && + secondLine.startsWith('{') && secondLine.endsWith('}'); + } + + private getModelTier(modelId: string): 'standard' | 'premium' | 'unknown' { + // Determine tier based on multiplier: 0 = standard, >0 = premium + // Look up from modelPricing.json + const pricingInfo = this.modelPricing[modelId]; + if (pricingInfo && typeof pricingInfo.multiplier === 'number') { + return pricingInfo.multiplier === 0 ? 'standard' : 'premium'; + } + + // Fallback: try to match partial model names + for (const [key, value] of Object.entries(this.modelPricing)) { + if (modelId.includes(key) || key.includes(modelId)) { + if (typeof value.multiplier === 'number') { + return value.multiplier === 0 ? 'standard' : 'premium'; + } + } + } + + return 'unknown'; + } + private estimateTokensFromText(text: string, model: string = 'gpt-4'): number { // Token estimation based on character count and model let tokensPerChar = 0.25; // default @@ -2579,11 +2921,11 @@ class CopilotTokenTracker implements vscode.Disposable { public async showUsageAnalysis(): Promise { this.log('📊 Opening Usage Analysis dashboard'); - // If panel already exists, just reveal it + // If panel already exists, dispose it and recreate with fresh data if (this.analysisPanel) { - this.analysisPanel.reveal(); - this.log('📊 Usage Analysis dashboard revealed (already exists)'); - return; + this.log('📊 Closing existing panel to refresh data...'); + this.analysisPanel.dispose(); + this.analysisPanel = undefined; } // Get usage analysis stats @@ -3323,7 +3665,6 @@ class CopilotTokenTracker implements vscode.Disposable { } break; case 'openSettings': - this.log('[DEBUG] openSettings message received from diagnostics webview'); await vscode.commands.executeCommand('workbench.action.openSettings', 'copilotTokenTracker.backend'); break; } @@ -3675,6 +4016,7 @@ class CopilotTokenTracker implements vscode.Disposable { const initialData = JSON.stringify({ today: stats.today, + last30Days: stats.last30Days, month: stats.month, lastUpdated: stats.lastUpdated.toISOString() }).replace(/
@@ -317,7 +333,7 @@ function renderLayout(stats: UsageAnalysisStats): void {
🔧Tool Usage
Functions and tools invoked by Copilot during interactions
-
+

📅 Today

@@ -332,6 +348,13 @@ function renderLayout(stats: UsageAnalysisStats): void { ${renderToolsTable(stats.month.toolCalls.byTool, 10)}
+
+

📆 Last 30 Days

+
+
Total Tool Calls: ${stats.last30Days.toolCalls.total}
+ ${renderToolsTable(stats.last30Days.toolCalls.byTool, 10)} +
+
@@ -339,7 +362,7 @@ function renderLayout(stats: UsageAnalysisStats): void {
🔌MCP Tools
Model Context Protocol (MCP) server and tool usage
-
+

📅 Today

@@ -360,6 +383,156 @@ function renderLayout(stats: UsageAnalysisStats): void { ` : '
No MCP tools used yet
'}
+
+

📆 Last 30 Days

+
+
Total MCP Calls: ${stats.last30Days.mcpTools.total}
+ ${stats.last30Days.mcpTools.total > 0 ? ` +
By Server:
${renderToolsTable(stats.last30Days.mcpTools.byServer, 8)}
+
By Tool:
${renderToolsTable(stats.last30Days.mcpTools.byTool, 8)}
+ ` : '
No MCP tools used yet
'} +
+
+
+
+ + +
+
🔀Multi-Model Usage
+
Track model diversity and switching patterns in your conversations
+
+
+

📅 Today

+
+
+
📊 Avg Models per Conversation
+
${stats.today.modelSwitching.averageModelsPerSession.toFixed(1)}
+
+
+
🔄 Switching Frequency
+
${stats.today.modelSwitching.switchingFrequency.toFixed(0)}%
+
Sessions with >1 model
+
+
+
📈 Max Models in Session
+
${stats.today.modelSwitching.maxModelsPerSession || 0}
+
+
+
+
Models by Tier:
+ ${stats.today.modelSwitching.standardModels.length > 0 ? ` +
+ 🔵 Standard: + ${stats.today.modelSwitching.standardModels.join(', ')} +
+ ` : ''} + ${stats.today.modelSwitching.premiumModels.length > 0 ? ` +
+ ⭐ Premium: + ${stats.today.modelSwitching.premiumModels.join(', ')} +
+ ` : ''} + ${stats.today.modelSwitching.unknownModels.length > 0 ? ` +
+ ❓ Unknown: + ${stats.today.modelSwitching.unknownModels.join(', ')} +
+ ` : ''} + ${stats.today.modelSwitching.mixedTierSessions > 0 ? ` +
+ 🔀 Mixed tier sessions: ${stats.today.modelSwitching.mixedTierSessions} +
+ ` : ''} +
+
+
+

� This Month

+
+
+
📊 Avg Models per Conversation
+
${stats.month.modelSwitching.averageModelsPerSession.toFixed(1)}
+
+
+
🔄 Switching Frequency
+
${stats.month.modelSwitching.switchingFrequency.toFixed(0)}%
+
Sessions with >1 model
+
+
+
📈 Max Models in Session
+
${stats.month.modelSwitching.maxModelsPerSession || 0}
+
+
+
+
Models by Tier:
+ ${stats.month.modelSwitching.standardModels.length > 0 ? ` +
+ 🔵 Standard: + ${stats.month.modelSwitching.standardModels.join(', ')} +
+ ` : ''} + ${stats.month.modelSwitching.premiumModels.length > 0 ? ` +
+ ⭐ Premium: + ${stats.month.modelSwitching.premiumModels.join(', ')} +
+ ` : ''} + ${stats.month.modelSwitching.unknownModels.length > 0 ? ` +
+ ❓ Unknown: + ${stats.month.modelSwitching.unknownModels.join(', ')} +
+ ` : ''} + ${stats.month.modelSwitching.mixedTierSessions > 0 ? ` +
+ 🔀 Mixed tier sessions: ${stats.month.modelSwitching.mixedTierSessions} +
+ ` : ''} +
+
+
+

📆 Last 30 Days

+
+
+
📊 Avg Models per Conversation
+
${stats.last30Days.modelSwitching.averageModelsPerSession.toFixed(1)}
+
+
+
🔄 Switching Frequency
+
${stats.last30Days.modelSwitching.switchingFrequency.toFixed(0)}%
+
Sessions with >1 model
+
+
+
📈 Max Models in Session
+
${stats.last30Days.modelSwitching.maxModelsPerSession || 0}
+
+
+
+
Models by Tier:
+ ${stats.last30Days.modelSwitching.standardModels.length > 0 ? ` +
+ 🔵 Standard: + ${stats.last30Days.modelSwitching.standardModels.join(', ')} +
+ ` : ''} + ${stats.last30Days.modelSwitching.premiumModels.length > 0 ? ` +
+ ⭐ Premium: + ${stats.last30Days.modelSwitching.premiumModels.join(', ')} +
+ ` : ''} + ${stats.last30Days.modelSwitching.unknownModels.length > 0 ? ` +
+ ❓ Unknown: + ${stats.last30Days.modelSwitching.unknownModels.join(', ')} +
+ ` : ''} + ${stats.last30Days.modelSwitching.mixedTierSessions > 0 ? ` +
+ 🔀 Mixed tier sessions: ${stats.last30Days.modelSwitching.mixedTierSessions} +
+ ` : ''} +
+
@@ -368,7 +541,8 @@ function renderLayout(stats: UsageAnalysisStats): void {
📈Sessions Summary
📅 Today Sessions
${stats.today.sessions}
-
📊 Month Sessions
${stats.month.sessions}
+
� This Month Sessions
${stats.month.sessions}
+
📆 Last 30 Days Sessions
${stats.last30Days.sessions}