diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 91c1e12d48..774f762b98 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -6,12 +6,19 @@ * * Uses hybrid approach: * 1. Primary: Direct OAuth API (https://api.anthropic.com/api/oauth/usage) - * 2. Fallback: CLI /usage command parsing + * 2. API profiles: Provider-specific endpoints (Z.ai, etc.) + * 3. Fallback: CLI /usage command parsing */ import { EventEmitter } from 'events'; +import { spawn } from 'child_process'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { ClaudeUsageSnapshot } from '../../shared/types/agent'; +import { loadProfilesFile, detectProvider, fetchUsageForProfile, fetchAnthropicOAuthUsage } from '../services/profile'; +import { getClaudeCliInvocationAsync } from '../claude-cli-utils'; +import { getSpawnCommand, getSpawnOptions } from '../env-utils'; +import { parseUsageOutput, parseResetTime, classifyRateLimitType } from './usage-parser'; +import { getProfileEnv } from '../rate-limit-detector'; export class UsageMonitor extends EventEmitter { private static instance: UsageMonitor; @@ -19,11 +26,15 @@ export class UsageMonitor extends EventEmitter { private currentUsage: ClaudeUsageSnapshot | null = null; private isChecking = false; private useApiMethod = true; // Try API first, fall back to CLI if it fails - + // Swap loop protection: track profiles that recently failed auth private authFailedProfiles: Map = new Map(); // profileId -> timestamp private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown - + private static CLI_USAGE_TIMEOUT_MS = 10000; // 10 seconds timeout for CLI /usage command + // API retry mechanism: retry API after N consecutive successful CLI fallbacks + private cliFallbackSuccessCount = 0; + private static CLI_FALLBACK_RETRY_THRESHOLD = 5; // Retry API after 5 successful CLI calls + // Debug flag for verbose logging private readonly isDebug = process.env.DEBUG === 'true'; @@ -46,8 +57,8 @@ export class UsageMonitor extends EventEmitter { const profileManager = getClaudeProfileManager(); const settings = profileManager.getAutoSwitchSettings(); - if (!settings.enabled || !settings.proactiveSwapEnabled) { - console.warn('[UsageMonitor] Proactive monitoring disabled. Settings:', JSON.stringify(settings, null, 2)); + if (!settings.enabled) { + console.warn('[UsageMonitor] Usage monitoring disabled. Settings:', JSON.stringify(settings, null, 2)); return; } @@ -57,7 +68,7 @@ export class UsageMonitor extends EventEmitter { } const interval = settings.usageCheckInterval || 30000; - console.warn('[UsageMonitor] Starting with interval:', interval, 'ms'); + console.warn('[UsageMonitor] Starting with interval:', interval, 'ms', 'Proactive swap:', settings.proactiveSwapEnabled ? 'enabled' : 'disabled'); // Check immediately this.checkUsageAndSwap(); @@ -124,7 +135,8 @@ export class UsageMonitor extends EventEmitter { const sessionExceeded = usage.sessionPercent >= settings.sessionThreshold; const weeklyExceeded = usage.weeklyPercent >= settings.weeklyThreshold; - if (sessionExceeded || weeklyExceeded) { + // Only perform proactive swap if enabled + if (settings.proactiveSwapEnabled && (sessionExceeded || weeklyExceeded)) { if (this.isDebug) { console.warn('[UsageMonitor:TRACE] Threshold exceeded', { sessionPercent: usage.sessionPercent, @@ -159,12 +171,12 @@ export class UsageMonitor extends EventEmitter { if ((error as any).statusCode === 401 || (error as any).statusCode === 403) { const profileManager = getClaudeProfileManager(); const activeProfile = profileManager.getActiveProfile(); - + if (activeProfile) { // Mark this profile as auth-failed to prevent swap loops this.authFailedProfiles.set(activeProfile.id, Date.now()); console.warn('[UsageMonitor] Auth failure detected, marked profile as failed:', activeProfile.id); - + // Clean up expired entries from the failed profiles map const now = Date.now(); this.authFailedProfiles.forEach((timestamp, profileId) => { @@ -172,7 +184,7 @@ export class UsageMonitor extends EventEmitter { this.authFailedProfiles.delete(profileId); } }); - + try { const excludeProfiles = Array.from(this.authFailedProfiles.keys()); console.warn('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles); @@ -287,7 +299,7 @@ export class UsageMonitor extends EventEmitter { if (error?.statusCode === 401 || error?.statusCode === 403) { throw error; } - + console.error('[UsageMonitor] API fetch failed:', error); return null; } @@ -296,18 +308,99 @@ export class UsageMonitor extends EventEmitter { /** * Fetch usage via CLI /usage command (fallback) * Note: This is a fallback method. The API method is preferred. - * CLI-based fetching would require spawning a Claude process and parsing output, - * which is complex. For now, we rely on the API method. + * CLI-based fetching spawns a Claude process and parses output. */ private async fetchUsageViaCLI( - _profileId: string, - _profileName: string + profileId: string, + profileName: string ): Promise { - // CLI-based usage fetching is not implemented yet. - // The API method should handle most cases. If we need CLI fallback, - // we would need to spawn a Claude process with /usage command and parse the output. - console.warn('[UsageMonitor] CLI fallback not implemented, API method should be used'); - return null; + try { + console.warn('[UsageMonitor] Attempting CLI fallback for profile:', profileName); + + // Get CLI invocation with augmented environment and profile-specific environment + const { command: claudeCmd, env: augmentedEnv } = await getClaudeCliInvocationAsync(); + const profileEnv = getProfileEnv(profileId); + + // Merge augmented environment with profile-specific environment + // Profile-specific vars take precedence + const mergedEnv: Record = { + ...augmentedEnv, + ...profileEnv, + }; + + // Spawn Claude CLI with /usage command + const execCmd = getSpawnCommand(claudeCmd); + const spawnOptions = getSpawnOptions(execCmd, { + env: mergedEnv, + timeout: UsageMonitor.CLI_USAGE_TIMEOUT_MS + }); + + const child = spawn(execCmd, ['/usage'], spawnOptions); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + child.kill(); + console.error('[UsageMonitor] CLI usage fetch timed out'); + resolve(null); + }, UsageMonitor.CLI_USAGE_TIMEOUT_MS); + + child.on('close', (code) => { + clearTimeout(timeoutId); + + if (code !== 0) { + console.error('[UsageMonitor] CLI usage fetch failed:', stderr); + resolve(null); + return; + } + + try { + // Parse CLI output - returns ClaudeUsageData + const usageData = parseUsageOutput(stdout); + + // Convert ClaudeUsageData to ClaudeUsageSnapshot + const snapshot: ClaudeUsageSnapshot = { + sessionPercent: usageData.sessionUsagePercent, + weeklyPercent: usageData.weeklyUsagePercent, + sessionResetTime: usageData.sessionResetTime || this.formatResetTime(undefined), + weeklyResetTime: usageData.weeklyResetTime || this.formatResetTime(undefined), + profileId, + profileName, + fetchedAt: new Date(), + limitType: classifyRateLimitType(usageData.weeklyResetTime || usageData.sessionResetTime) + }; + + console.warn('[UsageMonitor] Successfully fetched via CLI'); + + // Increment CLI success count and potentially retry API + this.cliFallbackSuccessCount++; + if (this.cliFallbackSuccessCount >= UsageMonitor.CLI_FALLBACK_RETRY_THRESHOLD) { + console.warn('[UsageMonitor] Retrying API method after', this.cliFallbackSuccessCount, 'successful CLI fetches'); + this.cliFallbackSuccessCount = 0; + this.useApiMethod = true; + } + + resolve(snapshot); + } catch (error) { + console.error('[UsageMonitor] Failed to parse CLI usage output:', error); + resolve(null); + } + }); + }); + } catch (error) { + console.error('[UsageMonitor] CLI usage fetch exception:', error); + return null; + } } /** @@ -347,12 +440,12 @@ export class UsageMonitor extends EventEmitter { additionalExclusions: string[] = [] ): Promise { const profileManager = getClaudeProfileManager(); - + // Get all profiles to swap to, excluding current and any additional exclusions const allProfiles = profileManager.getProfilesSortedByAvailability(); const excludeIds = new Set([currentProfileId, ...additionalExclusions]); const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id)); - + if (eligibleProfiles.length === 0) { console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')'); this.emit('proactive-swap-failed', { @@ -362,7 +455,7 @@ export class UsageMonitor extends EventEmitter { }); return; } - + // Use the best available from eligible profiles const bestProfile = eligibleProfiles[0]; diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 922fe281ab..2986c29b58 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -607,6 +607,32 @@ export function registerTerminalHandlers( ); + // Get usage statistics for active profile (API-based, on-demand) + ipcMain.handle( + IPC_CHANNELS.PROFILES_GET_USAGE, + async (): Promise> => { + try { + const { fetchActiveProfileUsage } = await import('../services/profile'); + const result = await fetchActiveProfileUsage(); + + if (!result.success || !result.usage) { + return { + success: false, + error: result.error || 'Failed to fetch usage data' + }; + } + + return { success: true, data: result.usage }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch usage data' + }; + } + } + ); + + // Terminal session management (persistence/restore) ipcMain.handle( IPC_CHANNELS.TERMINAL_GET_SESSIONS, @@ -761,12 +787,36 @@ export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): voi // Forward usage updates to renderer monitor.on('usage-updated', (usage: ClaudeUsageSnapshot) => { - mainWindow.webContents.send(IPC_CHANNELS.USAGE_UPDATED, usage); + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.USAGE_UPDATED, usage); + } }); - // Forward proactive swap notifications to renderer + // Forward proactive swap completed events to renderer + monitor.on('proactive-swap-completed', (data: any) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, { + type: 'completed', + ...data + }); + } + }); + + // Forward proactive swap failed events to renderer + monitor.on('proactive-swap-failed', (data: any) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, { + type: 'failed', + ...data + }); + } + }); + + // Forward show-swap-notification events (for in-app swap notifications) monitor.on('show-swap-notification', (notification: unknown) => { - mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification); + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification); + } }); debugLog('[terminal-handlers] Usage monitor event forwarding initialized'); diff --git a/apps/frontend/src/main/services/profile/index.ts b/apps/frontend/src/main/services/profile/index.ts index 1980eb0300..4e4e709f54 100644 --- a/apps/frontend/src/main/services/profile/index.ts +++ b/apps/frontend/src/main/services/profile/index.ts @@ -31,6 +31,17 @@ export { export type { CreateProfileInput, UpdateProfileInput } from './profile-service'; +// Profile Usage Service +export { + detectProvider, + fetchZaiUsage, + fetchAnthropicOAuthUsage, + fetchUsageForProfile, + fetchActiveProfileUsage +} from './profile-usage'; + +export type { UsageProvider, UsageFetchResult } from './profile-usage'; + // Re-export types from shared for convenience export type { APIProfile, @@ -41,3 +52,6 @@ export type { DiscoverModelsResult, DiscoverModelsError } from '@shared/types/profile'; + +// Re-export RESETTING_SOON from shared/types/agent for convenience +export { RESETTING_SOON } from '../../../shared/types/agent'; diff --git a/apps/frontend/src/main/services/profile/profile-usage.test.ts b/apps/frontend/src/main/services/profile/profile-usage.test.ts new file mode 100644 index 0000000000..e65e920756 --- /dev/null +++ b/apps/frontend/src/main/services/profile/profile-usage.test.ts @@ -0,0 +1,479 @@ +/** + * Tests for profile-usage.ts service + * Tests provider detection, API fetching, and CLI usage parsing + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + detectProvider, + fetchZaiUsage, + fetchAnthropicOAuthUsage, + fetchUsageForProfile, + ApiAuthError +} from './profile-usage'; +import type { ClaudeUsageSnapshot } from '../../../shared/types/agent'; + +// Mock global fetch +global.fetch = vi.fn(); + +describe('profile-usage service', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('detectProvider', () => { + const zaiApiKey = 'zai-api-key'; + const oauthToken = 'sk-ant-oat01-test-token'; + const regularApiKey = 'sk-ant-api03-test-key'; + + it('should detect Z.ai provider from api.z.ai URL', () => { + expect(detectProvider('https://api.z.ai', zaiApiKey)).toBe('zai'); + expect(detectProvider('https://api.z.ai/v1', zaiApiKey)).toBe('zai'); + expect(detectProvider('http://api.z.ai', zaiApiKey)).toBe('zai'); + }); + + it('should detect Z.ai provider from z.ai URL', () => { + expect(detectProvider('https://z.ai', zaiApiKey)).toBe('zai'); + expect(detectProvider('https://z.ai/api', zaiApiKey)).toBe('zai'); + }); + + it('should detect Anthropic OAuth provider from api.anthropic.com with OAuth token', () => { + expect(detectProvider('https://api.anthropic.com', oauthToken)).toBe('anthropic-oauth'); + expect(detectProvider('https://api.anthropic.com/v1', oauthToken)).toBe('anthropic-oauth'); + }); + + it('should return "other" for api.anthropic.com with regular API key', () => { + expect(detectProvider('https://api.anthropic.com', regularApiKey)).toBe('other'); + expect(detectProvider('https://api.anthropic.com/v1', regularApiKey)).toBe('other'); + }); + + it('should return "other" for unknown providers', () => { + expect(detectProvider('https://api.example.com', regularApiKey)).toBe('other'); + expect(detectProvider('https://custom-api.com/v1', regularApiKey)).toBe('other'); + expect(detectProvider('https://api.openai.com', regularApiKey)).toBe('other'); + }); + + it('should handle URLs with paths correctly', () => { + expect(detectProvider('https://api.anthropic.com/v1/messages', oauthToken)).toBe('anthropic-oauth'); + expect(detectProvider('https://api.z.ai/api/monitor/usage/quota/limit', zaiApiKey)).toBe('zai'); + }); + + it('should be case-insensitive for domain matching', () => { + expect(detectProvider('https://API.Z.AI', zaiApiKey)).toBe('zai'); + expect(detectProvider('https://API.ANTHROPIC.COM', oauthToken)).toBe('anthropic-oauth'); + }); + + it('should handle whitespace in URLs', () => { + expect(detectProvider(' https://api.z.ai ', zaiApiKey)).toBe('zai'); + expect(detectProvider(' https://api.anthropic.com ', oauthToken)).toBe('anthropic-oauth'); + }); + }); + + describe('fetchZaiUsage', () => { + it('should fetch and parse Z.ai quota successfully', async () => { + const mockResponse = { + code: 200, + msg: 'Operation successful', + data: { + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + usage: 1000, + currentValue: 534, + remaining: 466, + percentage: 53, + usageDetails: [ + { modelCode: 'search-prime', usage: 485 }, + { modelCode: 'web-reader', usage: 76 }, + { modelCode: 'zread', usage: 0 } + ] + }, + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + usage: 200000000, + currentValue: 27128437, + remaining: 172871563, + percentage: 13, + nextResetTime: 1768301417641 + } + ] + }, + success: true + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchZaiUsage('test-api-key', 'test-profile', 'Test Profile'); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('zai'); + expect(result?.sessionPercent).toBe(13); // From TOKENS_LIMIT.percentage + expect(result?.weeklyPercent).toBe(53); // From TIME_LIMIT.percentage + expect(result?.profileName).toBe('Test Profile'); + }); + + it('should handle missing TOKENS_LIMIT gracefully', async () => { + const mockResponse = { + code: 200, + msg: 'Operation successful', + data: { + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + usage: 1000, + currentValue: 650, + remaining: 350, + percentage: 65 + } + ] + }, + success: true + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchZaiUsage('test-api-key', 'test-profile', 'Test Profile'); + + expect(result?.sessionPercent).toBe(0); // Default when TOKENS_LIMIT not found + expect(result?.weeklyPercent).toBe(65); // From TIME_LIMIT + }); + + it('should handle missing TIME_LIMIT gracefully', async () => { + const mockResponse = { + code: 200, + msg: 'Operation successful', + data: { + limits: [ + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + usage: 200000000, + currentValue: 150000000, + remaining: 50000000, + percentage: 75 + } + ] + }, + success: true + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchZaiUsage('test-api-key', 'test-profile', 'Test Profile'); + + expect(result?.sessionPercent).toBe(75); // From TOKENS_LIMIT + expect(result?.weeklyPercent).toBe(0); // Default when TIME_LIMIT not found + }); + + it('should return null on API error', async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }); + + const result = await fetchZaiUsage('invalid-key', 'test-profile', 'Test Profile'); + + expect(result).toBeNull(); + }); + + it('should return null on network error', async () => { + (global.fetch as any).mockRejectedValue(new Error('Network error')); + + const result = await fetchZaiUsage('test-api-key', 'test-profile', 'Test Profile'); + + expect(result).toBeNull(); + }); + + it('should return null and log error on AbortError (timeout)', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + + (global.fetch as any).mockRejectedValue(abortError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchZaiUsage('test-api-key', 'test-profile', 'Test Profile'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith('[profile-usage] Z.ai fetch timed out'); + + consoleSpy.mockRestore(); + }); + + it('should set correct reset timestamp when nextResetTime is provided', async () => { + const futureTimestamp = Date.now() + 5 * 60 * 60 * 1000; // 5 hours from now + const mockResponse = { + code: 200, + msg: 'Operation successful', + data: { + limits: [ + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + usage: 200000000, + currentValue: 100000000, + remaining: 100000000, + percentage: 50, + nextResetTime: futureTimestamp + } + ] + }, + success: true + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchZaiUsage('test-api-key', 'test-profile', 'Test Profile'); + + expect(result?.sessionResetTimestamp).toBe(futureTimestamp); + }); + }); + + describe('fetchAnthropicOAuthUsage', () => { + it('should fetch and parse Anthropic OAuth usage successfully', async () => { + const mockResponse = { + five_hour_utilization: 0.72, + seven_day_utilization: 0.45, + five_hour_reset_at: '2025-01-17T15:00:00Z', + seven_day_reset_at: '2025-01-20T12:00:00Z' + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchAnthropicOAuthUsage('test-oauth-token', 'profile-1', 'My Profile'); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('anthropic-oauth'); + expect(result?.sessionPercent).toBe(72); // 0.72 * 100 + expect(result?.weeklyPercent).toBe(45); // 0.45 * 100 + expect(result?.profileId).toBe('profile-1'); + expect(result?.profileName).toBe('My Profile'); + }); + + it('should handle missing utilization data gracefully', async () => { + const mockResponse = { + five_hour_utilization: 0.5 + // seven_day_utilization missing + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchAnthropicOAuthUsage('token', 'profile-1', 'Profile'); + + expect(result?.sessionPercent).toBe(50); + expect(result?.weeklyPercent).toBe(0); + }); + + it('should return null on API error', async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Invalid token' + }); + + const result = await fetchAnthropicOAuthUsage('invalid-token', 'profile-1', 'Profile'); + + expect(result).toBeNull(); + }); + + it('should return null on network error', async () => { + (global.fetch as any).mockRejectedValue(new Error('Network error')); + + const result = await fetchAnthropicOAuthUsage('token', 'profile-1', 'Profile'); + + expect(result).toBeNull(); + }); + + it('should set correct reset timestamps when provided', async () => { + const mockResponse = { + five_hour_utilization: 0.5, + seven_day_utilization: 0.3, + five_hour_reset_at: '2025-01-17T15:00:00Z', + seven_day_reset_at: '2025-01-20T12:00:00Z' + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchAnthropicOAuthUsage('token', 'profile-1', 'Profile'); + + expect(result?.sessionResetTimestamp).toBeDefined(); + expect(result?.weeklyResetTimestamp).toBeDefined(); + expect(typeof result?.sessionResetTimestamp).toBe('number'); + expect(typeof result?.weeklyResetTimestamp).toBe('number'); + }); + + it('should determine limitType based on which is higher', async () => { + const mockResponse = { + five_hour_utilization: 0.4, + seven_day_utilization: 0.8, + five_hour_reset_at: '2025-01-17T15:00:00Z', + seven_day_reset_at: '2025-01-20T12:00:00Z' + }; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockResponse + }); + + const result = await fetchAnthropicOAuthUsage('token', 'profile-1', 'Profile'); + + expect(result?.limitType).toBe('weekly'); + }); + + it('should throw ApiAuthError when throwOnAuthFailure is true and API returns 401', async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }); + + await expect( + fetchAnthropicOAuthUsage('invalid-token', 'profile-1', 'Profile', true) + ).rejects.toThrow(ApiAuthError); + }); + + it('should throw ApiAuthError when throwOnAuthFailure is true and API returns 403', async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden' + }); + + await expect( + fetchAnthropicOAuthUsage('invalid-token', 'profile-1', 'Profile', true) + ).rejects.toThrow(ApiAuthError); + }); + }); + + describe('fetchUsageForProfile', () => { + it('should route to Z.ai fetch for zai provider', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ + code: 200, + msg: 'Operation successful', + data: { + limits: [ + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + usage: 200000000, + currentValue: 75000000, + remaining: 125000000, + percentage: 37 + } + ] + }, + success: true + }) + }); + + const result = await fetchUsageForProfile('zai', 'api-key', 'profile-1', 'Z.ai Profile'); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('zai'); + }); + + it('should route to Anthropic OAuth fetch for anthropic-oauth provider', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ + five_hour_utilization: 0.5, + seven_day_utilization: 0.3 + }) + }); + + const result = await fetchUsageForProfile('anthropic-oauth', 'oauth-token', 'profile-1', 'My Profile'); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('anthropic-oauth'); + }); + + it('should return null for "other" provider', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await fetchUsageForProfile('other', 'api-key', 'profile-1', 'Custom API'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith('[profile-usage] Usage statistics not available for this provider'); + + consoleSpy.mockRestore(); + }); + + it('should return null for unknown provider', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // @ts-expect-error - testing invalid provider + const result = await fetchUsageForProfile('unknown', 'key', 'profile-1', 'Profile'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith('[profile-usage] Unknown provider:', 'unknown'); + + consoleSpy.mockRestore(); + }); + }); + + describe('error handling', () => { + it('should handle fetch failures gracefully', async () => { + (global.fetch as any).mockRejectedValue(new Error('Connection failed')); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchZaiUsage('key', 'test-profile', 'Test Profile'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith('[profile-usage] Z.ai fetch failed:', expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + it('should handle malformed JSON gracefully', async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => { + throw new SyntaxError('Invalid JSON'); + } + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await fetchZaiUsage('key', 'test-profile', 'Test Profile'); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/apps/frontend/src/main/services/profile/profile-usage.ts b/apps/frontend/src/main/services/profile/profile-usage.ts new file mode 100644 index 0000000000..6e3becd0d1 --- /dev/null +++ b/apps/frontend/src/main/services/profile/profile-usage.ts @@ -0,0 +1,409 @@ +/** + * Profile Usage Service + * + * Fetches usage statistics for different API providers. + * Supports: + * - Anthropic OAuth (sk-ant-oat01- tokens) + * - Z.ai (GLM API) + * - Other Anthropic-compatible APIs (returns "not available") + */ + +import type { ClaudeUsageSnapshot } from '../../../shared/types/agent'; +import { RESETTING_SOON } from '../../../shared/types/agent'; +import { loadProfilesFile } from './profile-manager'; + +// ============================================ +// Error Classes +// ============================================ + +/** + * Custom error for API authentication failures + * Used when fetch returns 401 or 403 status codes + */ +export class ApiAuthError extends Error { + public readonly statusCode: number; + public readonly name = 'ApiAuthError'; + + constructor(statusCode: number, message?: string) { + super(message || `API Auth Failure: ${statusCode}`); + this.statusCode = statusCode; + } +} + +// ============================================ +// Provider Detection +// ============================================ + +/** + * Provider type for usage fetching + */ +export type UsageProvider = 'anthropic-oauth' | 'zai' | 'other'; + +/** + * Z.ai quota response structure + * API returns: https://api.z.ai/api/monitor/usage/quota/limit + */ +interface ZaiQuotaResponse { + code: number; + msg: string; + data: { + limits: Array<{ + type: 'TOKENS_LIMIT' | 'TIME_LIMIT'; + unit: number; + number: number; + usage: number; + currentValue: number; + remaining: number; + percentage: number; + nextResetTime?: number; // Unix timestamp for TOKENS_LIMIT + usageDetails?: Array<{ + modelCode: string; + usage: number; + }>; + }>; + }; + success: boolean; +} + +/** + * Anthropic OAuth usage response structure + */ +interface AnthropicUsageResponse { + five_hour_utilization?: number; + seven_day_utilization?: number; + five_hour_reset_at?: string; + seven_day_reset_at?: string; +} + +/** + * Detect provider type from base URL and API key/token format + * + * For api.anthropic.com: + * - OAuth tokens (sk-ant-oat01-*) -> 'anthropic-oauth' (usage available) + * - Regular API keys (sk-ant-api03-*, etc.) -> 'other' (no usage API) + * + * @param baseUrl - API base URL + * @param apiKey - API key or OAuth token + */ +export function detectProvider(baseUrl: string, apiKey: string): UsageProvider { + try { + const url = new URL(baseUrl.trim()); + const hostname = url.hostname.toLowerCase(); + + // Check for Z.ai domains (z.ai and subdomains) + if (hostname === 'z.ai' || hostname.endsWith('.z.ai')) { + return 'zai'; + } + + // Check for Anthropic OAuth domain (api.anthropic.com) + // Must also have an OAuth token (sk-ant-oat01-*) + if (hostname === 'api.anthropic.com') { + const isOAuthToken = apiKey.startsWith('sk-ant-oat01-'); + if (isOAuthToken) { + return 'anthropic-oauth'; + } + // Regular API keys on api.anthropic.com don't have a usage API + return 'other'; + } + + return 'other'; + } catch { + // Invalid URL, treat as unknown provider + return 'other'; + } +} + +// ============================================ +// Z.ai Usage Fetching +// ============================================ + +/** + * Fetch usage from Z.ai API endpoint + * Endpoint: https://api.z.ai/api/monitor/usage/quota/limit + */ +export async function fetchZaiUsage( + apiKey: string, + profileId: string, + profileName: string +): Promise { + const FETCH_TIMEOUT_MS = 10000; // 10 seconds timeout + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch('https://api.z.ai/api/monitor/usage/quota/limit', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}` + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error('[profile-usage] Z.ai API error:', response.status, response.statusText); + return null; + } + + const data = (await response.json()) as ZaiQuotaResponse; + + // Guard against malformed response - ensure limits array exists + const limits = Array.isArray(data?.data?.limits) ? data.data.limits : []; + + // Extract token and tool limits from the response + const tokensLimit = limits.find((limit) => limit.type === 'TOKENS_LIMIT'); + const timeLimit = limits.find((limit) => limit.type === 'TIME_LIMIT'); + + // Use pre-calculated percentages from API with bounds validation + const tokenPercent = Math.max(0, Math.min(100, tokensLimit?.percentage ?? 0)); + const toolPercent = Math.max(0, Math.min(100, timeLimit?.percentage ?? 0)); + + // Parse reset time from TOKENS_LIMIT's nextResetTime (Unix timestamp in milliseconds) + const sessionResetTimestamp = tokensLimit?.nextResetTime; + const sessionResetTime = sessionResetTimestamp + ? formatResetTimeFromTimestamp(sessionResetTimestamp) + : undefined; + + // Z.ai doesn't provide a weekly/monthly reset time for TIME_LIMIT in this response + // The TIME_LIMIT appears to be a monthly rolling window based on unit/number fields + return { + sessionPercent: tokenPercent, + weeklyPercent: toolPercent, + sessionResetTime, + weeklyResetTime: undefined, // Not provided by Z.ai API + sessionResetTimestamp, + weeklyResetTimestamp: undefined, + profileId, + profileName, + fetchedAt: new Date(), + limitType: tokenPercent > toolPercent ? 'session' : 'weekly', + provider: 'zai' + }; + } catch (error) { + // Handle AbortError from timeout + if (error instanceof Error && error.name === 'AbortError') { + console.error('[profile-usage] Z.ai fetch timed out'); + return null; + } + console.error('[profile-usage] Z.ai fetch failed:', error); + return null; + } +} + +// ============================================ +// Anthropic OAuth Usage Fetching +// ============================================ + +/** + * Fetch usage from Anthropic OAuth API endpoint + * Endpoint: https://api.anthropic.com/api/oauth/usage + * + * @param oauthToken - OAuth bearer token + * @param profileId - Profile ID for the snapshot + * @param profileName - Profile name for display + * @param throwOnAuthFailure - If true, throw error on 401/403 instead of returning null + */ +export async function fetchAnthropicOAuthUsage( + oauthToken: string, + profileId: string, + profileName: string, + throwOnAuthFailure = false +): Promise { + const FETCH_TIMEOUT_MS = 10000; // 10 seconds timeout + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${oauthToken}`, + 'anthropic-version': '2023-06-01' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error('[profile-usage] Anthropic OAuth API error:', response.status, response.statusText); + // Throw specific error for auth failures if requested + if (throwOnAuthFailure && (response.status === 401 || response.status === 403)) { + throw new ApiAuthError(response.status); + } + return null; + } + + const data = (await response.json()) as AnthropicUsageResponse; + + const fiveHourResetTimestamp = data.five_hour_reset_at + ? new Date(data.five_hour_reset_at).getTime() + : undefined; + + const sevenDayResetTimestamp = data.seven_day_reset_at + ? new Date(data.seven_day_reset_at).getTime() + : undefined; + + return { + sessionPercent: Math.max(0, Math.min(100, Math.round((data.five_hour_utilization || 0) * 100))), + weeklyPercent: Math.max(0, Math.min(100, Math.round((data.seven_day_utilization || 0) * 100))), + sessionResetTime: formatResetTimeFromTimestamp(fiveHourResetTimestamp), + weeklyResetTime: formatResetTimeFromTimestamp(sevenDayResetTimestamp), + sessionResetTimestamp: fiveHourResetTimestamp, + weeklyResetTimestamp: sevenDayResetTimestamp, + profileId, + profileName, + fetchedAt: new Date(), + limitType: (data.seven_day_utilization || 0) > (data.five_hour_utilization || 0) + ? 'weekly' + : 'session', + provider: 'anthropic-oauth' + }; + } catch (error) { + // Handle AbortError from timeout + if (error instanceof Error && error.name === 'AbortError') { + console.error('[profile-usage] Anthropic OAuth fetch timed out'); + return null; + } + // Re-throw ApiAuthError instances + if (error instanceof ApiAuthError) { + throw error; + } + console.error('[profile-usage] Anthropic OAuth fetch failed:', error); + return null; + } +} + +// ============================================ +// Main Routing Function +// ============================================ + +/** + * Fetch usage for a profile (routes to appropriate provider) + * + * @param provider - Provider type + * @param credentials - API key or OAuth token + * @param profileId - Profile ID for the snapshot + * @param profileName - Profile name for display + */ +export async function fetchUsageForProfile( + provider: UsageProvider, + credentials: string, + profileId: string, + profileName: string +): Promise { + switch (provider) { + case 'zai': + return fetchZaiUsage(credentials, profileId, profileName); + + case 'anthropic-oauth': + return fetchAnthropicOAuthUsage(credentials, profileId, profileName); + + case 'other': + console.warn('[profile-usage] Usage statistics not available for this provider'); + return null; + + default: + console.warn('[profile-usage] Unknown provider:', provider); + return null; + } +} + +// ============================================ +// Active Profile Usage +// ============================================ + +/** + * Result type for usage fetching operations + */ +export interface UsageFetchResult { + success: boolean; + usage?: ClaudeUsageSnapshot; + error?: string; +} + +/** + * Fetch usage for the currently active profile + * + * This function loads the profiles file and fetches usage for the + * active profile (OAuth or API profile). + */ +export async function fetchActiveProfileUsage(): Promise { + try { + const file = await loadProfilesFile(); + + // No active profile set + if (!file.activeProfileId) { + return { + success: false, + error: 'No active profile set' + }; + } + + // Find active profile + const profile = file.profiles.find(p => p.id === file.activeProfileId); + if (!profile) { + return { + success: false, + error: 'Active profile not found' + }; + } + + // Detect provider type + const provider = detectProvider(profile.baseUrl, profile.apiKey); + + // Fetch usage based on provider + const usage = await fetchUsageForProfile( + provider, + profile.apiKey, + profile.id, + profile.name + ); + + if (!usage) { + return { + success: false, + error: 'Failed to fetch usage data' + }; + } + + return { success: true, usage }; + } catch (error) { + console.error('[ProfileUsage] Failed to load profiles:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load profiles' + }; + } +} + +// ============================================ +// Utility Functions +// ============================================ + +/** + * Format reset time from Unix timestamp to human-readable string + * Returns RESETTING_SOON sentinel when diffMs <= 0 + */ +function formatResetTimeFromTimestamp(timestamp?: number): string | undefined { + if (!timestamp) return undefined; + + const now = Date.now(); + const diffMs = timestamp - now; + + if (diffMs <= 0) return RESETTING_SOON; + + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (diffHours < 24) { + return `${diffHours}h ${diffMins}m`; + } + + const diffDays = Math.floor(diffHours / 24); + const remainingHours = diffHours % 24; + return `${diffDays}d ${remainingHours}h`; +} diff --git a/apps/frontend/src/preload/api/profile-api.ts b/apps/frontend/src/preload/api/profile-api.ts index 69b27890cd..4b05dcf9f6 100644 --- a/apps/frontend/src/preload/api/profile-api.ts +++ b/apps/frontend/src/preload/api/profile-api.ts @@ -8,6 +8,7 @@ import type { TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile'; +import type { ClaudeUsageSnapshot } from '../../shared/types/agent'; export interface ProfileAPI { // Get all profiles @@ -29,6 +30,9 @@ export interface ProfileAPI { // Set active profile (null to switch to OAuth) setActiveAPIProfile: (profileId: string | null) => Promise; + // Get usage statistics for active profile + getAPIProfileUsage: () => Promise>; + // Test API profile connection testConnection: ( baseUrl: string, @@ -72,6 +76,10 @@ export const createProfileAPI = (): ProfileAPI => ({ setActiveAPIProfile: (profileId: string | null): Promise => ipcRenderer.invoke(IPC_CHANNELS.PROFILES_SET_ACTIVE, profileId), + // Get usage statistics for active profile + getAPIProfileUsage: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROFILES_GET_USAGE), + // Test API profile connection testConnection: ( baseUrl: string, diff --git a/apps/frontend/src/renderer/components/UsageIndicator.tsx b/apps/frontend/src/renderer/components/UsageIndicator.tsx index 38a3689c20..084fbdfea1 100644 --- a/apps/frontend/src/renderer/components/UsageIndicator.tsx +++ b/apps/frontend/src/renderer/components/UsageIndicator.tsx @@ -3,10 +3,12 @@ * * Displays current session/weekly usage as a badge with color-coded status. * Shows detailed breakdown on hover. + * Shows countdown timer when usage is at 99% or higher. */ -import React, { useState, useEffect } from 'react'; -import { Activity, TrendingUp, AlertCircle } from 'lucide-react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Activity, TrendingUp, AlertCircle, Clock } from 'lucide-react'; import { Tooltip, TooltipContent, @@ -14,10 +16,65 @@ import { TooltipTrigger, } from './ui/tooltip'; import type { ClaudeUsageSnapshot } from '../../shared/types/agent'; +import { RESETTING_SOON } from '../../shared/types/agent'; export function UsageIndicator() { + const { t } = useTranslation('navigation'); const [usage, setUsage] = useState(null); const [isVisible, setIsVisible] = useState(false); + const [countdown, setCountdown] = useState(''); + + // Calculate countdown from timestamp + const calculateCountdown = useCallback((timestamp?: number): string => { + if (!timestamp) return ''; + + const now = Date.now(); + const diffMs = timestamp - now; + + if (diffMs <= 0) return ''; + + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (diffDays > 0) { + return `${diffDays}d ${diffHours}h`; + } + return `${diffHours}h ${diffMins}m`; + }, []); + + // Format reset time for display, handling sentinel value + const formatResetTime = useCallback((resetTime?: string): string => { + if (!resetTime) return ''; + if (resetTime === RESETTING_SOON) { + return t('usageIndicator.resettingSoon'); + } + return resetTime; + }, [t]); + + // Update countdown every second when usage is high + useEffect(() => { + if (!usage) return; + + const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent); + if (maxUsage < 99) { + setCountdown(''); + return; + } + + // Derive reset timestamp based on limitType + const resetTimestamp = usage.limitType === 'weekly' + ? usage.weeklyResetTimestamp + : usage.sessionResetTimestamp; + setCountdown(calculateCountdown(resetTimestamp)); + + // Update every second + const intervalId = setInterval(() => { + setCountdown(calculateCountdown(resetTimestamp)); + }, 1000); + + return () => clearInterval(intervalId); + }, [usage, calculateCountdown]); useEffect(() => { // Listen for usage updates from main process @@ -26,16 +83,27 @@ export function UsageIndicator() { setIsVisible(true); }); - // Request initial usage on mount - window.electronAPI.requestUsageUpdate().then((result) => { + // Request initial usage on mount using new API profile usage method + // This supports both Anthropic OAuth and Z.ai (and other API profiles) + window.electronAPI.getAPIProfileUsage().then((result) => { if (result.success && result.data) { setUsage(result.data); setIsVisible(true); } }); + // Set up interval to refresh usage every 30 seconds + const intervalId = setInterval(() => { + window.electronAPI.getAPIProfileUsage().then((result) => { + if (result.success && result.data) { + setUsage(result.data); + } + }); + }, 30000); // 30 seconds + return () => { unsubscribe(); + clearInterval(intervalId); }; }, []); @@ -45,6 +113,7 @@ export function UsageIndicator() { // Determine color based on highest usage percentage const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent); + const showCountdown = maxUsage >= 99; const colorClasses = maxUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' : @@ -57,31 +126,46 @@ export function UsageIndicator() { maxUsage >= 71 ? TrendingUp : Activity; + // Provider-specific labels + const isZai = usage.provider === 'zai'; + const sessionLabel = isZai ? t('usageIndicator.tokenUsage') : t('usageIndicator.sessionUsage'); + const weeklyLabel = isZai ? t('usageIndicator.monthlyToolUsage') : t('usageIndicator.weeklyUsage'); + return (
- {/* Session usage */} + {/* Session/Token usage */}
- Session Usage + {sessionLabel} {Math.round(usage.sessionPercent)}%
{usage.sessionResetTime && (
- Resets: {usage.sessionResetTime} + {t('usageIndicator.resets')}: {formatResetTime(usage.sessionResetTime)}
)} {/* Progress bar */} @@ -100,22 +184,22 @@ export function UsageIndicator() {
- {/* Weekly usage */} + {/* Weekly/Monthly Tool usage */}
- Weekly Usage + {weeklyLabel} {Math.round(usage.weeklyPercent)}%
{usage.weeklyResetTime && (
- Resets: {usage.weeklyResetTime} + {t('usageIndicator.resets')}: {formatResetTime(usage.weeklyResetTime)}
)} {/* Progress bar */}
= 99 ? 'bg-red-500' : + usage.weeklyPercent >= 95 ? 'bg-red-500' : usage.weeklyPercent >= 91 ? 'bg-orange-500' : usage.weeklyPercent >= 71 ? 'bg-yellow-500' : 'bg-green-500' @@ -129,7 +213,7 @@ export function UsageIndicator() { {/* Active profile */}
- Active Account + {t('usageIndicator.activeAccount')} {usage.profileName}
diff --git a/apps/frontend/src/renderer/components/__tests__/UsageIndicator.test.tsx b/apps/frontend/src/renderer/components/__tests__/UsageIndicator.test.tsx new file mode 100644 index 0000000000..f7e5559f19 --- /dev/null +++ b/apps/frontend/src/renderer/components/__tests__/UsageIndicator.test.tsx @@ -0,0 +1,431 @@ +/** + * Unit tests for UsageIndicator component + * Tests usage badge rendering, color coding, countdown timer, + * provider-specific labels, and tooltip content + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../../shared/i18n'; +import { UsageIndicator } from '../UsageIndicator'; +import type { ClaudeUsageSnapshot } from '../../../shared/types/agent'; +import { RESETTING_SOON } from '../../../shared/types/agent'; + +// Wrapper component for i18n +function I18nWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +// Mock window.electronAPI +const mockOnUsageUpdated = vi.fn(); +const mockUnsubscribe = vi.fn(); +const mockRequestUsageUpdate = vi.fn(); + +Object.defineProperty(window, 'electronAPI', { + value: { + onUsageUpdated: vi.fn((callback) => { + mockOnUsageUpdated.mockImplementation(callback); + return mockUnsubscribe; // unsubscribe function + }), + requestUsageUpdate: mockRequestUsageUpdate + }, + writable: true, + configurable: true +}); + +// Helper to create test usage snapshot +function createUsageSnapshot(overrides: Partial = {}): ClaudeUsageSnapshot { + const now = new Date(); + const future = new Date(now.getTime() + 5 * 60 * 60 * 1000); // 5 hours from now + + return { + sessionPercent: 50, + weeklyPercent: 30, + sessionResetTime: '5h 0m', + weeklyResetTime: '2d 4h', + sessionResetTimestamp: future.getTime(), + weeklyResetTimestamp: future.getTime(), + profileId: 'profile-1', + profileName: 'Test Profile', + fetchedAt: now, + limitType: 'session', + provider: 'anthropic-oauth', + ...overrides + }; +} + +function renderWithI18n(ui: React.ReactElement) { + return render({ui}); +} + +describe('UsageIndicator', () => { + beforeAll(async () => { + // Ensure i18n is initialized before running tests + // Guard against duplicate initialization warnings + if (!i18n.isInitialized) { + await i18n.init(); + } + }); + + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock for successful initial request + mockRequestUsageUpdate.mockResolvedValue({ + success: true, + data: createUsageSnapshot() + }); + }); + + describe('initialization', () => { + it('should return null when no usage data is available', async () => { + mockRequestUsageUpdate.mockResolvedValue({ + success: true, + data: null + }); + + const { container } = renderWithI18n(); + + // Component should return null initially + expect(container.firstChild).toBeNull(); + }); + + it('should request initial usage on mount', () => { + renderWithI18n(); + + expect(mockRequestUsageUpdate).toHaveBeenCalledTimes(1); + }); + + it('should set up usage update listener on mount', () => { + renderWithI18n(); + + expect(window.electronAPI.onUsageUpdated).toHaveBeenCalledTimes(1); + }); + + it('should render when usage data is received', async () => { + mockRequestUsageUpdate.mockResolvedValue({ + success: true, + data: createUsageSnapshot({ sessionPercent: 50, weeklyPercent: 30 }) + }); + + const { container } = renderWithI18n(); + + // Wait for async render + await waitFor(() => { + expect(container.firstChild).not.toBeNull(); + }); + }); + }); + + describe('color coding', () => { + it('should show green color when usage < 71%', async () => { + const usage = createUsageSnapshot({ sessionPercent: 50, weeklyPercent: 30 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button'); + expect(badge).toHaveClass('text-green-500'); + expect(badge).toHaveClass('bg-green-500/10'); + }); + }); + + it('should show yellow color when usage is 71-90%', async () => { + const usage = createUsageSnapshot({ sessionPercent: 75, weeklyPercent: 30 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button'); + expect(badge).toHaveClass('text-yellow-500'); + expect(badge).toHaveClass('bg-yellow-500/10'); + }); + }); + + it('should show orange color when usage is 91-94%', async () => { + const usage = createUsageSnapshot({ sessionPercent: 92, weeklyPercent: 30 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button'); + expect(badge).toHaveClass('text-orange-500'); + expect(badge).toHaveClass('bg-orange-500/10'); + }); + }); + + it('should show red color when usage >= 95%', async () => { + const usage = createUsageSnapshot({ sessionPercent: 96, weeklyPercent: 30 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button'); + expect(badge).toHaveClass('text-red-500'); + expect(badge).toHaveClass('bg-red-500/10'); + }); + }); + + it('should use the higher usage percentage for color determination', async () => { + const usage = createUsageSnapshot({ sessionPercent: 50, weeklyPercent: 96 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button'); + expect(badge).toHaveClass('text-red-500'); + }); + }); + }); + + describe('percentage display', () => { + it('should display the max usage percentage', async () => { + const usage = createUsageSnapshot({ sessionPercent: 72, weeklyPercent: 30 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + renderWithI18n(); + + await waitFor(() => { + expect(screen.getByText('72%')).toBeInTheDocument(); + }); + }); + + it('should show weekly percent when higher', async () => { + const usage = createUsageSnapshot({ sessionPercent: 30, weeklyPercent: 85 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + renderWithI18n(); + + await waitFor(() => { + expect(screen.getByText('85%')).toBeInTheDocument(); + }); + }); + }); + + describe('countdown timer at 99%+ usage', () => { + it('should show countdown when usage >= 99%', async () => { + const now = new Date(); + const future = new Date(now.getTime() + 2 * 60 * 60 * 1000 + 30 * 60 * 1000); // 2h 30m from now + + const usage = createUsageSnapshot({ + sessionPercent: 99, + weeklyPercent: 50, + sessionResetTimestamp: future.getTime(), + limitType: 'session' + }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button') as HTMLElement; + expect(badge?.textContent).toContain('99%'); + // Countdown should appear (format will vary slightly) + expect(badge?.textContent).toMatch(/\d+[hd]\s*\d+[hm]/); + }); + }); + + it('should not show countdown when usage < 99%', async () => { + const usage = createUsageSnapshot({ sessionPercent: 95, weeklyPercent: 30 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button') as HTMLElement; + expect(badge?.textContent).toContain('95%'); + // Should only have percentage, no countdown + const parts = badge?.textContent?.split(' ').filter(Boolean) || []; + expect(parts.length).toBe(1); + }); + }); + }); + + describe('resetting soon and past timestamp handling', () => { + it('should not show countdown when sessionResetTimestamp is in the past', async () => { + // Use timestamp arithmetic instead of mutable Date.setHours + const pastTimestamp = Date.now() - (60 * 60 * 1000); // 1 hour ago + + const usage = createUsageSnapshot({ + sessionPercent: 99, + weeklyPercent: 30, + sessionResetTimestamp: pastTimestamp, + limitType: 'session' + }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + const { container } = renderWithI18n(); + + await waitFor(() => { + const badge = container.querySelector('button') as HTMLElement; + expect(badge).toBeInTheDocument(); + // Badge should only contain the percentage, no countdown + expect(badge?.textContent).toContain('99%'); + const parts = badge?.textContent?.split(' ').filter(Boolean) || []; + expect(parts.length).toBe(1); + }); + }); + }); + + describe('tooltip content', () => { + it('should show session usage percentage in tooltip', async () => { + const usage = createUsageSnapshot({ sessionPercent: 72, weeklyPercent: 30 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + renderWithI18n(); + + // Wait for badge to render + const badge = await waitFor(() => screen.getByRole('button', { name: /claude usage status/i })); + expect(badge).toBeInTheDocument(); + + // Simulate hover to reveal tooltip + fireEvent.mouseEnter(badge); + + // Verify tooltip content appears with session usage percentage + await waitFor(() => { + expect(screen.getByText('72%')).toBeInTheDocument(); + }); + + // Cleanup hover state + fireEvent.mouseLeave(badge); + }); + + it('should show weekly usage percentage in tooltip', async () => { + const usage = createUsageSnapshot({ sessionPercent: 30, weeklyPercent: 45 }); + mockRequestUsageUpdate.mockResolvedValue({ success: true, data: usage }); + + renderWithI18n(); + + // Wait for badge to render + const badge = await waitFor(() => screen.getByRole('button', { name: /claude usage status/i })); + expect(badge).toBeInTheDocument(); + + // Simulate hover to reveal tooltip + fireEvent.mouseEnter(badge); + + // Verify tooltip content appears with weekly usage percentage + await waitFor(() => { + expect(screen.getByText('45%')).toBeInTheDocument(); + }); + + // Cleanup hover state + fireEvent.mouseLeave(badge); + }); + }); + + describe('accessibility', () => { + it('should have proper aria-label', async () => { + mockRequestUsageUpdate.mockResolvedValue({ + success: true, + data: createUsageSnapshot() + }); + + renderWithI18n(); + + await waitFor(() => { + const badge = screen.getByRole('button', { name: /claude usage status/i }); + expect(badge).toBeInTheDocument(); + }); + }); + }); + + describe('component lifecycle', () => { + it('should clean up usage listener on unmount', () => { + const { unmount } = renderWithI18n(); + + // Initially, unsubscribe has not been called (it's just returned) + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + unmount(); + + // After unmount, useEffect cleanup should have called unsubscribe + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('should update UI when onUsageUpdated callback is invoked', async () => { + // Initial request returns null (no data) + mockRequestUsageUpdate.mockResolvedValue({ + success: true, + data: null + }); + + renderWithI18n(); + + // Component should not render initially + await waitFor(() => { + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + // Simulate a usage update push from main process + const updatedUsage = createUsageSnapshot({ sessionPercent: 80, weeklyPercent: 40 }); + mockOnUsageUpdated(updatedUsage); + + // UI should update to show the new percentage + await waitFor(() => { + expect(screen.getByText('80%')).toBeInTheDocument(); + }); + }); + }); + + describe('provider-specific labels', () => { + it('should render badge with Z.ai provider data', async () => { + const zaiUsage = createUsageSnapshot({ + provider: 'zai', + sessionPercent: 37, + weeklyPercent: 53 + }); + mockRequestUsageUpdate.mockResolvedValue({ + success: true, + data: zaiUsage + }); + + renderWithI18n(); + + // Wait for badge to render with Z.ai usage (higher percentage is 53%) + const badge = await waitFor(() => screen.getByRole('button', { name: /claude usage status/i })); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('53%'); + + // Verify the component receives Z.ai provider data + // The provider-specific labels would be visible in tooltip on hover + expect(zaiUsage.provider).toBe('zai'); + }); + }); + + describe('RESETTING_SOON sentinel handling', () => { + it('should handle RESETTING_SOON sentinel in usage snapshot', async () => { + const resettingSoonUsage = createUsageSnapshot({ + sessionPercent: 95, + weeklyPercent: 30, + sessionResetTimestamp: undefined, + sessionResetTime: RESETTING_SOON, + limitType: 'session' + }); + mockRequestUsageUpdate.mockResolvedValue({ + success: true, + data: resettingSoonUsage + }); + + renderWithI18n(); + + // Verify component renders with RESETTING_SOON sentinel + await waitFor(() => { + const badge = screen.getByRole('button', { name: /claude usage status/i }); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('95%'); + }); + + // Verify the snapshot contains RESETTING_SOON sentinel + expect(resettingSoonUsage.sessionResetTime).toBe(RESETTING_SOON); + }); + }); +}); diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index 169aa3db9f..47ccd6619b 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -146,6 +146,23 @@ const browserMockAPI: ElectronAPI = { success: true }), + getAPIProfileUsage: async () => ({ + success: true, + data: { + sessionPercent: 0, + weeklyPercent: 0, + sessionResetTime: undefined, + weeklyResetTime: undefined, + sessionResetTimestamp: undefined, + weeklyResetTimestamp: undefined, + profileId: 'mock', + profileName: 'Mock Profile', + fetchedAt: new Date(), + limitType: 'session', + provider: 'anthropic-oauth' + } + }), + testConnection: async (_baseUrl: string, _apiKey: string, _signal?: AbortSignal) => ({ success: true, data: { diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index eebd6c4eb9..a8e2066939 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -132,6 +132,7 @@ export const IPC_CHANNELS = { PROFILES_UPDATE: 'profiles:update', PROFILES_DELETE: 'profiles:delete', PROFILES_SET_ACTIVE: 'profiles:setActive', + PROFILES_GET_USAGE: 'profiles:getUsage', PROFILES_TEST_CONNECTION: 'profiles:test-connection', PROFILES_TEST_CONNECTION_CANCEL: 'profiles:test-connection-cancel', PROFILES_DISCOVER_MODELS: 'profiles:discover-models', diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index 3732351858..7e6da98766 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -30,6 +30,16 @@ "messages": { "initializeToCreateTasks": "Initialize Auto Claude to create tasks" }, + "usageIndicator": { + "ariaLabel": "Claude usage status", + "sessionUsage": "Session Usage", + "weeklyUsage": "Weekly Usage", + "tokenUsage": "Token Usage", + "monthlyToolUsage": "Monthly Tool Usage", + "resets": "Resets", + "resettingSoon": "Resetting soon...", + "activeAccount": "Active Account" + }, "claudeCode": { "checking": "Checking Claude Code...", "upToDate": "Claude Code is up to date", diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index 1a9327f880..ce31d852fb 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -30,6 +30,16 @@ "messages": { "initializeToCreateTasks": "Initialisez Auto Claude pour créer des tâches" }, + "usageIndicator": { + "ariaLabel": "Statut d'utilisation Claude", + "sessionUsage": "Utilisation de la session", + "weeklyUsage": "Utilisation hebdomadaire", + "tokenUsage": "Utilisation des jetons", + "monthlyToolUsage": "Utilisation mensuelle des outils", + "resets": "Réinitialisation", + "resettingSoon": "Réinitialisation bientôt...", + "activeAccount": "Compte actif" + }, "claudeCode": { "checking": "Vérification de Claude Code...", "upToDate": "Claude Code est à jour", diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index a5344c0301..d1eaa1cdbd 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -2,6 +2,17 @@ * Agent-related types (Claude profiles and authentication) */ +// ============================================ +// Constants +// ============================================ + +/** + * Sentinel value for "resetting soon" state + * Used in usage snapshots when the reset time has passed + * Frontend should map this to a localized string via i18n + */ +export const RESETTING_SOON = '__RESETTING_SOON__' as const; + // ============================================ // Claude Profile Types (Multi-Account Support) // ============================================ @@ -37,6 +48,10 @@ export interface ClaudeUsageSnapshot { sessionResetTime?: string; /** When the weekly limit resets (human-readable or ISO) */ weeklyResetTime?: string; + /** Unix timestamp for session reset (for countdown timer) */ + sessionResetTimestamp?: number; + /** Unix timestamp for weekly reset (for countdown timer) */ + weeklyResetTimestamp?: number; /** Profile ID this snapshot belongs to */ profileId: string; /** Profile name for display */ @@ -45,6 +60,8 @@ export interface ClaudeUsageSnapshot { fetchedAt: Date; /** Which limit is closest to threshold ('session' or 'weekly') */ limitType?: 'session' | 'weekly'; + /** Provider type for usage display */ + provider?: 'anthropic-oauth' | 'zai' | 'other'; } /** diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index a607fad067..963a4019dc 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -306,6 +306,7 @@ export interface ElectronAPI { updateAPIProfile: (profile: APIProfile) => Promise>; deleteAPIProfile: (profileId: string) => Promise; setActiveAPIProfile: (profileId: string | null) => Promise; + getAPIProfileUsage: () => Promise>; // Note: AbortSignal is handled in preload via separate cancel IPC channels, not passed through IPC testConnection: (baseUrl: string, apiKey: string, signal?: AbortSignal) => Promise>; discoverModels: (baseUrl: string, apiKey: string, signal?: AbortSignal) => Promise>;