From ff7d3539f85c79098c1bf4b5bd43e4fdaff75c03 Mon Sep 17 00:00:00 2001 From: StillKnotKnown Date: Thu, 22 Jan 2026 11:46:52 +0200 Subject: [PATCH] fix(frontend): resolve merge conflict in usage-monitor.ts The merge from develop brought in changes to usage-monitor.ts that were not properly merged. The file had 405 lines instead of 1229 lines. This commit properly resolves the merge by taking the complete file from the develop branch (3b87e24d). Signed-off-by: StillKnotKnown --- .../src/main/claude-profile/usage-monitor.ts | 1155 ++++++++++++++--- 1 file changed, 990 insertions(+), 165 deletions(-) diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 9f61c1a5..55f72d70 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -12,13 +12,164 @@ import { EventEmitter } from 'events'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { ClaudeUsageSnapshot } from '../../shared/types/agent'; +import { loadProfilesFile } from '../services/profile/profile-manager'; +import type { APIProfile } from '../../shared/types/profile'; +import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../shared/utils/provider-detection'; + +// Re-export for backward compatibility +export type { ApiProvider }; + +/** + * Provider usage endpoint configuration + * Maps each provider to its usage monitoring endpoint path + */ +interface ProviderUsageEndpoint { + provider: ApiProvider; + usagePath: string; +} + +const PROVIDER_USAGE_ENDPOINTS: readonly ProviderUsageEndpoint[] = [ + { + provider: 'anthropic', + usagePath: '/api/oauth/usage' + }, + { + provider: 'zai', + usagePath: '/api/monitor/usage/quota/limit' + }, + { + provider: 'zhipu', + usagePath: '/api/monitor/usage/quota/limit' + } +] as const; + +/** + * Get usage endpoint URL for a provider + * Constructs full usage endpoint URL from provider baseUrl and usage path + * + * @param provider - The provider type + * @param baseUrl - The API base URL (e.g., 'https://api.z.ai/api/anthropic') + * @returns Full usage endpoint URL or null if provider unknown + * + * @example + * getUsageEndpoint('anthropic', 'https://api.anthropic.com') + * // returns 'https://api.anthropic.com/api/oauth/usage' + * getUsageEndpoint('zai', 'https://api.z.ai/api/anthropic') + * // returns 'https://api.z.ai/api/monitor/usage/quota/limit' + * getUsageEndpoint('unknown', 'https://example.com') + * // returns null + */ +export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string | null { + const isDebug = process.env.DEBUG === 'true'; + + if (isDebug) { + console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Constructing usage endpoint:', { + provider, + baseUrl + }); + } + + const endpointConfig = PROVIDER_USAGE_ENDPOINTS.find(e => e.provider === provider); + if (!endpointConfig) { + if (isDebug) { + console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Unknown provider - no endpoint configured:', { + provider, + availableProviders: PROVIDER_USAGE_ENDPOINTS.map(e => e.provider) + }); + } + return null; + } + + if (isDebug) { + console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Found endpoint config for provider:', { + provider, + usagePath: endpointConfig.usagePath + }); + } + + try { + const url = new URL(baseUrl); + const originalPath = url.pathname; + // Replace the path with the usage endpoint path + url.pathname = endpointConfig.usagePath; + + // Note: quota/limit endpoint doesn't require query parameters + // The model-usage and tool-usage endpoints would need time windows, but we're using quota/limit + + const finalUrl = url.toString(); + + if (isDebug) { + console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Successfully constructed endpoint:', { + provider, + originalPath, + newPath: endpointConfig.usagePath, + finalUrl + }); + } + + return finalUrl; + } catch (error) { + console.error('[UsageMonitor] Invalid baseUrl for usage endpoint:', baseUrl); + if (isDebug) { + console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] URL construction failed:', { + baseUrl, + error: error instanceof Error ? error.message : String(error) + }); + } + return null; + } +} + +/** + * Detect API provider from baseUrl + * Extracts domain and matches against known provider patterns + * + * @param baseUrl - The API base URL (e.g., 'https://api.z.ai/api/anthropic') + * @returns The detected provider type ('anthropic' | 'zai' | 'zhipu' | 'unknown') + * + * @example + * detectProvider('https://api.anthropic.com') // returns 'anthropic' + * detectProvider('https://api.z.ai/api/anthropic') // returns 'zai' + * detectProvider('https://open.bigmodel.cn/api/paas/v4') // returns 'zhipu' + * detectProvider('https://unknown.com/api') // returns 'unknown' + */ +export function detectProvider(baseUrl: string): ApiProvider { + // Wrapper around shared detectProvider with debug logging for main process + const isDebug = process.env.DEBUG === 'true'; + + const provider = sharedDetectProvider(baseUrl); + + if (isDebug) { + console.warn('[UsageMonitor:PROVIDER_DETECTION] Detected provider:', { + baseUrl, + provider + }); + } + + return provider; +} + +/** + * Result of determining the active profile type + */ +interface ActiveProfileResult { + profileId: string; + profileName: string; + isAPIProfile: boolean; + baseUrl: string; + credential?: string; +} export class UsageMonitor extends EventEmitter { private static instance: UsageMonitor; private intervalId: NodeJS.Timeout | null = null; private currentUsage: ClaudeUsageSnapshot | null = null; private isChecking = false; - private useApiMethod = true; // Try API first, fall back to CLI if it fails + + // Per-profile API failure tracking with cooldown-based retry + // Map - stores when API last failed for this profile + private apiFailureTimestamps: Map = new Map(); + private static API_FAILURE_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes cooldown before API retry // Swap loop protection: track profiles that recently failed auth private authFailedProfiles: Map = new Map(); // profileId -> timestamp @@ -41,23 +192,23 @@ export class UsageMonitor extends EventEmitter { /** * Start monitoring usage at configured interval + * + * Note: Usage monitoring always runs to display the usage badge. + * Proactive account swapping only occurs if enabled in settings. + * + * Update interval: 30 seconds (30000ms) to keep usage stats accurate */ start(): void { - const profileManager = getClaudeProfileManager(); - const settings = profileManager.getAutoSwitchSettings(); - - if (!settings.enabled || !settings.proactiveSwapEnabled) { - console.warn('[UsageMonitor] Proactive monitoring disabled. Settings:', JSON.stringify(settings, null, 2)); - return; - } - if (this.intervalId) { console.warn('[UsageMonitor] Already running'); return; } - const interval = settings.usageCheckInterval || 30000; - console.warn('[UsageMonitor] Starting with interval:', interval, 'ms'); + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getAutoSwitchSettings(); + const interval = settings.usageCheckInterval || 30000; // 30 seconds for accurate usage tracking + + console.warn('[UsageMonitor] Starting with interval:', interval, 'ms (30-second updates for accurate usage stats)'); // Check immediately this.checkUsageAndSwap(); @@ -86,8 +237,63 @@ export class UsageMonitor extends EventEmitter { return this.currentUsage; } + /** + * Get credential for usage monitoring (OAuth token or API key) + * Detects profile type and returns appropriate credential + * + * Priority: + * 1. API Profile (if active) - returns apiKey directly + * 2. OAuth Profile - returns decrypted oauthToken + * + * @returns The credential string or undefined if none available + */ + private async getCredential(): Promise { + // Try API profile first (highest priority) + try { + const profilesFile = await loadProfilesFile(); + if (profilesFile.activeProfileId) { + const activeProfile = profilesFile.profiles.find( + (p) => p.id === profilesFile.activeProfileId + ); + if (activeProfile && activeProfile.apiKey) { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Using API profile credential:', activeProfile.name); + } + return activeProfile.apiKey; + } + } + } catch (error) { + // API profile loading failed, fall through to OAuth + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Failed to load API profiles, falling back to OAuth:', error); + } + } + + // Fall back to OAuth profile + const profileManager = getClaudeProfileManager(); + const activeProfile = profileManager.getActiveProfile(); + if (activeProfile?.oauthToken) { + const decryptedToken = profileManager.getProfileToken(activeProfile.id); + if (this.isDebug && decryptedToken) { + console.warn('[UsageMonitor:TRACE] Using OAuth profile credential:', activeProfile.name); + } + return decryptedToken; + } + + // No credential available + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] No credential available (no API or OAuth profile active)'); + } + return undefined; + } + /** * Check usage and trigger swap if thresholds exceeded + * + * Refactored to use helper methods for better maintainability: + * - determineActiveProfile(): Detects API vs OAuth profile + * - checkThresholdsExceeded(): Evaluates usage against thresholds + * - handleAuthFailure(): Manages auth failure recovery */ private async checkUsageAndSwap(): Promise { if (this.isChecking) { @@ -95,20 +301,22 @@ export class UsageMonitor extends EventEmitter { } this.isChecking = true; + let profileId: string | undefined; + let isAPIProfile = false; try { - const profileManager = getClaudeProfileManager(); - const activeProfile = profileManager.getActiveProfile(); - + // Step 1: Determine active profile (API vs OAuth) + const activeProfile = await this.determineActiveProfile(); if (!activeProfile) { - console.warn('[UsageMonitor] No active profile'); - return; + return; // No active profile } - // Fetch current usage (hybrid approach) - // Get decrypted token from ProfileManager (activeProfile.oauthToken is encrypted) - const decryptedToken = profileManager.getProfileToken(activeProfile.id); - const usage = await this.fetchUsage(activeProfile.id, decryptedToken ?? undefined); + profileId = activeProfile.profileId; + isAPIProfile = activeProfile.isAPIProfile; + + // Step 2: Fetch current usage (pass activeProfile for consistency) + const credential = await this.getCredential(); + const usage = await this.fetchUsage(profileId, credential, activeProfile); if (!usage) { console.warn('[UsageMonitor] Failed to fetch usage'); return; @@ -116,75 +324,64 @@ export class UsageMonitor extends EventEmitter { this.currentUsage = usage; - // Emit usage update for UI + // Step 3: Emit usage update for UI (always emit, regardless of proactive swap settings) this.emit('usage-updated', usage); - // Check thresholds - const settings = profileManager.getAutoSwitchSettings(); - const sessionExceeded = usage.sessionPercent >= settings.sessionThreshold; - const weeklyExceeded = usage.weeklyPercent >= settings.weeklyThreshold; + // Step 4: Check thresholds and perform proactive swap (OAuth profiles only) + if (!isAPIProfile) { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getAutoSwitchSettings(); - if (sessionExceeded || weeklyExceeded) { - if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Threshold exceeded', { - sessionPercent: usage.sessionPercent, - weekPercent: usage.weeklyPercent, - activeProfile: activeProfile.id, - hasToken: !!decryptedToken - }); + if (!settings.enabled || !settings.proactiveSwapEnabled) { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Proactive swap disabled, skipping threshold check'); + } + return; } - console.warn('[UsageMonitor] Threshold exceeded:', { - sessionPercent: usage.sessionPercent, - sessionThreshold: settings.sessionThreshold, - weeklyPercent: usage.weeklyPercent, - weeklyThreshold: settings.weeklyThreshold - }); + const thresholds = this.checkThresholdsExceeded(usage, settings); - // Attempt proactive swap - await this.performProactiveSwap( - activeProfile.id, - sessionExceeded ? 'session' : 'weekly' - ); + if (thresholds.anyExceeded) { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Threshold exceeded', { + sessionPercent: usage.sessionPercent, + weekPercent: usage.weeklyPercent, + activeProfile: profileId, + hasCredential: !!credential + }); + } + + console.warn('[UsageMonitor] Threshold exceeded:', { + sessionPercent: usage.sessionPercent, + sessionThreshold: settings.sessionThreshold ?? 95, + weeklyPercent: usage.weeklyPercent, + weeklyThreshold: settings.weeklyThreshold ?? 99 + }); + + // Attempt proactive swap + await this.performProactiveSwap( + profileId, + thresholds.sessionExceeded ? 'session' : 'weekly' + ); + } else { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Usage OK', { + sessionPercent: usage.sessionPercent, + weekPercent: usage.weeklyPercent + }); + } + } } else { if (this.isDebug) { - console.warn('[UsageMonitor:TRACE] Usage OK', { - sessionPercent: usage.sessionPercent, - weekPercent: usage.weeklyPercent - }); + console.warn('[UsageMonitor:TRACE] Skipping proactive swap for API profile (only supported for OAuth profiles)'); } } } catch (error) { - // Check for auth failure (401/403) from fetchUsageViaAPI + // Step 5: Handle auth failures 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) => { - if (now - timestamp > UsageMonitor.AUTH_FAILURE_COOLDOWN_MS) { - this.authFailedProfiles.delete(profileId); - } - }); - - try { - const excludeProfiles = Array.from(this.authFailedProfiles.keys()); - console.warn('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles); - await this.performProactiveSwap( - activeProfile.id, - 'session', // Treat auth failure as session limit for immediate swap - excludeProfiles - ); - return; - } catch (swapError) { - console.error('[UsageMonitor] Failed to perform auth-failure swap:', swapError); - } + if (profileId) { + await this.handleAuthFailure(profileId, isAPIProfile); + return; // handleAuthFailure manages its own logging } } @@ -195,104 +392,757 @@ export class UsageMonitor extends EventEmitter { } /** - * Fetch usage - HYBRID APPROACH - * Tries API first, falls back to CLI if API fails + * Check if API method should be used for a specific profile + * + * Uses cooldown-based retry: API is retried after API_FAILURE_COOLDOWN_MS + * + * @param profileId - Profile identifier + * @returns true if API should be tried, false if CLI should be used */ - private async fetchUsage( - profileId: string, - oauthToken?: string - ): Promise { - const profileManager = getClaudeProfileManager(); - const profile = profileManager.getProfile(profileId); - if (!profile) { - return null; - } - - // Attempt 1: Direct API call (preferred) - if (this.useApiMethod && oauthToken) { - const apiUsage = await this.fetchUsageViaAPI(oauthToken, profileId, profile.name); - if (apiUsage) { - console.warn('[UsageMonitor] Successfully fetched via API'); - return apiUsage; - } - - // API failed - switch to CLI method for future calls - console.warn('[UsageMonitor] API method failed, falling back to CLI'); - this.useApiMethod = false; - } - - // Attempt 2: CLI /usage command (fallback) - return await this.fetchUsageViaCLI(profileId, profile.name); + private shouldUseApiMethod(profileId: string): boolean { + const lastFailure = this.apiFailureTimestamps.get(profileId); + if (!lastFailure) return true; // No previous failure, try API + // Check if cooldown has expired (use >= to allow retry at exact boundary) + const elapsed = Date.now() - lastFailure; + return elapsed >= UsageMonitor.API_FAILURE_COOLDOWN_MS; } /** - * Fetch usage via OAuth API endpoint - * Endpoint: https://api.anthropic.com/api/oauth/usage + * Determine which profile is active (API profile vs OAuth profile) + * API profiles take priority over OAuth profiles + * + * @returns Active profile info or null if no profile is active + */ + private async determineActiveProfile(): Promise { + // First, check if an API profile is active + try { + const profilesFile = await loadProfilesFile(); + if (profilesFile.activeProfileId) { + const activeAPIProfile = profilesFile.profiles.find( + (p) => p.id === profilesFile.activeProfileId + ); + if (activeAPIProfile?.apiKey) { + // API profile is active and has an apiKey + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Active auth type: API Profile', { + profileId: activeAPIProfile.id, + profileName: activeAPIProfile.name, + baseUrl: activeAPIProfile.baseUrl + }); + } + return { + profileId: activeAPIProfile.id, + profileName: activeAPIProfile.name, + isAPIProfile: true, + baseUrl: activeAPIProfile.baseUrl + }; + } else if (activeAPIProfile) { + // API profile exists but missing apiKey - fall back to OAuth + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Active API profile missing apiKey, falling back to OAuth', { + profileId: activeAPIProfile.id, + profileName: activeAPIProfile.name + }); + } + } else { + // activeProfileId is set but profile not found - fall through to OAuth + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Active API profile ID set but profile not found, falling back to OAuth'); + } + } + } + } catch (error) { + // Failed to load API profiles - fall through to OAuth + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Failed to load API profiles, falling back to OAuth:', error); + } + } + + // If no API profile is active, check OAuth profiles + const profileManager = getClaudeProfileManager(); + const activeOAuthProfile = profileManager.getActiveProfile(); + + if (!activeOAuthProfile) { + console.warn('[UsageMonitor] No active profile (neither API nor OAuth)'); + return null; + } + + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Active auth type: OAuth Profile', { + profileId: activeOAuthProfile.id, + profileName: activeOAuthProfile.name + }); + } + + return { + profileId: activeOAuthProfile.id, + profileName: activeOAuthProfile.name, + isAPIProfile: false, + baseUrl: 'https://api.anthropic.com' + }; + } + + /** + * Check if thresholds are exceeded for proactive swapping + * + * @param usage - Current usage snapshot + * @param settings - Auto-switch settings + * @returns Object indicating which thresholds are exceeded + */ + private checkThresholdsExceeded( + usage: ClaudeUsageSnapshot, + settings: { sessionThreshold?: number; weeklyThreshold?: number } + ): { sessionExceeded: boolean; weeklyExceeded: boolean; anyExceeded: boolean } { + const sessionExceeded = usage.sessionPercent >= (settings.sessionThreshold ?? 95); + const weeklyExceeded = usage.weeklyPercent >= (settings.weeklyThreshold ?? 99); + + return { + sessionExceeded, + weeklyExceeded, + anyExceeded: sessionExceeded || weeklyExceeded + }; + } + + /** + * Handle auth failure by marking profile as failed and attempting proactive swap + * + * @param profileId - Profile that failed auth + * @param isAPIProfile - Whether this is an API profile (proactive swap only for OAuth) + */ + private async handleAuthFailure(profileId: string, isAPIProfile: boolean): Promise { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getAutoSwitchSettings(); + + // Proactive swap is only supported for OAuth profiles, not API profiles + if (isAPIProfile || !settings.enabled || !settings.proactiveSwapEnabled) { + console.warn('[UsageMonitor] Auth failure detected but proactive swap is disabled or using API profile, skipping swap'); + return; + } + + // Mark this profile as auth-failed to prevent swap loops + this.authFailedProfiles.set(profileId, Date.now()); + console.warn('[UsageMonitor] Auth failure detected, marked profile as failed:', profileId); + + // Clean up expired entries from the failed profiles map + const now = Date.now(); + this.authFailedProfiles.forEach((timestamp, failedProfileId) => { + if (now - timestamp > UsageMonitor.AUTH_FAILURE_COOLDOWN_MS) { + this.authFailedProfiles.delete(failedProfileId); + } + }); + + try { + const excludeProfiles = Array.from(this.authFailedProfiles.keys()); + console.warn('[UsageMonitor] Attempting proactive swap (excluding failed profiles):', excludeProfiles); + await this.performProactiveSwap( + profileId, + 'session', // Treat auth failure as session limit for immediate swap + excludeProfiles + ); + } catch (swapError) { + console.error('[UsageMonitor] Failed to perform auth-failure swap:', swapError); + } + } + + /** + * Fetch usage - HYBRID APPROACH + * Tries API first, falls back to CLI if API fails + * + * Enhanced to support multiple providers (Anthropic, z.ai, ZHIPU) + * Detects provider from active profile's baseUrl and routes to appropriate endpoint + * + * @param profileId - Profile identifier + * @param credential - OAuth token or API key + * @param activeProfile - Optional active profile info to avoid race conditions + */ + private async fetchUsage( + profileId: string, + credential?: string, + activeProfile?: ActiveProfileResult + ): Promise { + // Get profile name - check both API profiles and OAuth profiles + let profileName: string | undefined; + + // First, check if it's an API profile + try { + const profilesFile = await loadProfilesFile(); + const apiProfile = profilesFile.profiles.find(p => p.id === profileId); + if (apiProfile) { + profileName = apiProfile.name; + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Found API profile:', { + profileId, + profileName, + baseUrl: apiProfile.baseUrl + }); + } + } + } catch (error) { + // Failed to load API profiles, continue to OAuth check + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error); + } + } + + // If not found in API profiles, check OAuth profiles + if (!profileName) { + const profileManager = getClaudeProfileManager(); + const oauthProfile = profileManager.getProfile(profileId); + if (oauthProfile) { + profileName = oauthProfile.name; + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Found OAuth profile:', { + profileId, + profileName + }); + } + } + } + + // If still not found, return null + if (!profileName) { + console.warn('[UsageMonitor:FETCH] Profile not found in either API or OAuth profiles:', profileId); + return null; + } + + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Starting usage fetch:', { + profileId, + profileName, + hasCredential: !!credential, + useApiMethod: this.shouldUseApiMethod(profileId) + }); + } + + // Attempt 1: Direct API call (preferred) + // Per-profile tracking: if API fails for one profile, it only affects that profile + if (this.shouldUseApiMethod(profileId) && credential) { + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Attempting API fetch method'); + } + const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, activeProfile); + if (apiUsage) { + console.warn('[UsageMonitor] Successfully fetched via API'); + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] API fetch successful:', { + sessionPercent: apiUsage.sessionPercent, + weeklyPercent: apiUsage.weeklyPercent + }); + } + return apiUsage; + } + + // API failed - record timestamp for cooldown-based retry + console.warn('[UsageMonitor] API method failed, recording failure timestamp for cooldown retry'); + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] API fetch failed, will retry after cooldown'); + } + this.apiFailureTimestamps.set(profileId, Date.now()); + } else if (!credential) { + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] No credential available, skipping API method'); + } + } + + // Attempt 2: CLI /usage command (fallback) + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Attempting CLI fallback method'); + } + return await this.fetchUsageViaCLI(profileId, profileName); + } + + /** + * Fetch usage via provider-specific API endpoints + * + * Supports multiple providers with automatic detection: + * - Anthropic OAuth: https://api.anthropic.com/api/oauth/usage + * - z.ai: https://api.z.ai/api/monitor/usage/model-usage + * - ZHIPU: https://open.bigmodel.cn/api/monitor/usage/model-usage + * + * Detects provider from active profile's baseUrl and routes to appropriate endpoint. + * Normalizes all provider responses to common ClaudeUsageSnapshot format. + * + * @param credential - OAuth token or API key + * @param profileId - Profile identifier + * @param profileName - Profile display name + * @param activeProfile - Optional pre-determined active profile info to avoid race conditions + * @returns Normalized usage snapshot or null on failure */ private async fetchUsageViaAPI( - oauthToken: string, + credential: string, profileId: string, - profileName: string + profileName: string, + activeProfile?: ActiveProfileResult ): Promise { + if (this.isDebug) { + console.warn('[UsageMonitor:API_FETCH] Starting API fetch for usage:', { + profileId, + profileName, + hasCredential: !!credential, + hasActiveProfile: !!activeProfile + }); + } + try { - const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + // Step 1: Determine if we're using an API profile or OAuth profile + // Use passed activeProfile if available, otherwise detect to maintain backward compatibility + let apiProfile: APIProfile | undefined; + let baseUrl: string; + let provider: ApiProvider; + + if (activeProfile && activeProfile.isAPIProfile) { + // Use the pre-determined profile to avoid race conditions + // Trust the activeProfile data and use baseUrl directly + baseUrl = activeProfile.baseUrl; + provider = detectProvider(baseUrl); + } else if (activeProfile && !activeProfile.isAPIProfile) { + // OAuth profile - always Anthropic + provider = 'anthropic'; + baseUrl = 'https://api.anthropic.com'; + } else { + // No activeProfile passed - need to detect from profiles file + const profilesFile = await loadProfilesFile(); + apiProfile = profilesFile.profiles.find(p => p.id === profileId); + + if (apiProfile && apiProfile.apiKey) { + // API profile found + baseUrl = apiProfile.baseUrl; + provider = detectProvider(baseUrl); + } else { + // OAuth profile fallback + provider = 'anthropic'; + baseUrl = 'https://api.anthropic.com'; + } + } + + if (this.isDebug) { + const isAPIProfile = !!apiProfile; + console.warn('[UsageMonitor:TRACE] Fetching usage', { + provider, + baseUrl, + isAPIProfile, + profileId + }); + } + + // Step 3: Get provider-specific usage endpoint + const usageEndpoint = getUsageEndpoint(provider, baseUrl); + if (!usageEndpoint) { + console.warn('[UsageMonitor] Unknown provider - no usage endpoint configured:', { + provider, + baseUrl, + profileId + }); + return null; + } + + if (this.isDebug) { + console.warn('[UsageMonitor:API_FETCH] Fetching from endpoint:', { + provider, + endpoint: usageEndpoint, + hasCredential: !!credential + }); + } + + // Step 4: Fetch usage from provider endpoint + // All providers use Bearer token authentication (RFC 6750) + const authHeader = `Bearer ${credential}`; + + const response = await fetch(usageEndpoint, { method: 'GET', headers: { - 'Authorization': `Bearer ${oauthToken}`, + 'Authorization': authHeader, 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01' + ...(provider === 'anthropic' && { 'anthropic-version': '2023-06-01' }) } }); if (!response.ok) { - console.error('[UsageMonitor] API error:', response.status, response.statusText); - // Throw specific error for auth failures so we can trigger a swap + console.error('[UsageMonitor] API error:', response.status, response.statusText, { + provider, + endpoint: usageEndpoint + }); + + // Check for auth failures via status code (works for all providers) if (response.status === 401 || response.status === 403) { - const error = new Error(`API Auth Failure: ${response.status}`); + const error = new Error(`API Auth Failure: ${response.status} (${provider})`); (error as any).statusCode = response.status; throw error; } + + // For other error statuses, try to parse response body to detect auth failures + // This handles cases where providers might return different status codes for auth errors + let errorData: any; + try { + errorData = await response.json(); + } catch (parseError) { + // If we can't parse the error response, just log it and continue + if (this.isDebug) { + console.warn('[UsageMonitor:AUTH_DETECTION] Could not parse error response body:', { + provider, + status: response.status, + parseError + }); + } + // Record failure timestamp for cooldown retry + this.apiFailureTimestamps.set(profileId, Date.now()); + return null; + } + + if (this.isDebug) { + console.warn('[UsageMonitor:AUTH_DETECTION] Checking error response for auth failure:', { + provider, + status: response.status, + errorData + }); + } + + // Check for common auth error patterns in response body + const authErrorPatterns = [ + 'unauthorized', + 'authentication', + 'invalid token', + 'invalid api key', + 'expired token', + 'forbidden', + 'access denied', + 'credentials', + 'auth failed' + ]; + + const errorText = JSON.stringify(errorData).toLowerCase(); + const hasAuthError = authErrorPatterns.some(pattern => errorText.includes(pattern)); + + if (hasAuthError) { + const error = new Error(`API Auth Failure detected in response body (${provider}): ${JSON.stringify(errorData)}`); + (error as any).statusCode = response.status; // Include original status code + (error as any).detectedInBody = true; + throw error; + } + + // Record failure timestamp for cooldown retry (non-auth error) + this.apiFailureTimestamps.set(profileId, Date.now()); return null; } - const data = await response.json() as { - five_hour_utilization?: number; - seven_day_utilization?: number; - five_hour_reset_at?: string; - seven_day_reset_at?: string; - }; + if (this.isDebug) { + console.warn('[UsageMonitor:API_FETCH] API response received successfully:', { + provider, + status: response.status, + contentType: response.headers.get('content-type') + }); + } - // Expected response format: - // { - // "five_hour_utilization": 0.72, // 0.0-1.0 - // "seven_day_utilization": 0.45, // 0.0-1.0 - // "five_hour_reset_at": "2025-01-17T15:00:00Z", - // "seven_day_reset_at": "2025-01-20T12:00:00Z" - // } + // Step 5: Parse and normalize response based on provider + const rawData = await response.json(); - return { - sessionPercent: Math.round((data.five_hour_utilization || 0) * 100), - weeklyPercent: Math.round((data.seven_day_utilization || 0) * 100), - sessionResetTime: this.formatResetTime(data.five_hour_reset_at), - weeklyResetTime: this.formatResetTime(data.seven_day_reset_at), - profileId, - profileName, - fetchedAt: new Date(), - limitType: (data.seven_day_utilization || 0) > (data.five_hour_utilization || 0) - ? 'weekly' - : 'session' - }; + if (this.isDebug) { + console.warn('[UsageMonitor:PROVIDER] Raw response from', provider, ':', JSON.stringify(rawData, null, 2)); + } + + // Step 6: Extract data wrapper for z.ai and ZHIPU responses + // These providers wrap the actual usage data in a 'data' field + let responseData = rawData; + if (provider === 'zai' || provider === 'zhipu') { + if (rawData.data) { + responseData = rawData.data; + if (this.isDebug) { + console.warn('[UsageMonitor:PROVIDER] Extracted data field from response:', { + provider, + extractedData: JSON.stringify(responseData, null, 2) + }); + } + } else { + if (this.isDebug) { + console.warn('[UsageMonitor:PROVIDER] No data field found in response, using raw response:', { + provider, + responseKeys: Object.keys(rawData) + }); + } + } + } + + // Step 7: Normalize response based on provider type + let normalizedUsage: ClaudeUsageSnapshot | null = null; + + if (this.isDebug) { + console.warn('[UsageMonitor:NORMALIZATION] Selecting normalization method:', { + provider, + method: `normalize${provider.charAt(0).toUpperCase() + provider.slice(1)}Response` + }); + } + + switch (provider) { + case 'anthropic': + normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName); + break; + case 'zai': + normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName); + break; + case 'zhipu': + normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName); + break; + default: + console.warn('[UsageMonitor] Unsupported provider for usage normalization:', provider); + return null; + } + + if (!normalizedUsage) { + console.warn('[UsageMonitor] Failed to normalize response from', provider); + // Record failure timestamp for cooldown retry (normalization failure) + this.apiFailureTimestamps.set(profileId, Date.now()); + return null; + } + + if (this.isDebug) { + console.warn('[UsageMonitor:PROVIDER] Normalized usage:', { + provider, + sessionPercent: normalizedUsage.sessionPercent, + weeklyPercent: normalizedUsage.weeklyPercent, + limitType: normalizedUsage.limitType + }); + console.warn('[UsageMonitor:API_FETCH] API fetch completed successfully'); + } + + return normalizedUsage; } catch (error: any) { // Re-throw auth failures to be handled by checkUsageAndSwap - if (error?.statusCode === 401 || error?.statusCode === 403) { + // This includes both status code auth failures (401/403) and body-detected failures + if (error?.message?.includes('Auth Failure') || error?.statusCode === 401 || error?.statusCode === 403) { throw error; } console.error('[UsageMonitor] API fetch failed:', error); + // Record failure timestamp for cooldown retry (network/other errors) + this.apiFailureTimestamps.set(profileId, Date.now()); return null; } } + /** + * Normalize Anthropic API response to ClaudeUsageSnapshot + * + * Expected Anthropic response format: + * { + * "five_hour_utilization": 0.72, // 0.0-1.0 + * "seven_day_utilization": 0.45, // 0.0-1.0 + * "five_hour_reset_at": "2025-01-17T15:00:00Z", + * "seven_day_reset_at": "2025-01-20T12:00:00Z" + * } + */ + private normalizeAnthropicResponse( + data: any, + profileId: string, + profileName: string + ): ClaudeUsageSnapshot { + const fiveHourUtil = data.five_hour_utilization ?? 0; + const sevenDayUtil = data.seven_day_utilization ?? 0; + + return { + sessionPercent: Math.round(fiveHourUtil * 100), + weeklyPercent: Math.round(sevenDayUtil * 100), + // Omit sessionResetTime/weeklyResetTime - renderer uses timestamps with formatTimeRemaining + sessionResetTime: undefined, + weeklyResetTime: undefined, + sessionResetTimestamp: data.five_hour_reset_at, + weeklyResetTimestamp: data.seven_day_reset_at, + profileId, + profileName, + fetchedAt: new Date(), + limitType: sevenDayUtil > fiveHourUtil ? 'weekly' : 'session', + usageWindows: { + sessionWindowLabel: 'common:usage.window5Hour', + weeklyWindowLabel: 'common:usage.window7Day' + } + }; + } + + /** + * Normalize quota/limit response for z.ai and ZHIPU providers + * + * Both providers use the same response format with a limits array containing + * TOKENS_LIMIT (5-hour usage) and TIME_LIMIT (monthly usage) items. + * + * @param data - Raw response data with limits array + * @param profileId - Profile identifier + * @param profileName - Profile display name + * @param providerName - Provider name for logging ('zai' or 'zhipu') + * @returns Normalized usage snapshot or null on parse failure + */ + private normalizeQuotaLimitResponse( + data: any, + profileId: string, + profileName: string, + providerName: 'zai' | 'zhipu' + ): ClaudeUsageSnapshot | null { + const logPrefix = providerName.toUpperCase(); + + if (this.isDebug) { + console.warn(`[UsageMonitor:${logPrefix}_NORMALIZATION] Starting normalization:`, { + profileId, + profileName, + responseKeys: Object.keys(data), + hasLimits: !!data.limits, + limitsCount: data.limits?.length || 0 + }); + } + + try { + // Check if response has limits array + if (!data || !Array.isArray(data.limits)) { + console.warn(`[UsageMonitor:${logPrefix}] Invalid response format - missing limits array:`, { + hasData: !!data, + hasLimits: !!data?.limits, + limitsType: typeof data?.limits + }); + return null; + } + + // Find TOKENS_LIMIT (5-hour usage) and TIME_LIMIT (monthly usage) + const tokensLimit = data.limits.find((item: any) => item.type === 'TOKENS_LIMIT'); + const timeLimit = data.limits.find((item: any) => item.type === 'TIME_LIMIT'); + + if (this.isDebug) { + console.warn(`[UsageMonitor:${logPrefix}_NORMALIZATION] Found limit types:`, { + hasTokensLimit: !!tokensLimit, + hasTimeLimit: !!timeLimit, + tokensLimit: tokensLimit ? { + type: tokensLimit.type, + unit: tokensLimit.unit, + number: tokensLimit.number, + usage: tokensLimit.usage, + currentValue: tokensLimit.currentValue, + remaining: tokensLimit.remaining, + percentage: tokensLimit.percentage, + nextResetTime: tokensLimit.nextResetTime, + nextResetDate: tokensLimit.nextResetTime ? new Date(tokensLimit.nextResetTime).toISOString() : undefined + } : null, + timeLimit: timeLimit ? { + type: timeLimit.type, + percentage: timeLimit.percentage, + currentValue: timeLimit.currentValue, + remaining: timeLimit.remaining + } : null + }); + } + + // Extract percentages + const sessionPercent = tokensLimit?.percentage !== undefined + ? Math.round(tokensLimit.percentage) + : 0; + + const weeklyPercent = timeLimit?.percentage !== undefined + ? Math.round(timeLimit.percentage) + : 0; + + if (this.isDebug) { + console.warn(`[UsageMonitor:${logPrefix}_NORMALIZATION] Extracted usage:`, { + sessionPercent, + weeklyPercent, + limitType: weeklyPercent > sessionPercent ? 'weekly' : 'session' + }); + } + + // Extract reset time from API response + // The API provides nextResetTime as a Unix timestamp (milliseconds) for TOKENS_LIMIT + const now = new Date(); + let sessionResetTimestamp: string; + + if (tokensLimit?.nextResetTime && typeof tokensLimit.nextResetTime === 'number') { + // Use the reset time from the API response (Unix timestamp in ms) + sessionResetTimestamp = new Date(tokensLimit.nextResetTime).toISOString(); + } else { + // Fallback: calculate as 5 hours from now + sessionResetTimestamp = new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString(); + } + + // Calculate monthly reset time (1st of next month at midnight UTC) + const nextMonth = new Date(now); + nextMonth.setUTCMonth(now.getUTCMonth() + 1, 1); + nextMonth.setUTCHours(0, 0, 0, 0); + const weeklyResetTimestamp = nextMonth.toISOString(); + + return { + sessionPercent, + weeklyPercent, + // Omit sessionResetTime/weeklyResetTime - renderer uses timestamps with formatTimeRemaining + sessionResetTime: undefined, + weeklyResetTime: undefined, + sessionResetTimestamp, + weeklyResetTimestamp, + profileId, + profileName, + fetchedAt: new Date(), + limitType: weeklyPercent > sessionPercent ? 'weekly' : 'session', + usageWindows: { + sessionWindowLabel: 'common:usage.window5HoursQuota', + weeklyWindowLabel: 'common:usage.windowMonthlyToolsQuota' + }, + // Extract raw usage values for display in tooltip + sessionUsageValue: tokensLimit?.currentValue, + sessionUsageLimit: tokensLimit?.usage, + weeklyUsageValue: timeLimit?.currentValue, + weeklyUsageLimit: timeLimit?.usage + }; + } catch (error) { + console.error(`[UsageMonitor:${logPrefix}] Failed to parse quota/limit response:`, error, 'Raw data:', data); + return null; + } + } + + /** + * Normalize z.ai API response to ClaudeUsageSnapshot + * + * Expected endpoint: https://api.z.ai/api/monitor/usage/quota/limit + * + * Response format (from empirical testing): + * { + * "data": { + * "limits": [ + * { + * "type": "TOKENS_LIMIT", + * "percentage": 75.5 + * }, + * { + * "type": "TIME_LIMIT", + * "percentage": 45.2, + * "currentValue": 12345, + * "usage": 50000, + * "usageDetails": {...} + * } + * ] + * } + * } + * + * Maps TOKENS_LIMIT → session usage (5-hour window) + * Maps TIME_LIMIT → monthly usage (displayed as weekly in UI) + */ + private normalizeZAIResponse( + data: any, + profileId: string, + profileName: string + ): ClaudeUsageSnapshot | null { + // Delegate to shared quota/limit response normalization + return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zai'); + } + + /** + * Normalize ZHIPU AI response to ClaudeUsageSnapshot + * + * Expected endpoint: https://open.bigmodel.cn/api/monitor/usage/quota/limit + * + * Uses the same response format as z.ai with limits array containing + * TOKENS_LIMIT and TIME_LIMIT items. + */ + private normalizeZhipuResponse( + data: any, + profileId: string, + profileName: string + ): ClaudeUsageSnapshot | null { + // Delegate to shared quota/limit response normalization + return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zhipu'); + } + /** * Fetch usage via CLI /usage command (fallback) * Note: This is a fallback method. The API method is preferred. @@ -310,31 +1160,6 @@ export class UsageMonitor extends EventEmitter { return null; } - /** - * Format ISO timestamp to human-readable reset time - */ - private formatResetTime(isoTimestamp?: string): string { - if (!isoTimestamp) return 'Unknown'; - - try { - const date = new Date(isoTimestamp); - const now = new Date(); - const diffMs = date.getTime() - now.getTime(); - 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`; - } catch (_error) { - return isoTimestamp; - } - } - /** * Perform proactive profile swap * @param currentProfileId - The profile to switch from