From cfe7dedd092127569b2ea4c8f8ce21f88b048dd8 Mon Sep 17 00:00:00 2001 From: StillKnotKnown <192589389+StillKnotKnown@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:48:13 +0200 Subject: [PATCH] feat: Add API profile providers usage endpoints support (#1279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * auto-claude: subtask-1-1 - Add provider type definitions and detection utility This commit adds: - ApiProvider type definition for usage monitoring (anthropic | zai | zhipu | unknown) - ProviderPattern interface mapping domain patterns to provider types - detectProvider() utility function to identify API provider from baseUrl - Support for subdomain matching (e.g., dev.bigmodel.cn matches bigmodel.cn) - Graceful error handling for invalid URLs (returns 'unknown') The detection function correctly identifies all known provider baseUrl patterns and returns 'unknown' for unsupported URLs. Co-Authored-By: Claude * auto-claude: subtask-1-2 - Create provider endpoint configuration mapping - Added ProviderUsageEndpoint interface following api-profiles.ts pattern - Created PROVIDER_USAGE_ENDPOINTS constant with usage paths for each provider: - anthropic: /api/oauth/usage (existing) - zai: /api/monitor/usage/model-usage (new) - zhipu: /api/monitor/usage/model-usage (new) - Added getUsageEndpoint() function to construct full usage endpoint URLs - Includes proper error handling and JSDoc documentation - Uses readonly arrays and const assertions matching codebase patterns Co-Authored-By: Claude * auto-claude: subtask-1-3 - Add credential detection logic (OAuth token vs API key) Implement credential detection logic that automatically determines whether to use OAuth token or API key based on the active profile type. Changes: - Add getCredential() private method that: * Checks for active API profile (via loadProfilesFile) * Returns apiKey directly if API profile is active * Falls back to OAuth profile (via getProfileToken) * Returns undefined if no credential available - Update checkUsageAndSwap() to use new getCredential() method - Add debug logging to trace credential type selection - Import required modules (loadProfilesFile, APIProfile type) This enables usage monitoring to work with both OAuth profiles (ClaudeProfile) and API profiles (APIProfile), paving the way for multi-provider support. Co-Authored-By: Claude * auto-claude: subtask-2-1 - Implement z.ai usage fetcher with response normalization Refactored fetchUsageViaAPI to support multiple providers: - Added provider detection from active API profile's baseUrl - Implemented provider-specific usage endpoint routing (anthropic, z.ai, zhipu) - Created normalizeZAIResponse with flexible field name matching for undocumented API - Created normalizeZhipuResponse using same flexible parsing as z.ai - Added helper methods: extractUsageField, extractLimitField, extractResetField - Added getAPIProfile to load active API profile with baseUrl and apiKey - Comprehensive logging for empirical response structure discovery - Graceful fallback to 0% usage when endpoints unavailable Follows existing Anthropic OAuth pattern while extending to support API profiles. Logs raw response structures for debugging undocumented endpoints. Co-Authored-By: Claude * auto-claude: subtask-2-3 - Create generic response normalization helper function - Added normalizeGenericProviderResponse() helper function to handle heterogeneous response formats from different providers - Refactored normalizeZAIResponse() to use the generic helper with zai-specific field mappings - Refactored normalizeZhipuResponse() to use the generic helper with zhipu-specific field mappings - The helper function accepts configurable field name mappings and performs flexible parsing for undocumented API response structures - Added comprehensive logging for debugging provider-specific issues - Maintains graceful degradation by returning 0% usage when parsing fails Co-Authored-By: Claude * auto-claude: subtask-3-2 - Add comprehensive logging for debugging provider issues Enhanced debug logging across all provider-related decision points: - Provider detection: Log baseUrl, domain extraction, pattern matching - Endpoint construction: Log URL building process, path replacement - API fetch orchestration: Track method selection, fallback behavior - Field extraction: Log attempted fields, matched fields, available keys - Normalization flow: Track method selection, percentage calculation - Provider-specific normalization: Detailed logging for zai/zhipu All debug logs are gated by DEBUG=true environment variable to avoid spam in production. Logs use structured [UsageMonitor:*] prefixes for easy filtering and grep. This makes it much easier to diagnose issues with: - Unknown provider baseUrl patterns - Incorrect endpoint path construction - Response format changes from providers - Field mapping failures in generic normalization * auto-claude: subtask-3-3 - Update error handling to trigger proactive swap for all providers Enhanced auth failure detection in fetchUsageViaAPI to support all providers: - Added response body parsing for auth error pattern detection - Checks for common auth error messages (unauthorized, invalid token, etc.) - Re-throws auth failures regardless of status code - Ensures proactive swap is triggered for auth failures from any provider This handles cases where providers might return non-401/403 status codes with auth-related error messages in the response body. * auto-claude: subtask-4-1 - Create test file structure and mocks - Created usage-monitor.test.ts with comprehensive test coverage - Tests provider detection (anthropic, zai, zhipu, unknown) - Tests usage endpoint construction for all providers - Tests UsageMonitor singleton pattern - Tests start/stop monitoring functionality - Tests event emission and listener management - All 25 tests passing Co-Authored-By: Claude * auto-claude: subtask-4-3 - Write response normalization tests Implemented comprehensive normalization tests for all providers: - Anthropic response normalization (2 tests) - z.ai response normalization (3 tests) - ZHIPU response normalization (2 tests) - Percentage calculation tests (2 tests) - Malformed response handling tests (2 tests) All tests pass and verify: - Correct percentage calculations from usage/limit values - Flexible field name matching for undocumented APIs - Graceful handling of missing/invalid data - Proper limitType determination - Reset time formatting Co-Authored-By: Claude * auto-claude: subtask-4-4 - Write error handling tests Added comprehensive error handling tests for usage-monitor: - API error handling (401, 403, 500, network failures, invalid JSON) - Credential error handling (missing/empty credentials) - Profile error handling (null profiles, missing fields) - Provider-specific error handling (zai, ZHIPU, unknown providers) - Reset time formatting error handling (invalid timestamps, null/undefined) - Concurrent check prevention All 55 tests passing successfully. * auto-claude: subtask-4-5 - Write backward compatibility tests - Added comprehensive backward compatibility tests for usage-monitor - Tests cover: legacy OAuth profiles, settings compatibility, response format changes, provider detection - All 18 backward compatibility tests pass Co-Authored-By: Claude * fix: Add frontend provider badges for OAuth, API token, and API profiles QA Fix - Addresses request to display provider type badges in the UI. Changes: - Created provider detection utility (provider-detection.ts) for renderer process - Updated AuthStatusIndicator to display provider type badges: - OAuth: Shows "Anthropic" with orange badge and Lock icon - z.ai API Profile: Shows "z.ai" with blue badge and Key icon - ZHIPU AI API Profile: Shows "ZHIPU AI" with purple badge and Key icon - Added AuthStatusIndicator to ProjectTabBar next to UsageIndicator - Provider detection based on baseUrl patterns matches backend logic - Added comprehensive tests for provider detection (18 tests) - Updated AuthStatusIndicator tests (9 tests) for new behavior - All 49 ProjectTabBar integration tests still pass The badges now clearly show which authentication method and provider is active: - Users can see at a glance whether they're using OAuth or an API profile - Provider-specific colors help distinguish between Anthropic, z.ai, and ZHIPU - Tooltips provide detailed information about authentication type and profile name Co-Authored-By: Claude * fix: Add usage window labels and fix z.ai monthly calculation This commit addresses QA feedback to improve usage badge display and correctly handle z.ai's monthly limits. Changes: - Add usageWindows metadata to ClaudeUsageSnapshot to track window types - Update normalizeAnthropicResponse to include '5-hour window' and '7-day window' labels - Update normalizeZAIResponse to include '5-hour window' and 'Calendar month' labels - Add monthly_usage/month_limit fields to z.ai weekly usage mapping - Update normalizeZhipuResponse to include '5-hour window' and '7-day window' labels - Update normalizeGenericProviderResponse to accept and use window labels - Update UsageIndicator to: - Show 5-hour window (sessionPercent) on the badge per QA feedback - Use dynamic window labels in hover tooltip instead of hardcoded text - Display provider-specific window types (e.g., "Calendar month" for z.ai) This ensures: - Badge shows the 5-hour window as requested - z.ai correctly calculates monthly limits (resets on 1st of month) - Hover tooltip shows all usage endpoints with clear labels - Usage badge displays for API profiles (z.ai, ZHIPU) All 1855 tests passing (0 regressions) Co-Authored-By: Claude * fix: Make usage badge show regardless of proactive swap settings The usage monitor now always starts and fetches usage data for the badge, even when proactive swap is disabled. Proactive swapping only occurs when explicitly enabled in settings. Changes: - Remove proactive swap check from start() method - Move proactive swap check into checkUsageAndSwap() before swapping logic - Always emit usage-updated events for UI badge - Only perform threshold checks and swaps when proactive swap enabled - Auth failure swaps also respect proactive swap setting This ensures the usage badge is always visible when usage data is available, while respecting user preferences for automatic account switching. Fixes QA issue: "The badge is not showing on the topbar next to the provider badge" QA Fix Session: 1 * fix: Always show usage badge regardless of endpoint availability Fixes QA issue: \"the usage badge is still not showing\" Problem: The UsageIndicator component would hide completely when usage data was unavailable (e.g., when z.ai or ZHIPU usage endpoints return errors or are unsupported). This left users with no indication that usage monitoring was active. Solution: Modified UsageIndicator to always display, showing three states: 1. Loading state (\"...\") - while fetching initial data 2. Unavailable state (\"N/A\") - when endpoint doesn't return data 3. Usage percentage - when data is available This ensures users can always see that usage monitoring is active and which profile is being used, with clear feedback when usage data is unavailable for certain providers. Changes: - Added isLoading state to show loading indicator on mount - Added isAvailable state to track if usage data is available - Render loading state with animated pulse icon - Render unavailable state with tooltip explanation - Only hide badge if both loading is done AND no data available All tests pass (1855 passed, 6 skipped). QA Fix Session: 1 * fix: Correct z.ai and ZHIPU usage endpoint implementation Fixes QA issue where z.ai provider was not being detected correctly and showed "N/A usage data is unavailable". Changes: 1. Add required query parameters (startTime, endTime) to z.ai and ZHIPU endpoints - Time window: from yesterday at current hour to today at current hour end - Matches reference implementation from z.ai usage script 2. Fix authentication header for z.ai and ZHIPU providers - Anthropic: Uses "Bearer ${token}" format - z.ai/ZHIPU: Use token directly (no "Bearer" prefix) - Matches reference implementation from z.ai usage script 3. Extract data wrapper from z.ai and ZHIPU responses - These providers wrap usage data in a "data" field - Extract data field before normalization - Matches reference implementation from z.ai usage script 4. Update tests to expect query parameters in endpoints - Tests now verify presence of startTime and endTime parameters - All 73 tests passing Verified: - All unit tests pass (73/73) - Provider detection works correctly for all providers - Endpoint construction includes required query parameters - Response parsing extracts data wrapper correctly - Authentication uses correct format per provider QA Fix Session: 1 * fix: Correct usage monitor auth detection for API profiles vs OAuth The usage monitor was not correctly detecting whether an API profile or OAuth profile was active, causing it to always show OAuth usage information even when an API profile was active. Changes: - Modified checkUsageAndSwap() to first check if an API profile is active (by checking profilesFile.activeProfileId) - Only fall back to OAuth profiles if no API profile is active - Updated fetchUsage() to check both API and OAuth profile sources - Made proactive swap only work for OAuth profiles (not API profiles) This ensures that when an API profile is active, the usage monitor fetches usage from the correct provider endpoint and displays the API profile's usage information instead of OAuth information. Fixes QA feedback: "The usage is not detecting that the active auth is not oauth but api profile and is showing the oauth information when it should be showing the active profiles information." Co-Authored-By: Claude * fix: Correct z.ai and ZHIPU usage endpoint implementation Fixes from QA feedback: - Query quota/limit endpoint instead of model-usage endpoint - Parse limits array to extract TOKENS_LIMIT and TIME_LIMIT data - Map TOKENS_LIMIT to session usage (5-hour window) - Map TIME_LIMIT to monthly usage (displayed as weekly in UI) - Ensure stats update every 30 seconds for accurate tracking The reference implementation shows that z.ai and ZHIPU providers require querying the /api/monitor/usage/quota/limit endpoint which returns a limits array with type and percentage fields. Co-Authored-By: Claude * docs: Update implementation plan with QA fix session 1 * fix: Update z.ai and ZHIPU usage labels and reset times - Change session window label from '5-hour window' to '5 Hours Quota' - Change weekly window label from 'Calendar month' to 'Total Monthly Tools Quota' - Calculate and display actual 5-hour window reset time (e.g., 'Resets in 1h 4m') - Display monthly reset as '1st of ' format Fixes QA feedback for usage display clarity. QA Fix Session: 2 * docs: Update implementation plan with QA fix session 2 * fix: Address QA feedback for usage monitoring UI Fixes: 1. Removed 'Usage' word from usage labels in tooltip - Changed '{sessionLabel} Usage' to '{sessionLabel}' - Changed '{weeklyLabel} Usage' to '{weeklyLabel}' 2. Verified Anthropic usage endpoints via WebSearch research - Confirmed OAuth endpoint: /api/oauth/usage is correct - Documented that Anthropic Claude usage does not have separate tools usage endpoint (unlike z.ai) - Anthropic returns overall utilization percentages only 3. Added usage warning badge (>90%) to AuthStatusIndicator - Badge appears to left of provider badge when usage >= 90% - Shows higher of session/weekly usage percentage - Includes countdown timer showing reset time for the window with higher usage - Tooltip displays usage alert, percentage, and reset countdown - Badge uses red color scheme with animated alert icon Also fixed TypeScript compilation errors in usage-monitor.ts: - Moved variable declarations outside try block for catch block accessibility - Added null checks before using profileId and profileName QA Fix Session: 3 * fix: Address QA feedback for usage monitoring UI Fixes: - Remove percentage display from usage warning badge (only show icon) - Move countdown timer from tooltip to visible badge positioned right of provider badge Changes: - Usage warning badge now only displays AlertTriangle icon without percentage text - Percentage value moved to tooltip content for the warning badge - Countdown timer now displays as a visible blue badge showing reset time - Countdown timer positioned to the right of provider badge - Countdown timer shows whenever usage data is available, not just during warnings Verified: - No TypeScript errors introduced in modified file - Layout follows flex order: [Warning Badge] [Provider Badge] [Countdown Timer] QA Fix Session: 3 * docs: Update implementation plan with QA fix session 3 - Fix session 3 completed - Issues fixed: Additional percentage removal and countdown timer repositioning - Ready for QA re-validation * fix: Address QA feedback for usage monitoring UI Fixes: 1. Remove duplicate 'Resets:' word in tooltips - Changed from "Resets: Resets in Xh Ym" to just "Resets in Xh Ym" 2. Replace countdown timer badge with 5 hour usage badge - Badge between provider and usage percentage now shows 5 hour usage percentage 3. Show 5 hour usage badge only when >= 90% and in red color - Badge is hidden until threshold is reached 4. Fix time synchronization issue - Store ISO timestamps and calculate relative time dynamically in UI instead of at fetch time Changes: - Added sessionResetTimestamp and weeklyResetTimestamp fields to ClaudeUsageSnapshot type - Updated z.ai and ZHIPU normalization to store ISO timestamps instead of pre-calculated strings - Updated UsageIndicator and AuthStatusIndicator components to calculate reset time dynamically from timestamps - Removed countdown timer badge, replaced with 5 hour usage badge that only shows when >= 90% Verified: - Build succeeds without errors - All UI components correctly calculate and display reset times dynamically - Badge behavior matches QA requirements QA Fix Session: 4 * docs: Update implementation plan with QA fix session 4 * fix: Correct 5-hour window reset time calculation Fixed the calculation of sessionResetTimestamp for the 5-hour rolling window to properly show time remaining until the next 5-hour interval boundary (0:00, 5:00, 10:00, 15:00, 20:00) instead of just the next hour. Changes: - Updated normalizeZAIResponse to calculate reset based on 5-hour intervals - Updated normalizeZhipuResponse to calculate reset based on 5-hour intervals - Reset time now correctly shows time remaining in the current window The >=90% badge is confirmed to be based on actual usage percentage (tokensLimit.percentage) from the API, not time-based calculation. Fixes QA feedback: "the usage badge tooltip is showing the remain time for the 5 hour window incorrectly. It should show remaining time left in the 5 hour window" QA Fix Session: 5 * docs: Update implementation plan with QA fix session 5 * fix: Correct 5-hour rolling window reset time calculation The previous implementation incorrectly calculated the reset time based on fixed 5-hour interval marks (0:00, 5:00, 10:00, 15:00, 20:00) instead of using a true rolling 5-hour window that resets exactly 5 hours from the current time. This matches the z.ai/ZHIPU provider behavior where the 5-hour window is a sliding window, not fixed interval resets. Changes: - Updated normalizeZAIResponse to calculate reset as now + 5 hours - Updated normalizeZhipuResponse to calculate reset as now + 5 hours - Removed complex logic for finding next 5-hour interval mark Example: At 23:51, the tooltip now correctly shows "Resets in 5h" instead of "Resets in ~3h" (until the next 0:00, 5:00, 10:00, etc. mark). Fixes: Incorrect time remaining display in usage tooltip * test: Fix TypeScript errors in usage-monitor.test.ts Fixed TypeScript compilation errors in the usage monitor test file: 1. Response mock type fixes: - Changed all 'as Response' casts to 'as unknown as Response' - Mock objects don't satisfy the full Response interface - Required 8 replacements across the file 2. mockLoadProfilesFile type fixes: - Added explicit type for profiles array to prevent 'never[]' inference - Added 'string | null' type annotation for activeProfileId The TypeScript compilation now passes successfully. Remaining test failures are pre-existing issues unrelated to these type fixes. * fix: Use nextResetTime from z.ai/ZHIPU API for accurate reset time calculation The quota/limit API response now includes nextResetTime as a Unix timestamp (milliseconds) for TOKENS_LIMIT, which provides the exact reset time for the 5-hour quota window. Changes: - Extract nextResetTime from tokensLimit in normalizeZAIResponse - Extract nextResetTime from tokensLimit in normalizeZhipuResponse - Fall back to "now + 5 hours" if nextResetTime is not available - Enhanced debug logging to show all API fields for future debugging Verified via live API test: - API returns nextResetTime: 1768708657242 - Correctly shows "Resets in 3h 43m" instead of incorrect "Resets in 5h" This matches the z.ai provider's actual quota window timing and ensures the tooltip displays accurate time remaining for the 5-hour quota. Note: The tool-usage and model-usage endpoints provide time-series analytics but are not needed for the tooltip display. The quota/limit endpoint provides all necessary information (TOKENS_LIMIT + TIME_LIMIT). * feat: Add raw usage values (xxx/xxxx format) to usage tooltip Adds display of raw usage values in "current/total" format for both token and tool usage in the usage indicator tooltip. Changes: - Added new optional fields to ClaudeUsageSnapshot type: - sessionUsageValue, sessionUsageLimit (tokens) - weeklyUsageValue, weeklyUsageLimit (tools) - Updated normalizeZAIResponse to extract currentValue and usage from TOKENS_LIMIT and TIME_LIMIT in quota/limit API response - Updated normalizeZhipuResponse with same extraction logic - Updated UsageIndicator tooltip to display "xxx/xxxx" format alongside percentage, using toLocaleString() for number formatting - Added comprehensive tests for quota/limit endpoint normalization: - z.ai quota/limit endpoint normalization tests - ZHIPU quota/limit endpoint normalization tests - Tests for missing nextResetTime, currentValue, usage fields Example tooltip display: - Session: "20,926,987/200,000,000 10%" - Tools: "660/1,000 66%" The raw values are only shown when both currentValue and usage are available in the API response, providing users with more detailed usage information. * refactor: Format usage values with units (K, M, B) and move below progress bar Changes: - Added formatUsageValue function to format large numbers with units: - Values >= 1B: Show as "X.XX B" (e.g., "1.50 B") - Values >= 1M: Show as "X.XX M" (e.g., "27.76 M") - Values >= 1K: Show as "X.X K" (e.g., "500.5 K") - Values < 1K: Show as-is (e.g., "660") - Moved raw usage values display from beside the percentage to below the progress bar - Updated both Session (5-hour quota) and Weekly (monthly tools) sections Before: "27,761,582/200,000,000" shown next to "10%" After: "27.76 M / 200 M" shown below the progress bar This makes the tooltip cleaner and the large numbers more readable. * refactor: Rename 'Total Monthly Tools Quota' to 'Monthly Tools Quota' Simplify the weekly window label in the usage tooltip from 'Total Monthly Tools Quota' to 'Monthly Tools Quota' for brevity. - Updated normalizeZAIResponse weeklyWindowLabel - Updated normalizeZhipuResponse weeklyWindowLabel - Updated corresponding test assertions * feat: Enhance provider tooltip with account information Added detailed account-related information to the provider badge tooltip for API profiles: - Profile name (moved to dedicated row) - Profile ID (truncated to 8 characters for readability) - Creation date (formatted as locale date) - API Endpoint URL (full baseUrl displayed in monospace font) - Provider website link with external link icon The tooltip now shows: Authentication: API Profile Provider: z.ai ───────────────────────────── Profile: My z.ai Account ID: a1b2c3d4 Created: 1/15/2026 API Endpoint https://api.z.ai/api/anthropic Visit z.ai ↗ This provides users with quick access to account details and provider resources directly from the header. * refactor: Simplify provider tooltip - remove visit link and created date Simplified the provider badge tooltip by removing: - Visit provider website link with external icon - Profile creation date The tooltip now shows a cleaner, more focused display: - Authentication type (OAuth / API Profile) - Provider name - Profile name and ID (truncated) - API Endpoint URL Removed unused helper functions: - formatDate() - providerWebsites constant - getProviderWebsite() * fix: Add i18n translations and fix usage monitor tests - Add i18n translation keys for all hardcoded UI strings in UsageIndicator and AuthStatusIndicator - Fix usage-monitor tests to match quota/limit endpoint format - Update getUsageEndpoint tests to expect quota/limit endpoint - Update z.ai/ZHIPU normalization tests to use limits array format - Add window.electronAPI mocks for usage functions in AuthStatusIndicator tests * fix: Remove node_modules from version control - Remove tracked node_modules symlinks from git index - These should not be committed as they are in .gitignore * fix: Add i18n support and error handling for usage indicators - Add 'usage' namespace to useTranslation hooks - Replace hardcoded "N/A" with i18n key (usage:notAvailable) - Replace hardcoded aria-label with i18n key (usage:usageStatusAriaLabel) - Replace hardcoded fallback labels (Session/Weekly) with i18n keys - Replace hardcoded "Resets in" strings with i18n keys (resetsInHours/resetsInDays) - Add error handling for requestUsageUpdate promises in both components - Add corresponding translation keys to en/common.json and fr/common.json * fix: Address remaining coderabbitai feedback - Fix formatResetTime to handle invalid and past timestamps: - Return 'Unknown' for invalid dates (NaN) - Return 'Expired' for past dates - Update tests to match new behavior - Fix resetTime fallback when formatResetTime returns undefined: - Use nullish-coalescing to preserve fallback values - Reorganize misplaced test case: - Move Anthropic subdomain test to correct block - Update AuthStatusIndicator.tsx: - Add 'usage' namespace to useTranslation - Add error handling for requestUsageUpdate promise - Update UsageIndicator.tsx: - Add 'usage' namespace to useTranslation - Add error handling for requestUsageUpdate promise - Fix sessionResetTime and weeklyResetTime fallback * fix: Refactor usage-monitor to use shared utilities and fix auth error handling - Fix HIGH severity auth error handling bug: - Narrow try-catch scope to only wrap response.json() - Auth errors are now properly propagated for proactive account swapping - Remove duplicate provider detection code: - Import detectProvider and ApiProvider from shared/utils/provider-detection.ts - Simplify local detectProvider to thin wrapper with debug logging - Remove duplicate PROVIDER_PATTERNS and ProviderPattern interface - Remove duplicate provider detection tests (covered by shared test suite) - Fix hardcoded month names: - Replace hardcoded monthNames array with Intl.DateTimeFormat API - Uses locale-aware formatting (defaults to English) Total: ~120 lines of duplicate code removed * refactor: Consolidate duplicate normalization functions - Consolidate normalizeZAIResponse and normalizeZhipuResponse into shared normalizeQuotaLimitResponse function with providerName parameter - Both functions now delegate to shared implementation - Removes ~230 lines of duplicate code Total improvement: ~350 lines of duplicate code removed across all commits * refactor: Extract formatResetTime to shared utility and localize provider names - Extract formatResetTime to shared utility (src/shared/utils/format-time.ts): - Add formatTimeRemaining() for renderer process with i18n support - Add formatTimeRemainingSimple() for main process (no i18n) - Simplify usage-monitor.ts to use formatTimeRemainingSimple wrapper - Update UI components to use shared formatTimeRemaining utility: - Remove duplicate formatResetTime implementations - Both components now call shared formatTimeRemaining() - Localize provider names in AuthStatusIndicator: - Add translation keys for provider labels (providerAnthropic, providerZai, providerZhipu) - Add authenticationAriaLabel translation key with interpolation - Update aria-label to use localized provider name via getLocalizedProviderLabel() - Update visible provider label to use i18n - Add i18n translations to en/common.json and fr/common.json Total: 1 new shared utility, ~50 lines of duplicate code removed * test: Fix AuthStatusIndicator test with proper i18n mocking and add format-time utility - Mock useTranslation hook directly instead of using I18nextProvider - Add AlertTriangle icon import to fix TypeScript error - Fix variable shadowing issue in translation mock - Add shared format-time.ts utility for time formatting * test: Remove unused variable callCountAfterStart * fix: Address coderabbitai feedback - remove unused mock, fix type safety, add date validation - Remove unused Translation mock from AuthStatusIndicator tests - Fix getLocalizedProviderLabel with type-safe PROVIDER_TRANSLATION_KEYS mapping - Add fallback to getProviderLabel for unknown providers - Add invalid date check (isNaN) to formatTimeRemaining for consistency - Add providerUnknown translation key to en/fr locales * test: Fix remaining coderabbitai feedback in usage-monitor tests - Remove unused variable weeklyReset - Replace fragile literal assertions with behavior-oriented checks - Strengthen getCurrentUsage test with explicit type and property checks * fix: Use correct namespace for reset-time translation keys - Update formatTimeRemaining defaults to use 'common:usage.resetsInHours' and 'common:usage.resetsInDays' - Update JSDoc examples to reflect correct namespace - Keys are in common.json under usage section, not in separate usage namespace * fix: CRITICAL - Add missing Bearer prefix for z.ai/ZHIPU API authentication This fixes a critical bug where z.ai and ZHIPU usage monitoring requests were failing with 401 Unauthorized due to missing 'Bearer ' prefix in the Authorization header. Root cause: Incorrect assumption in code comment that z.ai/ZHIPU use raw tokens instead of Bearer authentication. All providers (Anthropic, z.ai, ZHIPU) use standard Bearer token authentication per RFC 6750. The conditional logic that omitted 'Bearer ' for non-Anthropic providers has been removed. All providers now use consistent 'Bearer ${credential}' format. This fix restores usage monitoring functionality for users with z.ai or ZHIPU API profiles. Reported by: @sentry (AI agent) Severity: CRITICAL * refactor: Address remaining coderabbitai feedback Test improvements: - Replace brittle literal assertion with type checks for sessionResetTime (line 339) Code cleanup: - Remove unused normalizeGenericProviderResponse and helper methods (~248 lines) - Functions were dead code since normalizeZAIResponse and normalizeZhipuResponse use normalizeQuotaLimitResponse instead Documentation: - Add JSDoc notes to formatTimeRemainingSimple about hardcoded English sentinel values - Document ClaudeUsageSnapshot sessionResetTime/weeklyResetTime localization requirements - Note that renderer should use sessionResetTimestamp with formatTimeRemaining() for i18n * fix: Address remaining coderabbitai feedback i18n fixes: - Remove hardcoded "Claude" from usageStatusAriaLabel in en/fr locales - Change to provider-agnostic "Usage status" / "Statut d'utilisation" - Visible provider name already shown in badge text, aria-label doesn't need to repeat it Bug fix: - Guard isAPIProfile on apiKey presence to prevent OAuth swap suppression - If activeAPIProfile exists but lacks apiKey, fall back to OAuth instead - Added debug logging for this fallback scenario Test improvement: - Make getCurrentUsage test deterministic by seeding state - No longer relies on singleton state from previous tests * fix: Correct i18n namespace for usage translations Fixed tooltip texts not displaying correctly by updating the i18n namespace from 'usage:' to 'common:usage.' in components and tests. Changes: - UsageIndicator.tsx: Updated all usage translation keys to use 'common:usage.xxx' namespace - AuthStatusIndicator.tsx: Updated PROVIDER_TRANSLATION_KEYS and all translation calls to use 'common:usage.xxx' namespace - AuthStatusIndicator.test.tsx: Updated translation mock to use new namespace format The 'usage' translations are defined in common.json under the 'usage' key, not in a separate usage.json namespace file. * fix: Address coderabbitai feedback - Use providerUnknown translation key instead of skipping it and falling back to English getProviderLabel - Replace hardcoded K/M/B suffixes with locale-aware Intl.NumberFormat using notation: "compact" and compactDisplay: "short" - Add safe fallback to toString() if Intl is unavailable * refactor: Extract OAUTH_FALLBACK constant to eliminate duplication * refactor: Improve promise chain and type safety - Use .finally() to consolidate loading-state teardown, removing duplicated setIsLoadingUsage(false) calls - Add error logging in .catch() for better diagnostics - Change getLocalizedProviderLabel to accept ApiProvider instead of string to avoid unsafe cast * fix: Don't show stale reset time placeholder after window resets The usage tooltip was incorrectly showing "Resets in ..." after the 5-hour window had already reset. This happened because: 1. When the window resets, sessionResetTimestamp becomes a timestamp in the past 2. formatTimeRemaining() correctly returns undefined for past dates 3. But the code fell back to usage?.sessionResetTime, which contains the placeholder "Resets in ..." from the backend Fix: Remove the fallback to sessionResetTime/weeklyResetTime when formatTimeRemaining returns undefined. This prevents displaying stale placeholder text after the window has reset. * fix: Add timestamp fields to Anthropic OAuth usage response The normalizeAnthropicResponse function was missing the sessionResetTimestamp and weeklyResetTimestamp fields that the frontend uses for dynamic countdown calculation. This caused OAuth accounts to not display the "Resets in Xh Ym" countdown in the usage tooltip, while API profile accounts (z.ai, ZHIPU) worked correctly. The fix adds the raw ISO timestamps from the API response to the ClaudeUsageSnapshot, enabling formatTimeRemaining() to work for OAuth accounts. * test: Add assertions for timestamp fields in Anthropic normalization Add test assertions for sessionResetTimestamp and weeklyResetTimestamp in the normalizeAnthropicResponse tests. This ensures the raw ISO timestamps are properly passed through from the API response to the frontend for dynamic countdown calculation. * refactor: Address coderabbitai feedback - Fix duplicate getLocalizedProviderLabel calls in AuthStatusIndicator - Localize backend-provided usage window labels (5-hour window, 7-day window, 5 Hours Quota, Monthly Tools Quota) with translation keys - Add English and French translations for usage window labels - Create localizeUsageWindowLabel helper function to map backend labels to i18n translation keys * fix: Address CI typecheck and timezone issues - Restore truncated translation files (JSON syntax was valid but content was accidentally deleted during earlier edit) - Add usage window label translation keys for i18n (window5Hour, window7Day, window5HoursQuota, windowMonthlyToolsQuota) - Fix timezone bug in monthly reset calculation: use UTC methods (setUTCMonth, setUTCHours) instead of local timezone methods to ensure consistent UTC timestamps regardless of user's local timezone * fix: Close usage section in translation files before oauth section Fixes JSON syntax issue where the usage section was missing its closing brace before the oauth section began. * docs: map existing codebase - STACK.md - Technologies and dependencies - ARCHITECTURE.md - System design and patterns - STRUCTURE.md - Directory layout - CONVENTIONS.md - Code style and patterns - TESTING.md - Test structure - INTEGRATIONS.md - External services - CONCERNS.md - Technical debt and issues * docs: initialize project PR Review System Robustness - improvements to make PR reviews trustworthy enough to replace human review * chore: add project config Mode: yolo Depth: comprehensive Parallelization: enabled * fix: Address PR review feedback - code quality improvements Fixes all 7 issues from PR review: HIGH: - useApiMethod flag now uses per-profile tracking (Map) instead of a single global flag, allowing API retry for different profiles MEDIUM: - Added default values (95/99) for undefined threshold settings - Fixed stale placeholder text by checking for "..." in fallback values - Extracted duplicated localizeUsageWindowLabel to shared format-time utility - Refactored checkUsageAndSwap method into smaller helper methods: * determineActiveProfile() - Detects API vs OAuth profile * checkThresholdsExceeded() - Evaluates usage against thresholds * handleAuthFailure() - Manages auth failure recovery LOW: - Added error logging in UsageIndicator catch block - Fixed variable shadowing in forEach callback (failedProfileId) * fix: Address CodeRabbitAI actionable comments for i18n compliance Fixes all actionable comments from CodeRabbitAI review: 1. localizeUsageWindowLabel now returns localized fallback (t(defaultKey)) instead of raw backend text for unknown labels 2. Added nullish coalescing (??) in UsageIndicator and AuthStatusIndicator to preserve fallback when formatTimeRemaining returns undefined 3. Weekly label now uses weekly-specific default key ('common:usage.weeklyDefault') instead of session default 4. Removed hardcoded English strings from main process: - Omitted sessionResetTime/weeklyResetTime fields (set to undefined) - Removed formatResetTime() method that returned 'Unknown'/'Expired' - Removed import of formatTimeRemainingSimple - Removed 'Resets in ...' placeholder - Removed '1st of {month}' hardcoded monthly reset format - Renderer now uses timestamps with formatTimeRemaining() for i18n 5. Updated tests to reflect undefined reset times and verify timestamps are still provided for renderer localization * feat: Enhance usage and provider tooltips with improved visual design - UsageIndicator: Add header with icon, gradient progress bars with shine effect, icons for each section (Clock, TrendingUp, Info, User), improved spacing and layout - AuthStatusIndicator: Add header with Shield icon, profile details with Fingerprint/Key icons, styled monospace ID badge, bordered code block for API endpoint - Add i18n keys: usageBreakdown, used, authenticationDetails, created (en + fr) - Remove Calendar import after removing created date display * fix: Handle null values in formatUsageValue for robustness Change from strict equality (=== undefined) to loose equality (== null) to catch both null and undefined values from API responses. This prevents 'null' string being displayed when backend sends null. * fix: Localize loading state in UsageIndicator Replace hardcoded "..." with localized t('common:usage.loading') key. Add "loading": "Loading..." (en) and "Chargement..." (fr) to usage section. * fix: Accessibility and i18n improvements for UsageIndicator - Use motion-safe:animate-pulse to respect prefers-reduced-motion - Filter out hardcoded English 'Unknown' and 'Expired' strings from main process - Add hasHardcodedText helper to check for placeholder/sentinel values * fix: Prevent incomplete UI display when API returns null values Change conditional checks from !== undefined to != null to catch both null and undefined values. This prevents broken UI display like " / 5000" or "1000 / " when formatUsageValue returns undefined for null inputs. * refactor: Code quality improvements - shared helpers and better defensive coding QUAL-002: Extract hasHardcodedText to shared utility (format-time.ts) - Export hasHardcodedText function for consistent sentinel value filtering - Update both UsageIndicator and AuthStatusIndicator to use shared helper - Add JSDoc documentation with examples QUAL-003: Use nullish coalescing in usage-monitor.ts - Change || 0 to ?? 0 for utilization value defaults - Only defaults for null/undefined, not other falsy values QUAL-001: Document formatUsageValue behavior - Add comprehensive JSDoc explaining undefined return behavior - Document that caller is responsible for null checking (which they do) - Include usage examples * hotfix(build): disable npmRebuild for electron-builder workspace compatibility electron-builder's @electron/rebuild cannot properly handle symlinked node_modules directories in npm workspace setups. It fails with: ENOENT: no such file or directory, stat 'apps/frontend/node_modules' Setting npmRebuild: false bypasses this issue because: 1. stageRuntimePackages() already copies @lydell/node-pty to out/main/node_modules 2. @lydell/node-pty uses prebuilt binaries - no rebuild needed 3. This skips the problematic @electron/rebuild phase entirely Fixes macOS Intel, macOS Silicon, and potentially Windows release builds. Co-Authored-By: Claude Opus 4.5 * fix: Implement cooldown-based API retry and motion-safe animations HIGH: Fix permanent API failure disabling usage monitoring - Change from boolean flag to timestamp-based cooldown mechanism - Replace useApiMethodForProfile Map with apiFailureTimestamps - API failures now record timestamp and retry after 2 minute cooldown - Update shouldUseApiMethod to check cooldown expiration TRIVIAL: Respect prefers-reduced-motion for accessibility - Change animate-pulse to motion-safe:animate-pulse in AuthStatusIndicator warning - Change animate-pulse to motion-safe:animate-pulse in UsageIndicator loading state * fix(ci): handle npm workspaces partial node_modules blocking symlink npm workspaces may create a partial node_modules directory in apps/frontend for packages that couldn't be hoisted. This blocked the symlink creation because the condition checked if the directory existed before creating the symlink. Changes: - Remove any existing partial node_modules directory (not symlink) - Only then create the symlink to root node_modules - Add verification that symlink resolves correctly - Add detailed logging for debugging This fixes macOS release builds failing with ENOENT during @electron/osx-sign code signing phase. Co-Authored-By: Claude Opus 4.5 * fix(ci): remove broken symlinks before creating node_modules link The previous fix only removed partial directories but not broken symlinks. CI logs showed that an existing broken symlink pointing to "../../../../../../apps/frontend/node_modules" was skipped, causing electron-builder to fail with ENOENT during macOS code signing. Changes: - Check if existing symlink points to correct target (../../node_modules) - Remove incorrect or broken symlinks before creating new one - Add strict verification that symlink exists AND resolves correctly - Fail fast with clear error if verification fails Co-Authored-By: Claude Opus 4.5 * fix(ci): remove broken symlinks before creating node_modules link The previous fix only removed partial directories but not broken symlinks. CI logs showed that an existing broken symlink pointing to "../../../../../../apps/frontend/node_modules" was skipped, causing electron-builder to fail with ENOENT during macOS code signing. Changes: - Check if existing symlink points to correct target (../../node_modules) - Remove incorrect or broken symlinks before creating new one - Add strict verification that symlink exists AND resolves correctly - Fail fast with clear error if verification fails Co-Authored-By: Claude Opus 4.5 * fix(ci): handle Windows junction verification correctly Windows junctions don't appear as symlinks to bash's -L test, causing the verification step to fail even when the junction was created successfully. Changes: - Skip symlink check (-L) on Windows since junctions are different - Verify link works by checking electron package is accessible - Add more diagnostic output on failure Co-Authored-By: Claude Opus 4.5 * fix(ci): use absolute path for Windows junction target Windows mklink /J resolves relative paths from CWD, not from the junction's location. This caused the junction to point to the wrong directory (D:\a\node_modules instead of D:\a\Auto-Claude\Auto-Claude\node_modules). Fix: Use pwd -W to get the Windows-style absolute path for the target. Co-Authored-By: Claude Opus 4.5 * fix(ci): use cygpath for proper Windows junction path format pwd -W output was being mangled when passed to cmd.exe, resulting in paths like \D:/a/... instead of D:\a\... Use cygpath -w which properly converts to Windows path format with backslashes. Co-Authored-By: Claude Opus 4.5 * fix(ci): use PowerShell for Windows junction creation cmd.exe's mklink was creating junctions with malformed paths (leading backslash before drive letter). PowerShell's New-Item -ItemType Junction handles Windows paths more reliably. Co-Authored-By: Claude Opus 4.5 * fix(ci): fix yq command multiline parsing in merge-macos-manifests The multiline yq expression was being incorrectly parsed by the shell, causing 'invalid input text' error. Put the expression on a single line. Co-Authored-By: Claude Opus 4.5 * fix(ci): use heredoc for yq expression to avoid shell escaping The yq expression with special characters (brackets, braces, pipes) was being mangled by shell expansion. Write it to a file using heredoc with quoted delimiter to prevent any interpretation. Co-Authored-By: Claude Opus 4.5 * fix(ci): use two-step yq approach for manifest merging The 'add' function doesn't exist in yq v4, causing parse errors. Use a two-step approach that was tested locally: 1. Collect all files with '[.files] | flatten' 2. Load merged files into first manifest with 'load()' Tested locally with yq v4.50.1 Co-Authored-By: Claude Opus 4.5 * fix: Prevent race condition in profile detection during usage fetch MEDIUM: Fix potential credential/basUrl mismatch from profile changes The active profile is now determined once in checkUsageAndSwap and passed down through fetchUsage to fetchUsageViaAPI. This prevents race conditions where the profile could change between determineActiveProfile() and the later getAPIProfile() call in fetchUsageViaAPI. Changes: - Add activeProfile parameter to fetchUsage() and fetchUsageViaAPI() - Pass pre-determined activeProfile from checkUsageAndSwap() - Use passed profile info in fetchUsageViaAPI when available - Fallback to getAPIProfile() for backward compatibility * test: Add tests for cooldown retry and race condition fixes - Add cooldown-based API retry tests: - Record API failure timestamp on error - Allow API retry after cooldown expires (2 minutes) - Prevent API retry during cooldown period - Allow API call when no previous failure recorded - Handle edge case exactly at cooldown boundary - Track failures independently for different profiles - Add race condition prevention tests: - Use passed activeProfile instead of re-detecting - Fall back to profile detection when activeProfile not provided - Handle OAuth profile in activeProfile parameter - Add shared utility hasHardcodedText tests: - Return true for empty string, null, undefined - Return true for 'Unknown' and 'Expired' - Return false for valid time strings - Case-sensitive filtering - Handle whitespace-only strings - Fix 'should handle unknown provider gracefully' test to pass activeProfile parameter so it correctly tests unknown provider path instead of OAuth fallback path * fix: Address CodeRabbitAI actionable comments 1. Remove unused getAPIProfile() method (dead code) - The method was defined but never called - fetchUsageViaAPI() calls loadProfilesFile() directly instead - No other references to getAPIProfile exist in the codebase 2. Fix hardcoded English strings in usageWindows (i18n violation) - Changed normalizeAnthropicResponse to use 'common:usage.window5Hour' and 'common:usage.window7Day' - Changed normalizeQuotaLimitResponse to use 'common:usage.window5HoursQuota' and 'common:usage.windowMonthlyToolsQuota' - Updated localizeUsageWindowLabel() to handle translation keys from backend - Maintains backward compatibility for legacy hardcoded strings via USAGE_WINDOW_LABEL_MAP - Updated test expectations to expect translation keys instead of hardcoded strings * test: Fix hasHardcodedText import usage - Changed from require() to ES6 import at top of file - Removed duplicate require statements from each test - Import path: ../../shared/utils/format-time * fix: Address CodeRabbitAI test failures 1. Fix hasHardcodedText to trim whitespace before checking - Now treats whitespace-only strings like ' ' as empty - Uses text?.trim() before falsy/Unknown/Expired checks 2. Fix shouldUseApiMethod cooldown boundary comparison - Changed from > to >= for exact boundary handling - Now allows retry when elapsed time >= API_FAILURE_COOLDOWN_MS 3. Fix unknown provider handling in fetchUsageViaAPI - Use activeProfile.baseUrl directly when isAPIProfile is true - Avoids race condition from re-fetching profiles file - Prevents falling through to OAuth path when profile lookup fails 4. Record API failure timestamp on all error paths - Added timestamp recording before return null in parse error path - Added timestamp recording before return null in non-auth error path - Added timestamp recording in catch block for network errors - Added timestamp recording for normalization failures * fix: TypeScript type errors for ActiveProfileResult - Added baseUrl and credential properties to ActiveProfileResult interface - Updated determineActiveProfile() to return baseUrl in ActiveProfileResult - Fixed isAPIProfile reference in debug logging (computed locally) - Removed credential property from test objects (not required by interface) * test: Fix console.warn expectation for unknown provider test - Updated test to match actual console.warn call with two arguments - First argument is the message string, second is the details object - Changed from expect.stringContaining to expect.objectContaining for proper matching * fix: Use profileId parameter instead of activeProfile?.profileId in fallback path When activeProfile is not provided (falsy), the code was using activeProfile?.profileId which always evaluates to undefined. The correct approach is to use the profileId parameter which is available in the function scope. This fixes the bug where the fallback profile detection would never find the correct profile when invoked without an activeProfile parameter. --------- Co-authored-by: StillKnotKnown Co-authored-by: Claude Co-authored-by: Test User Co-authored-by: AndyMik90 Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> --- .planning/PROJECT.md | 69 + .planning/codebase/ARCHITECTURE.md | 193 ++ .planning/codebase/CONCERNS.md | 224 +++ .planning/codebase/CONVENTIONS.md | 283 +++ .planning/codebase/INTEGRATIONS.md | 204 ++ .planning/codebase/STACK.md | 140 ++ .planning/codebase/STRUCTURE.md | 219 +++ .planning/codebase/TESTING.md | 485 +++++ .planning/config.json | 6 + .../main/claude-profile/usage-monitor.test.ts | 1709 +++++++++++++++++ .../src/main/claude-profile/usage-monitor.ts | 1167 +++++++++-- .../components/AuthStatusIndicator.test.tsx | 129 +- .../components/AuthStatusIndicator.tsx | 302 ++- .../src/renderer/components/ProjectTabBar.tsx | 2 + .../renderer/components/UsageIndicator.tsx | 284 ++- .../src/shared/i18n/locales/en/common.json | 35 + .../src/shared/i18n/locales/fr/common.json | 35 + apps/frontend/src/shared/types/agent.ts | 38 +- apps/frontend/src/shared/utils/format-time.ts | 203 ++ .../shared/utils/provider-detection.test.ts | 120 ++ .../src/shared/utils/provider-detection.ts | 112 ++ implementation_plan.json | 67 +- 22 files changed, 5731 insertions(+), 295 deletions(-) create mode 100644 .planning/PROJECT.md create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md create mode 100644 .planning/config.json create mode 100644 apps/frontend/src/main/claude-profile/usage-monitor.test.ts create mode 100644 apps/frontend/src/shared/utils/format-time.ts create mode 100644 apps/frontend/src/shared/utils/provider-detection.test.ts create mode 100644 apps/frontend/src/shared/utils/provider-detection.ts diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 00000000..3ce1b9df --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,69 @@ +# PR Review System Robustness + +## What This Is + +Improvements to Auto Claude's PR review system to make it trustworthy enough to replace human review. The system uses specialist agents (security, logic, quality, codebase-fit) with a finding-validator that re-investigates findings before presenting them. This milestone fixes gaps that cause false positives and missed context. + +## Core Value + +**When the system flags something, it's a real issue.** Trustworthy PR reviews that are faster, more thorough, and more accurate than human review. + +## Requirements + +### Validated + +- ✓ Multi-agent PR review architecture — existing +- ✓ Specialist agents (security, logic, quality, codebase-fit) — existing +- ✓ Finding-validator for follow-up reviews — existing +- ✓ Dismissal tracking with reasons — existing +- ✓ CI status enforcement — existing +- ✓ Context gathering (diff, comments, related files) — existing + +### Active + +- [ ] **REQ-001**: Finding-validator runs on initial reviews (not just follow-ups) +- [ ] **REQ-002**: Fix line 1288 bug — include ai_reviews in follow-up context +- [ ] **REQ-003**: Fetch formal PR reviews from `/pulls/{pr}/reviews` API +- [ ] **REQ-004**: Add Read/Grep/Glob tool instructions to all specialist prompts +- [ ] **REQ-005**: Expand JS/TS import analysis (path aliases, CommonJS, re-exports) +- [ ] **REQ-006**: Add Python import analysis (currently skipped) +- [ ] **REQ-007**: Increase related files limit from 20 to 50 with prioritization +- [ ] **REQ-008**: Add reverse dependency analysis (what imports changed files) + +### Out of Scope + +- Real-time review streaming — complexity, not needed for accuracy goal +- Review caching/memoization — premature optimization +- Custom specialist agents — current four dimensions sufficient + +## Context + +**Problem**: False positives in PR reviews erode trust. Users have to second-guess every finding, defeating the purpose of automated review. + +**Root cause**: Finding-validator (which catches false positives) only runs during follow-up reviews. Initial reviews present unvalidated findings. Additionally, context gathering has bugs and gaps that cause the AI to make claims without complete information. + +**Existing system**: +- `apps/backend/runners/github/` — PR review orchestration +- `apps/backend/runners/github/services/parallel_orchestrator_reviewer.py` — initial review +- `apps/backend/runners/github/services/parallel_followup_reviewer.py` — follow-up review (has finding-validator) +- `apps/backend/runners/github/context_gatherer.py` — gathers PR context +- `apps/backend/prompts/github/pr_*.md` — specialist agent prompts + +**Reference**: Full PRD at `docs/PR_REVIEW_SYSTEM_IMPROVEMENTS.md` + +## Constraints + +- **Existing architecture**: Work within current multi-agent PR review structure +- **Backward compatibility**: Don't break existing review workflows +- **Performance**: Validation step should not significantly slow reviews (run in parallel where possible) + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Add finding-validator to initial reviews | Catches false positives before user sees them | — Pending | +| Same validator for initial and follow-up | Consistency, proven approach from follow-up reviews | — Pending | +| Expand import analysis incrementally | JS/TS first (REQ-005), Python second (REQ-006) | — Pending | + +--- +*Last updated: 2026-01-19 after initialization* diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 00000000..276b9594 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,193 @@ +# Architecture + +**Analysis Date:** 2026-01-19 + +## Pattern Overview + +**Overall:** Multi-Agent Orchestration with Electron Desktop UI + +**Key Characteristics:** +- Dual-app architecture: Python backend (CLI + agents) + Electron frontend (desktop UI) +- Agent-based autonomous coding via Claude Agent SDK +- Git worktree isolation for safe parallel development +- Phase-based pipeline execution for spec creation and implementation +- Event-driven IPC communication between frontend and backend + +## Layers + +**Frontend (Electron Main Process):** +- Purpose: Desktop application shell, native OS integration, IPC coordination +- Location: `apps/frontend/src/main/` +- Contains: Window management, IPC handlers, service managers (terminal, python env, CLI tools) +- Depends on: Backend Python CLI, Claude Code CLI +- Used by: Renderer process via IPC + +**Frontend (Renderer Process):** +- Purpose: React-based user interface +- Location: `apps/frontend/src/renderer/` +- Contains: Components, Zustand stores, hooks, contexts +- Depends on: Main process via preload IPC bridge +- Used by: End users + +**Backend Core:** +- Purpose: Authentication, SDK client factory, security, workspace management +- Location: `apps/backend/core/` +- Contains: `client.py` (SDK factory), `auth.py`, `worktree.py`, `workspace.py`, security hooks +- Depends on: Claude Agent SDK, project analyzer +- Used by: Agents, CLI commands, runners + +**Backend Agents:** +- Purpose: AI agent implementations for autonomous coding +- Location: `apps/backend/agents/` +- Contains: Coder, planner, memory manager, session management +- Depends on: Core client, prompts, phase config +- Used by: CLI commands, QA loop + +**Backend QA:** +- Purpose: Quality assurance validation loop +- Location: `apps/backend/qa/` +- Contains: QA reviewer, QA fixer, criteria validation, issue tracking +- Depends on: Agents, core client +- Used by: CLI commands after build completion + +**Backend Spec:** +- Purpose: Spec creation pipeline with complexity-based phases +- Location: `apps/backend/spec/` +- Contains: Pipeline orchestrator, complexity assessment, validation +- Depends on: Core client, agents +- Used by: CLI spec commands, frontend task creation + +**Backend Security:** +- Purpose: Command validation, allowlist management, secrets scanning +- Location: `apps/backend/security/` +- Contains: Validators, hooks, command parser, secrets scanner +- Depends on: Project analyzer +- Used by: Core client via pre-tool-use hooks + +**Backend CLI:** +- Purpose: Command-line interface and argument routing +- Location: `apps/backend/cli/` +- Contains: Main entry, build/spec/workspace/QA commands +- Depends on: All backend modules +- Used by: Entry point (`run.py`), frontend terminal + +## Data Flow + +**Spec Creation Flow:** +1. User creates task via frontend or CLI (`--task "description"`) +2. `SpecOrchestrator` (`spec/pipeline/orchestrator.py`) initializes +3. Complexity assessment determines phase count (3-8 phases) +4. `AgentRunner` executes phases: Discovery -> Requirements -> [Research] -> Context -> Spec -> Plan -> Validate +5. Each phase uses Claude Agent SDK session with phase-specific prompts +6. Output: `spec.md`, `requirements.json`, `context.json`, `implementation_plan.json` + +**Implementation Flow:** +1. CLI starts with `python run.py --spec 001` +2. `run_autonomous_agent()` in `agents/coder.py` orchestrates +3. Planner agent creates subtask-based `implementation_plan.json` +4. Coder agent implements subtasks in iteration loop +5. Each subtask runs as Claude Agent SDK session +6. On completion, QA validation loop runs (`qa/loop.py`) +7. QA reviewer validates -> QA fixer fixes issues -> loop until approved + +**Frontend-Backend IPC Flow:** +1. Renderer component dispatches action (e.g., start task) +2. Zustand store calls `window.api.invoke('ipc-channel', args)` +3. Preload script bridges to main process +4. IPC handler in `ipc-handlers/` processes request +5. Handler spawns Python subprocess or manages terminal +6. Events streamed back via IPC to update stores + +**State Management:** +- Frontend: Zustand stores per domain (`task-store`, `project-store`, `settings-store`, etc.) +- Backend: File-based state (`implementation_plan.json`, `qa_report.md`) +- Session recovery: `RecoveryManager` tracks agent sessions for resumption + +## Key Abstractions + +**ClaudeSDKClient:** +- Purpose: Configured Claude Agent SDK client with security hooks +- Examples: `apps/backend/core/client.py:create_client()` +- Pattern: Factory function with multi-layered security (sandbox, permissions, hooks) + +**SpecOrchestrator:** +- Purpose: Coordinates spec creation pipeline phases +- Examples: `apps/backend/spec/pipeline/orchestrator.py` +- Pattern: Orchestrator with dynamic phase selection based on complexity + +**WorktreeManager:** +- Purpose: Git worktree isolation for safe parallel builds +- Examples: `apps/backend/core/worktree.py` +- Pattern: Each spec gets isolated worktree branch (`auto-claude/{spec-name}`) + +**SecurityProfile:** +- Purpose: Dynamic command allowlist based on project analysis +- Examples: `apps/backend/project_analyzer.py`, `apps/backend/security/` +- Pattern: Base + stack-specific + custom commands cached in `.auto-claude-security.json` + +**IPC Handlers:** +- Purpose: Bridge between Electron renderer and backend services +- Examples: `apps/frontend/src/main/ipc-handlers/` +- Pattern: Domain-specific handler modules registered via `ipc-setup.ts` + +## Entry Points + +**Backend CLI:** +- Location: `apps/backend/run.py` +- Triggers: Terminal, frontend subprocess spawn, direct invocation +- Responsibilities: Argument parsing, command routing to `cli/` modules + +**Electron Main:** +- Location: `apps/frontend/src/main/index.ts` +- Triggers: Application launch +- Responsibilities: Window creation, IPC setup, service initialization + +**Renderer Entry:** +- Location: `apps/frontend/src/renderer/main.tsx` +- Triggers: Window load +- Responsibilities: React app mount, store initialization + +**Spec Pipeline:** +- Location: `apps/backend/spec/pipeline/orchestrator.py:SpecOrchestrator` +- Triggers: CLI `--task`, frontend task creation +- Responsibilities: Dynamic phase execution for spec creation + +**Agent Loop:** +- Location: `apps/backend/agents/coder.py:run_autonomous_agent()` +- Triggers: CLI `--spec 001`, frontend build start +- Responsibilities: Subtask iteration, session management, recovery + +## Error Handling + +**Strategy:** Multi-level error handling with recovery support + +**Patterns:** +- Agent sessions: `RecoveryManager` tracks state for resumption after interruption +- Security validation: Pre-tool-use hooks reject dangerous commands before execution +- QA loop: Escalation to human review after max iterations (`MAX_QA_ITERATIONS`) +- Git operations: Retry with exponential backoff for network errors +- Frontend: Error boundaries with toast notifications + +## Cross-Cutting Concerns + +**Logging:** +- Backend: Python `logging` module with task-specific loggers (`task_logger/`) +- Frontend: Electron app logger (`app-logger.ts`), Sentry integration + +**Validation:** +- Command security: `security/` validators with dynamic allowlists +- Spec validation: `spec/validate_pkg/` for implementation plan schema +- Tool input: `security/tool_input_validator.py` for Claude tool arguments + +**Authentication:** +- OAuth flow: `core/auth.py` manages Claude OAuth tokens +- Token storage: Keychain (macOS), Credential Manager (Windows), encrypted file (Linux) +- Token validation: Pre-SDK-call validation to prevent encrypted token errors + +**Internationalization:** +- Frontend: `react-i18next` with namespace-organized JSON files +- Location: `apps/frontend/src/shared/i18n/locales/{en,fr}/` + +--- + +*Architecture analysis: 2026-01-19* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 00000000..fb904710 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,224 @@ +# Codebase Concerns + +**Analysis Date:** 2026-01-19 + +## Tech Debt + +**Large File Complexity:** +- Issue: Several core files exceed 1000+ lines, indicating potential need for further modularization +- Files: + - `apps/backend/core/workspace.py` (2096 lines) - Already refactored but remains large + - `apps/backend/runners/github/orchestrator.py` (1607 lines) + - `apps/backend/core/worktree.py` (1404 lines) + - `apps/backend/runners/github/context_gatherer.py` (1292 lines) + - `apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts` (3149 lines) + - `apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts` (2874 lines) +- Impact: Difficult to navigate, test, and maintain; increases risk of merge conflicts +- Fix approach: Continue modular extraction pattern (workspace.py partially done); extract sub-modules for GitHub orchestrator + +**Deprecated Modules Still in Codebase:** +- Issue: Deprecated code remains active and produces warnings +- Files: + - `apps/backend/runners/github/confidence.py` - Marked deprecated, uses DeprecationWarning + - `apps/frontend/src/main/terminal/terminal-manager.ts` - Contains deprecated sync methods + - `apps/frontend/src/main/terminal/session-handler.ts` - persistAllSessions deprecated +- Impact: Technical confusion, potential runtime warnings, maintenance burden +- Fix approach: Remove deprecated modules or complete migration to evidence-based validation + +**Global State / Module-Level Caches:** +- Issue: Multiple modules use global variables and module-level caches that are not thread-safe +- Files: + - `apps/backend/security/profile.py` (5 global variables for caching) + - `apps/backend/core/client.py` (_PROJECT_INDEX_CACHE, _CLAUDE_CLI_CACHE) + - `apps/backend/core/io_utils.py` (_pipe_broken global) + - `apps/backend/core/sentry.py` (_sentry_initialized, _sentry_enabled) + - `apps/backend/task_logger/utils.py` (_current_logger global) +- Impact: Potential race conditions in multi-threaded scenarios; difficult to test in isolation +- Fix approach: Convert to class-based singletons with proper locking; use thread-local storage where appropriate + +**Incomplete TODO Implementation:** +- Issue: Critical features have TODO placeholders +- Files: + - `apps/backend/core/workspace.py:1578` - `_record_merge_completion` not implemented + - `apps/backend/merge/conflict_analysis.py:272-283` - Advanced implicit conflict detection not implemented + - `apps/frontend/src/renderer/stores/settings-store.ts:214` - i18n keys not implemented + - `apps/frontend/src/renderer/components/ideation/EnvConfigModal.tsx:1` - Props interface not defined +- Impact: Missing functionality, potential runtime issues +- Fix approach: Implement or remove features; document if intentionally deferred + +**Empty Exception Handlers:** +- Issue: Many `pass` statements in exception handlers swallow errors silently +- Files: 237+ instances of `pass` after exception handling across backend +- Locations include: + - `apps/backend/core/worktree.py:448` + - `apps/backend/services/orchestrator.py:384, 396, 411, 423` + - `apps/backend/cli/workspace_commands.py:339-359` (multiple) + - `apps/backend/runners/github/memory_integration.py` (multiple) +- Impact: Silent failures make debugging difficult; errors may propagate unexpectedly +- Fix approach: Add logging to catch blocks; re-raise critical exceptions; document intentional suppressions + +## Known Bugs + +**Status Flip-Flop Bug (Task Store):** +- Symptoms: Task status may incorrectly change between terminal states +- Files: `apps/frontend/src/renderer/stores/task-store.ts:278, 282, 324, 346` +- Trigger: Phase transitions in updateTaskFromPlan +- Workaround: Multiple FIX comments added inline; logic guards terminal phases + +**BulkPRDialog Error Detection:** +- Symptoms: String-based error detection is fragile +- Files: `apps/frontend/src/renderer/components/BulkPRDialog.tsx:32` +- Trigger: API error messages changing format +- Workaround: None - TODO comment acknowledges the issue + +## Security Considerations + +**Shell=True Usage:** +- Risk: Command injection if inputs not properly sanitized +- Files: + - `apps/backend/core/git_executable.py:134` - Windows 'where' command + - `apps/backend/core/gh_executable.py:61` - Windows 'where' command +- Current mitigation: Limited to Windows platform detection, not user-controlled input +- Recommendations: Document why shell=True is required; ensure no user input reaches these calls + +**Subprocess Execution Spread Across Codebase:** +- Risk: Inconsistent security validation; command injection if not properly controlled +- Files: 50+ files with subprocess.run/Popen calls +- Current mitigation: Security hooks in `apps/backend/security/hooks.py`; allowlist in project_analyzer +- Recommendations: Consolidate subprocess calls through centralized wrappers; audit all subprocess calls + +**Environment Variable Handling:** +- Risk: Sensitive data exposure through env vars +- Files: 100+ os.environ references across backend +- Current mitigation: Token validation in `apps/backend/core/auth.py`; encrypted token detection +- Recommendations: Audit all env var usage; ensure secrets are not logged; use secure storage APIs + +**Token Decryption Not Implemented:** +- Risk: Encrypted tokens fail silently, requiring manual workarounds +- Files: `apps/backend/core/auth.py:103-228` +- Current mitigation: Clear error messages directing users to alternatives +- Recommendations: Implement cross-platform token decryption or improve error UX + +## Performance Bottlenecks + +**Blocking Sleep Calls:** +- Problem: time.sleep() calls block threads +- Files: + - `apps/backend/core/workspace/models.py:129, 218` + - `apps/backend/core/worktree.py:95, 106` + - `apps/backend/services/orchestrator.py:451` + - `apps/backend/runners/github/file_lock.py:172` + - `apps/backend/runners/gitlab/glab_client.py:168` +- Cause: Synchronous retry logic with exponential backoff +- Improvement path: Convert to async operations where possible; use asyncio.sleep for async code + +**Project Index Cache TTL:** +- Problem: 5-minute TTL may cause stale data or unnecessary reloads +- Files: `apps/backend/core/client.py:43` (_CACHE_TTL_SECONDS = 300) +- Cause: Fixed TTL doesn't adapt to project activity +- Improvement path: Implement file-watcher invalidation; make TTL configurable + +**Security Profile Cache:** +- Problem: Module-level cache with no size limits +- Files: `apps/backend/security/profile.py:23-27` +- Cause: Global state without eviction policy +- Improvement path: Add LRU eviction; consider bounded cache + +## Fragile Areas + +**Merge System:** +- Files: + - `apps/backend/core/workspace.py` (complex merge orchestration) + - `apps/backend/merge/` directory (conflict detection, resolution) +- Why fragile: Complex state machine for parallel merges; many edge cases in git operations +- Safe modification: Always test with multiple concurrent specs; use DEBUG=true for verbose logging +- Test coverage: Tests exist but may not cover all race conditions + +**GitHub Integration:** +- Files: + - `apps/backend/runners/github/orchestrator.py` + - `apps/backend/runners/github/rate_limiter.py` + - `apps/backend/runners/github/gh_client.py` +- Why fragile: External API dependencies; rate limiting complexity; async/await patterns +- Safe modification: Mock external calls in tests; test rate limit scenarios explicitly +- Test coverage: Good coverage in `tests/test_github_*.py` + +**Terminal Integration (Frontend):** +- Files: + - `apps/frontend/src/renderer/stores/terminal-store.ts` + - `apps/frontend/src/main/terminal/claude-integration-handler.ts` +- Why fragile: Complex state management; IPC communication; PTY lifecycle +- Safe modification: Test terminal creation/destruction cycles; watch for memory leaks +- Test coverage: Tests exist in `__tests__/` directories + +**Auth/Token Handling:** +- Files: `apps/backend/core/auth.py` (898 lines) +- Why fragile: Platform-specific code paths; external dependency on Claude CLI; keyring integration +- Safe modification: Test on all platforms; verify OAuth flow end-to-end +- Test coverage: `tests/test_auth.py` exists + +## Scaling Limits + +**Concurrent Agent Sessions:** +- Current capacity: Limited by Claude SDK rate limits and system resources +- Limit: No explicit session pooling or queuing +- Scaling path: Implement session pool; add retry queues for rate limits + +**Graphiti Memory Database:** +- Current capacity: LadybugDB (embedded Kuzu) - single-process access +- Limit: No concurrent write support across multiple processes +- Scaling path: Consider distributed graph database for multi-user scenarios + +## Dependencies at Risk + +**Deprecated Python Packages:** +- Risk: `secretstorage` on Linux has complex DBus dependencies +- Impact: Installation failures on minimal Linux systems +- Migration plan: Document fallback to .env storage; improve error messages + +**Platform-Specific Code:** +- Risk: Windows/macOS/Linux code paths diverge +- Impact: Platform-specific bugs (documented in CLAUDE.md) +- Migration plan: Centralized platform abstraction in `apps/backend/core/platform/` + +## Missing Critical Features + +**Implicit Conflict Detection:** +- Problem: Function rename + usage conflicts not detected +- Blocks: Accurate parallel merge conflict resolution +- Files: `apps/backend/merge/conflict_analysis.py:272-283` + +**_record_merge_completion:** +- Problem: Merge completion not recorded for timeline tracking +- Blocks: Full merge history audit trail +- Files: `apps/backend/core/workspace.py:1578` + +## Test Coverage Gaps + +**Async Code Testing:** +- What's not tested: Many async functions have limited coverage +- Files: 70+ files with async functions, 92+ with await statements +- Risk: Race conditions in async code may go unnoticed +- Priority: High - async bugs are hard to reproduce + +**Platform-Specific Paths:** +- What's not tested: Windows-specific code paths on Linux CI +- Files: Platform detection in `apps/backend/core/platform/__init__.py` +- Risk: Windows-only bugs not caught until user reports +- Priority: Medium - CI now runs on all platforms per CLAUDE.md + +**Global State Reset:** +- What's not tested: Cache invalidation edge cases +- Files: All files with module-level caches +- Risk: State leakage between tests +- Priority: Medium - add cache reset fixtures + +**Exception Handler Behavior:** +- What's not tested: Error paths through empty except blocks +- Files: 237+ `pass` statements in exception handlers +- Risk: Silent failures in production +- Priority: High - add tests that trigger exception paths + +--- + +*Concerns audit: 2026-01-19* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 00000000..65af1e71 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,283 @@ +# Coding Conventions + +**Analysis Date:** 2026-01-19 + +## Naming Patterns + +**Files:** +- Python: `snake_case.py` (e.g., `project_analyzer.py`, `qa_report.py`) +- TypeScript: `kebab-case.ts` or `PascalCase.tsx` for React components +- Test files: `test_*.py` (Python), `*.test.ts` (TypeScript) +- Config files: lowercase with extension (e.g., `ruff.toml`, `tsconfig.json`) + +**Functions:** +- Python: `snake_case` (e.g., `validate_command()`, `get_security_profile()`) +- TypeScript: `camelCase` (e.g., `detectRateLimit()`, `parsePhaseEvent()`) + +**Variables:** +- Python: `snake_case` for locals, `UPPER_SNAKE_CASE` for constants +- TypeScript: `camelCase` for locals, `UPPER_SNAKE_CASE` for constants + +**Classes/Types:** +- Python: `PascalCase` (e.g., `SecurityProfile`, `ClaudeSDKClient`) +- TypeScript: `PascalCase` for types/interfaces (e.g., `ExecutionParserContext`) + +**Constants:** +- Module-level: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_UTILITY_MODEL`, `SAFE_COMMANDS`) +- Private cache variables: `_UPPER_SNAKE_CASE` (e.g., `_PROJECT_INDEX_CACHE`) + +## Code Style + +**Formatting - Python (Backend):** +- Tool: Ruff (v0.14.10 via pre-commit) +- Quote style: Double quotes +- Indent style: Spaces (4 spaces per PEP 8) +- Line endings: Auto +- Key rules enabled: + - `E`, `W` (pycodestyle) + - `F` (Pyflakes) + - `I` (isort) + - `B` (flake8-bugbear) + - `C4` (flake8-comprehensions) + - `UP` (pyupgrade) + +**Formatting - TypeScript (Frontend):** +- Tool: Biome (v2.3.11) +- Commands: + ```bash + cd apps/frontend && npx biome check --write . # Lint + format + ``` +- TypeScript compiler: `tsc --noEmit` for type checking +- Strict mode enabled in `tsconfig.json` + +**Linting:** +- Python: Ruff handles both linting and formatting +- TypeScript: Biome handles both (replaced ESLint for 15-25x faster performance) + +## Import Organization + +**Python Order (enforced by isort via Ruff):** +1. Standard library imports (`import os`, `import json`) +2. Third-party imports (`from claude_agent_sdk import ...`) +3. Local imports (`from core.client import create_client`) + +**TypeScript Order:** +1. React/external library imports +2. Local component imports +3. Type imports + +**Path Aliases (TypeScript):** +```typescript +// tsconfig.json paths +"@/*": ["src/renderer/*"] +"@shared/*": ["src/shared/*"] +"@preload/*": ["src/preload/*"] +"@features/*": ["src/renderer/features/*"] +"@components/*": ["src/renderer/shared/components/*"] +"@hooks/*": ["src/renderer/shared/hooks/*"] +"@lib/*": ["src/renderer/shared/lib/*"] +``` + +## Error Handling + +**Python Patterns:** +```python +# Try-except with specific exceptions +try: + result = subprocess.run(cmd, capture_output=True, timeout=5) +except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: + logger.debug(f"Operation failed: {e}") + return None + +# Validation with early return +def validate_something(value: str) -> tuple[bool, str]: + if not value: + return False, "Value is required" + if invalid_condition: + return False, "Value is invalid because..." + return True, "" +``` + +**TypeScript Patterns:** +```typescript +// Result object pattern for detection functions +interface DetectionResult { + isDetected: boolean; + message?: string; + details?: Record; +} + +function detectSomething(input: string): DetectionResult { + if (!input) { + return { isDetected: false }; + } + // Detection logic... + return { isDetected: true, message: "Detected condition X" }; +} +``` + +## Logging + +**Python Framework:** Standard library `logging` + +**Patterns:** +```python +import logging + +logger = logging.getLogger(__name__) + +# Debug for verbose/diagnostic info +logger.debug(f"Cache HIT for {key}") + +# Info for significant operations +logger.info(f"Found Claude CLI: {path} (v{version})") + +# Warning for recoverable issues +logger.warning(f"Invalid configuration: {value}, using default") + +# Error with context +logger.error(f"Failed to process {file}: {error}") +``` + +**TypeScript Logging:** Console-based in development, suppressed in tests. + +## Comments + +**When to Comment:** +- Public functions: Always document with docstrings/JSDoc +- Complex algorithms: Explain the "why" not the "what" +- Security-related code: Explain security implications +- Workarounds: Reference issue numbers + +**Python Docstrings:** +```python +def create_client( + project_dir: Path, + spec_dir: Path, + model: str, + agent_type: str = "coder", +) -> ClaudeSDKClient: + """ + Create a Claude Agent SDK client with multi-layered security. + + Uses AGENT_CONFIGS for phase-aware tool and MCP server configuration. + + Args: + project_dir: Root directory for the project (working directory) + spec_dir: Directory containing the spec (for settings file) + model: Claude model to use + agent_type: Agent type identifier from AGENT_CONFIGS + + Returns: + Configured ClaudeSDKClient + + Raises: + ValueError: If agent_type is not found in AGENT_CONFIGS + """ +``` + +**TypeScript JSDoc:** +```typescript +/** + * Detect rate limit from CLI output. + * + * @param output - Raw CLI output string + * @returns Detection result with isRateLimited flag and optional resetTime + */ +function detectRateLimit(output: string): RateLimitResult { + // ... +} +``` + +## Function Design + +**Size:** Keep functions focused on a single responsibility. Functions over 50 lines should be considered for splitting. + +**Parameters:** +- Python: Use type hints for all parameters +- TypeScript: Use explicit types, avoid `any` +- Default values for optional parameters +- Keyword arguments for functions with 3+ parameters + +**Return Values:** +- Python: Use tuple for multiple returns `-> tuple[bool, str]` +- TypeScript: Use result objects for complex returns +- Always annotate return types + +## Module Design + +**Python Exports:** +- Use `__all__` in `__init__.py` to control public API +- Prefix internal functions/classes with underscore + +**TypeScript Barrel Files:** +```typescript +// index.ts barrel export pattern +export { ExecutionPhaseParser } from './execution-phase-parser'; +export { IdeationPhaseParser } from './ideation-phase-parser'; +export type { ExecutionParserContext } from './types'; +``` + +## Security Conventions + +**Validation First:** +```python +# Always validate input before processing +def _validate_custom_mcp_server(server: dict) -> bool: + """Validate a custom MCP server configuration for security.""" + if not isinstance(server, dict): + return False + + # Required fields + required_fields = {"id", "name", "type"} + if not all(field in server for field in required_fields): + return False + + # Blocklist dangerous commands + DANGEROUS_COMMANDS = {"bash", "sh", "cmd", "powershell"} + if command in DANGEROUS_COMMANDS: + logger.warning(f"Rejected dangerous command: {command}") + return False + + return True +``` + +**Sensitive Commands:** Always use allowlist approach, never blocklist alone. + +## Internationalization (Frontend) + +**Always use i18n for user-facing text:** +```tsx +import { useTranslation } from 'react-i18next'; + +const { t } = useTranslation(['navigation', 'common']); + +// Correct +{t('navigation:items.githubPRs')} + +// Wrong - hardcoded string +GitHub PRs +``` + +**Translation file structure:** +- `apps/frontend/src/shared/i18n/locales/en/*.json` +- `apps/frontend/src/shared/i18n/locales/fr/*.json` + +## Platform-Specific Code + +**Use platform abstraction module:** +```typescript +// Correct - use abstraction +import { isWindows, getPathDelimiter } from './platform'; + +// Wrong - direct check +if (process.platform === 'win32') { ... } +``` + +**Platform modules:** +- Frontend: `apps/frontend/src/main/platform/` +- Backend: `apps/backend/core/platform/` + +--- + +*Convention analysis: 2026-01-19* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 00000000..730a21ce --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,204 @@ +# External Integrations + +**Analysis Date:** 2026-01-19 + +## APIs & External Services + +**Claude AI (Primary):** +- Service: Anthropic Claude API via Claude Agent SDK +- SDK: `claude-agent-sdk` >= 0.1.19 (Python backend) +- Auth: OAuth tokens via system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) +- Env: `CLAUDE_CODE_OAUTH_TOKEN` or auto-detected from system credential store +- Implementation: `apps/backend/core/client.py`, `apps/backend/core/auth.py` + +**CRITICAL: Never use `anthropic.Anthropic()` directly. Always use `create_client()` from `core.client`.** + +**Context7 MCP (Documentation Lookup):** +- Service: Upstash Context7 documentation retrieval +- SDK: `@upstash/context7-mcp` (spawned via npx) +- Auth: None (public MCP server) +- Implementation: Configured in `apps/backend/core/client.py` MCP servers +- Usage: Automatically available to agents for documentation queries + +**Linear (Optional Project Management):** +- Service: Linear issue tracking and project management +- SDK: Linear MCP server (HTTP-based) +- Auth: `LINEAR_API_KEY` (Bearer token) +- Env: `LINEAR_API_KEY`, `LINEAR_TEAM_ID`, `LINEAR_PROJECT_ID` +- Implementation: `apps/backend/integrations/linear/integration.py` +- Features: Subtask-to-issue sync, progress tracking, stuck task escalation + +**GitHub:** +- Service: GitHub API for issues, PRs, releases +- SDK: `gh` CLI (subprocess calls) +- Auth: GitHub CLI auth (`gh auth login`) +- Implementation: `apps/frontend/src/main/ipc-handlers/github/` +- Features: Import issues, create PRs, manage releases, triage automation + +**GitLab (Optional):** +- Service: GitLab API for issues and merge requests +- SDK: `glab` CLI or Personal Access Token +- Auth: `glab auth login` or `GITLAB_TOKEN` +- Env: `GITLAB_INSTANCE_URL`, `GITLAB_TOKEN`, `GITLAB_PROJECT` +- Implementation: `apps/frontend/src/main/ipc-handlers/gitlab/` + +## Data Storage + +**Databases:** +- LadybugDB (embedded graph database) + - Connection: Local file at `~/.auto-claude/memories/{database_name}` + - Client: `real_ladybug` Python package (requires Python 3.12+) + - No Docker required - fully embedded + - Provider-specific database naming to prevent embedding dimension mismatches + +**File Storage:** +- Local filesystem only +- Project data: `.auto-claude/` directory per project +- Specs: `.auto-claude/specs/{id}-{name}/` +- Worktrees: `.auto-claude/worktrees/` (git worktree isolation) + +**Caching:** +- Project index cache (5 minute TTL, thread-safe) +- CLI path cache (per-session) +- Implementation: `apps/backend/core/client.py` (`_PROJECT_INDEX_CACHE`) + +## Memory System (Graphiti) + +**Graph Memory:** +- Engine: Graphiti-core + LadybugDB +- Purpose: Cross-session context retention, pattern learning +- Data: Episodes (insights, discoveries, patterns, gotchas, outcomes) +- Config: `apps/backend/integrations/graphiti/config.py` +- Memory: `apps/backend/integrations/graphiti/memory.py` + +**Multi-Provider Support:** + +| Provider | LLM | Embedder | Env Vars | +|----------|-----|----------|----------| +| OpenAI | Yes | Yes | `OPENAI_API_KEY`, `OPENAI_MODEL` | +| Anthropic | Yes | No | `ANTHROPIC_API_KEY`, `GRAPHITI_ANTHROPIC_MODEL` | +| Azure OpenAI | Yes | Yes | `AZURE_OPENAI_*` (API_KEY, BASE_URL, deployments) | +| Voyage AI | No | Yes | `VOYAGE_API_KEY`, `VOYAGE_EMBEDDING_MODEL` | +| Google AI | Yes | Yes | `GOOGLE_API_KEY`, `GOOGLE_LLM_MODEL` | +| Ollama | Yes | Yes | `OLLAMA_*` (BASE_URL, models, embedding dim) | +| OpenRouter | Yes | Yes | `OPENROUTER_API_KEY`, `OPENROUTER_*_MODEL` | + +**Provider Implementation:** `apps/backend/integrations/graphiti/providers_pkg/` + +## Authentication & Identity + +**Claude OAuth:** +- Provider: Anthropic Claude Code OAuth +- Implementation: `apps/backend/core/auth.py` +- Storage: + - macOS: Keychain (`/usr/bin/security find-generic-password`) + - Windows: `~/.claude/.credentials.json` or Credential Manager + - Linux: Secret Service API via DBus (`secretstorage` package) +- Token format: `sk-ant-oat01-*` (OAuth access token) +- Login flow: `claude` CLI with `/login` command (opens browser) + +**GitHub Auth:** +- Provider: GitHub CLI OAuth +- Implementation: IPC handlers in frontend +- Storage: Managed by `gh` CLI + +**GitLab Auth:** +- Provider: GitLab Personal Access Token or glab CLI OAuth +- Implementation: `apps/frontend/src/main/ipc-handlers/gitlab/` +- Storage: Managed by `glab` CLI or `.env` file + +## Monitoring & Observability + +**Error Tracking:** +- Service: Sentry (optional) +- SDK: `@sentry/electron` 7.5.0 +- Auth: `SENTRY_DSN` (set in CI for official builds) +- Env: `SENTRY_DSN`, `SENTRY_TRACES_SAMPLE_RATE`, `SENTRY_PROFILES_SAMPLE_RATE` +- Implementation: `apps/frontend/src/main/sentry.ts` +- Note: Disabled in forks unless SENTRY_DSN is explicitly set + +**Logs:** +- Backend: Python `logging` module (structured JSON in debug mode) +- Frontend: `electron-log` (file + console) +- Location: Platform-specific logs directory +- Debug: Set `DEBUG=true` for verbose output + +## CI/CD & Deployment + +**Hosting:** +- Distribution: GitHub Releases (electron-updater compatible) +- Auto-update: electron-updater checks GitHub releases + +**CI Pipeline:** +- Service: GitHub Actions +- Workflow: `.github/workflows/ci.yml` +- Matrix: Linux, Windows, macOS +- Jobs: test-python, test-frontend, ci-complete (gate job) + +**Release Pipeline:** +- Workflow: `.github/workflows/release.yml` (triggered on tag) +- Artifacts: DMG, ZIP (macOS), NSIS/ZIP (Windows), AppImage/DEB/Flatpak (Linux) + +## Environment Configuration + +**Required env vars (backend):** +``` +CLAUDE_CODE_OAUTH_TOKEN # Or use system keychain +GRAPHITI_ENABLED=true # Enable memory system +``` + +**Optional env vars (backend):** +``` +ANTHROPIC_BASE_URL # Custom API endpoint +LINEAR_API_KEY # Linear integration +ELECTRON_MCP_ENABLED # E2E testing +DEBUG=true # Verbose logging +``` + +**Required env vars (frontend):** +``` +# None required - optional debug/Sentry settings +``` + +**Secrets location:** +- Development: `.env` files (gitignored) +- CI/CD: GitHub Secrets +- Production: System credential stores (no secrets in app bundle) + +## MCP (Model Context Protocol) Servers + +**Built-in MCP Servers:** + +| Server | Purpose | Agent Access | Configuration | +|--------|---------|--------------|---------------| +| context7 | Documentation lookup | All agents | Auto-enabled | +| linear | Project management | All agents | `LINEAR_API_KEY` | +| electron | Desktop app automation | QA agents only | `ELECTRON_MCP_ENABLED` | +| puppeteer | Web browser automation | QA agents only | Project capability detection | +| graphiti-memory | Knowledge graph | All agents | `GRAPHITI_MCP_URL` | +| auto-claude | Custom tools | Phase-specific | Auto-enabled | + +**Custom MCP Servers:** +- Config: `.auto-claude/.env` (`CUSTOM_MCP_SERVERS` JSON array) +- Validation: `apps/backend/core/client.py` (`_validate_custom_mcp_server`) +- Allowed commands: `npx`, `npm`, `node`, `python`, `python3`, `uv`, `uvx` + +**Per-Agent MCP Overrides:** +- Add servers: `AGENT_MCP_{agent}_ADD=server1,server2` +- Remove servers: `AGENT_MCP_{agent}_REMOVE=server1,server2` + +## Webhooks & Callbacks + +**Incoming:** +- None (desktop application, no server) + +**Outgoing:** +- GitHub API calls (via `gh` CLI) +- GitLab API calls (via `glab` CLI or REST) +- Linear MCP server (HTTP) +- Sentry error reports (if configured) +- Auto-update checks (GitHub Releases API) + +--- + +*Integration audit: 2026-01-19* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 00000000..c818c358 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,140 @@ +# Technology Stack + +**Analysis Date:** 2026-01-19 + +## Languages + +**Primary:** +- TypeScript 5.9.3 - Electron frontend (desktop UI, IPC handlers, state management) +- Python 3.12+ - Backend agents, CLI, integrations, security + +**Secondary:** +- JavaScript (ES modules) - Build scripts, configuration +- JSON - Configuration, data storage, IPC communication + +## Runtime + +**Environment:** +- Node.js >= 24.0.0 (Electron main/renderer processes) +- Python 3.12+ (required for LadybugDB/Graphiti memory system) + +**Package Manager:** +- npm 10.0.0+ (root monorepo, frontend) +- uv (Python backend - fast pip alternative) +- Lockfiles: `package-lock.json` (present), Python deps in `requirements.txt` + +## Frameworks + +**Core:** +- Electron 39.2.7 - Cross-platform desktop application shell +- React 19.2.3 - UI components and state management +- Claude Agent SDK >= 0.1.19 - AI agent orchestration (CRITICAL: NOT raw Anthropic API) + +**Testing:** +- Vitest 4.0.16 - Frontend unit tests +- Playwright 1.52.0 - E2E testing for Electron +- pytest 7.0.0+ - Backend Python tests +- pytest-asyncio 0.21.0+ - Async test support + +**Build/Dev:** +- electron-vite 5.0.0 - Electron build toolchain +- Vite 7.2.7 - Frontend bundler +- electron-builder 26.4.0 - Cross-platform packaging (dmg, exe, AppImage, deb, flatpak) + +## Key Dependencies + +**Critical (AI/Agent):** +- `claude-agent-sdk` >= 0.1.19 - Core AI agent SDK (replaces direct Anthropic API) +- `@anthropic-ai/sdk` 0.71.2 - Anthropic client (used by Graphiti providers) + +**Infrastructure:** +- `@lydell/node-pty` 1.1.0 - Terminal emulation (native module) +- `@xterm/xterm` 6.0.0 - Terminal rendering +- `electron-updater` 6.6.2 - Auto-update mechanism +- `chokidar` 5.0.0 - File system watching +- `zustand` 5.0.9 - React state management + +**UI Components:** +- `@radix-ui/*` - Accessible UI primitives (dialogs, dropdowns, tabs, etc.) +- `tailwindcss` 4.1.17 - Utility-first CSS +- `lucide-react` 0.562.0 - Icons +- `motion` 12.23.26 - Animations + +**Memory/Database:** +- `real_ladybug` >= 0.13.0 - Embedded graph database (Python 3.12+, no Docker) +- `graphiti-core` >= 0.5.0 - Knowledge graph memory layer + +**Observability:** +- `@sentry/electron` 7.5.0 - Error tracking (optional, requires SENTRY_DSN) +- `electron-log` 5.4.3 - Structured logging + +**Internationalization:** +- `i18next` 25.7.3 + `react-i18next` 16.5.0 - Multi-language support (en, fr) + +## Configuration + +**Environment:** +- Backend: `apps/backend/.env` (OAuth tokens, integrations, memory config) +- Frontend: `apps/frontend/.env` (debug settings, Sentry DSN) +- Example files: `.env.example` in both directories + +**Key Backend Env Vars:** +``` +CLAUDE_CODE_OAUTH_TOKEN # Required: OAuth token (or use system keychain) +ANTHROPIC_BASE_URL # Optional: Custom API endpoint +GRAPHITI_ENABLED # Required: true to enable memory +GRAPHITI_LLM_PROVIDER # openai|anthropic|azure_openai|ollama|google|openrouter +GRAPHITI_EMBEDDER_PROVIDER # openai|voyage|azure_openai|ollama|google|openrouter +LINEAR_API_KEY # Optional: Linear integration +ELECTRON_MCP_ENABLED # Optional: E2E testing via Electron MCP +``` + +**Build:** +- `apps/frontend/electron.vite.config.ts` - Electron/Vite build config +- `apps/frontend/vitest.config.ts` - Test configuration +- `apps/frontend/package.json` (build section) - electron-builder config +- `ruff.toml` - Python linting/formatting + +## Platform Requirements + +**Development:** +- macOS, Windows, or Linux +- Node.js 24+, Python 3.12+ +- Git (required for worktree isolation) +- Git Bash (Windows only, for Claude Code CLI) + +**Production:** +- macOS: DMG/ZIP (arm64 + x64) +- Windows: NSIS installer/ZIP +- Linux: AppImage, DEB, Flatpak +- Bundled Python runtime (downloaded via `scripts/download-python.cjs`) + +**CI/CD:** +- GitHub Actions (`.github/workflows/ci.yml`) +- Matrix testing: Linux, Windows, macOS +- Python 3.12 + 3.13 (Linux only) + +## Monorepo Structure + +``` +autonomous-coding/ +├── apps/ +│ ├── backend/ # Python (uv, requirements.txt) +│ └── frontend/ # Electron/React (npm, package.json) +├── tests/ # Shared test suite +├── scripts/ # Build/release scripts +└── package.json # Root workspace config +``` + +**Workspace Commands:** +```bash +npm run install:all # Install both frontend and backend +npm run dev # Start Electron in dev mode +npm run build # Build frontend +npm run package # Package for current platform +npm run test:backend # Run Python tests +``` + +--- + +*Stack analysis: 2026-01-19* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 00000000..91d85eae --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,219 @@ +# Codebase Structure + +**Analysis Date:** 2026-01-19 + +## Directory Layout + +``` +autonomous-coding/ +├── apps/ +│ ├── backend/ # Python backend - CLI, agents, core logic +│ │ ├── agents/ # Agent implementations (coder, planner, memory) +│ │ ├── cli/ # Command-line interface modules +│ │ ├── core/ # Client factory, auth, worktree, security +│ │ ├── integrations/ # External integrations (Graphiti, Linear) +│ │ ├── memory/ # Memory system (sessions, patterns) +│ │ ├── merge/ # Git merge conflict resolution +│ │ ├── prompts/ # Agent system prompts (.md files) +│ │ ├── qa/ # QA validation loop +│ │ ├── runners/ # Feature runners (GitHub, GitLab, roadmap, spec) +│ │ ├── security/ # Command validators, secrets scanning +│ │ ├── spec/ # Spec creation pipeline +│ │ └── ui/ # CLI output formatting +│ └── frontend/ # Electron desktop app +│ ├── src/ +│ │ ├── main/ # Electron main process +│ │ ├── renderer/ # React renderer (components, stores) +│ │ ├── preload/ # IPC bridge scripts +│ │ └── shared/ # Shared types, constants, i18n +│ └── resources/ # App icons, assets +├── tests/ # Python test suite +├── scripts/ # Build and utility scripts +├── docs/ # Documentation +└── guides/ # User guides +``` + +## Directory Purposes + +**`apps/backend/`:** +- Purpose: All Python backend code (CLI, agents, core infrastructure) +- Contains: Agent implementations, CLI modules, security, integrations +- Key files: `run.py` (entry point), `core/client.py` (SDK factory) + +**`apps/backend/agents/`:** +- Purpose: AI agent implementations for autonomous coding +- Contains: Coder agent loop, planner, memory manager, session utilities +- Key files: `coder.py`, `planner.py`, `memory_manager.py`, `session.py` + +**`apps/backend/cli/`:** +- Purpose: CLI command implementations +- Contains: Build, spec, workspace, QA, batch commands +- Key files: `main.py`, `build_commands.py`, `workspace_commands.py` + +**`apps/backend/core/`:** +- Purpose: Core infrastructure (client, auth, workspace, platform) +- Contains: SDK client factory, OAuth, worktree manager, platform abstraction +- Key files: `client.py`, `auth.py`, `worktree.py`, `workspace.py` + +**`apps/backend/qa/`:** +- Purpose: QA validation after build completion +- Contains: QA loop, reviewer, fixer, criteria validation, issue tracking +- Key files: `loop.py`, `reviewer.py`, `fixer.py`, `criteria.py` + +**`apps/backend/spec/`:** +- Purpose: Spec creation pipeline +- Contains: Pipeline orchestrator, complexity assessment, validation +- Key files: `pipeline/orchestrator.py`, `complexity.py`, `validate_pkg/` + +**`apps/backend/security/`:** +- Purpose: Bash command validation and security +- Contains: Validators, hooks, command parser, secrets scanner +- Key files: `hooks.py`, `validator.py`, `parser.py`, `scan_secrets.py` + +**`apps/backend/prompts/`:** +- Purpose: Agent system prompts (Markdown files) +- Contains: Prompts for coder, planner, QA, spec agents +- Key files: `coder.md`, `planner.md`, `qa_reviewer.md`, `spec_gatherer.md` + +**`apps/backend/runners/`:** +- Purpose: Feature-specific execution runners +- Contains: GitHub PR review, roadmap generation, spec creation +- Key files: `github/orchestrator.py`, `spec_runner.py`, `roadmap_runner.py` + +**`apps/frontend/src/main/`:** +- Purpose: Electron main process +- Contains: Window management, IPC handlers, service managers +- Key files: `index.ts`, `ipc-setup.ts`, `cli-tool-manager.ts` + +**`apps/frontend/src/renderer/`:** +- Purpose: React UI +- Contains: Components, stores, hooks, contexts +- Key files: `App.tsx`, `components/`, `stores/` + +**`apps/frontend/src/shared/`:** +- Purpose: Shared code between main and renderer +- Contains: Types, constants, i18n, utilities +- Key files: `types/`, `constants/`, `i18n/` + +## Key File Locations + +**Entry Points:** +- `apps/backend/run.py`: Backend CLI entry point +- `apps/frontend/src/main/index.ts`: Electron main entry +- `apps/frontend/src/renderer/main.tsx`: React app entry + +**Configuration:** +- `apps/backend/.env`: Backend environment variables +- `apps/backend/.env.example`: Backend env template +- `apps/frontend/.env`: Frontend environment variables +- `apps/backend/requirements.txt`: Python dependencies +- `apps/frontend/package.json`: Frontend dependencies + +**Core Logic:** +- `apps/backend/core/client.py`: Claude SDK client factory +- `apps/backend/core/auth.py`: OAuth token management +- `apps/backend/core/worktree.py`: Git worktree isolation +- `apps/backend/agents/coder.py`: Main agent loop +- `apps/backend/spec/pipeline/orchestrator.py`: Spec creation pipeline + +**Testing:** +- `tests/`: All Python tests (pytest) +- `tests/conftest.py`: Pytest fixtures and configuration +- `apps/frontend/src/main/__tests__/`: Main process tests +- `apps/frontend/src/renderer/__tests__/`: Renderer tests + +## Naming Conventions + +**Files:** +- Python modules: `snake_case.py` (e.g., `workspace_commands.py`) +- TypeScript modules: `kebab-case.ts` (e.g., `cli-tool-manager.ts`) +- React components: `PascalCase.tsx` (e.g., `KanbanBoard.tsx`) +- Prompts: `snake_case.md` (e.g., `qa_reviewer.md`) +- Tests: `test_*.py` (Python), `*.test.ts/tsx` (TypeScript) + +**Directories:** +- Python packages: `snake_case/` with `__init__.py` +- TypeScript modules: `kebab-case/` +- Package submodules: `*_pkg/` suffix (e.g., `tools_pkg/`, `queries_pkg/`) + +**Classes and Functions:** +- Python classes: `PascalCase` (e.g., `SpecOrchestrator`) +- Python functions: `snake_case` (e.g., `run_autonomous_agent`) +- TypeScript/React: `camelCase` functions, `PascalCase` components + +## Where to Add New Code + +**New Agent Feature:** +- Primary code: `apps/backend/agents/` +- Prompt: `apps/backend/prompts/{agent_name}.md` +- Tests: `tests/test_agent_*.py` + +**New CLI Command:** +- Implementation: `apps/backend/cli/{domain}_commands.py` +- Registration: `apps/backend/cli/main.py` (argument parsing) +- Tests: `tests/test_{command}.py` + +**New Frontend Component:** +- Implementation: `apps/frontend/src/renderer/components/{ComponentName}.tsx` +- Translations: `apps/frontend/src/shared/i18n/locales/en/{namespace}.json` +- Tests: `apps/frontend/src/renderer/components/__tests__/` + +**New Frontend Store:** +- Implementation: `apps/frontend/src/renderer/stores/{domain}-store.ts` +- Pattern: Use Zustand with typed state and actions + +**New IPC Handler:** +- Handler module: `apps/frontend/src/main/ipc-handlers/{domain}-handlers.ts` +- Registration: `apps/frontend/src/main/ipc-handlers/index.ts` +- Types: `apps/frontend/src/shared/types/` + +**New Security Validator:** +- Implementation: `apps/backend/security/validator.py` +- Registration: Add to `VALIDATORS` dict in same file +- Tests: `tests/test_security.py` + +**New Integration:** +- Implementation: `apps/backend/integrations/{service}/` +- Configuration: Add env vars to `.env.example` +- Documentation: Update `CLAUDE.md` + +**Utilities:** +- Backend shared helpers: `apps/backend/core/` or domain-specific module +- Frontend shared helpers: `apps/frontend/src/shared/utils/` + +## Special Directories + +**`.auto-claude/`:** +- Purpose: Per-project spec storage and build state +- Generated: Yes (by backend during spec creation) +- Committed: No (gitignored) +- Contents: `specs/`, `worktrees/tasks/`, `insights/` + +**`.worktrees/`:** +- Purpose: Legacy worktree location (deprecated) +- Generated: Yes (by worktree manager) +- Committed: No (gitignored) + +**`node_modules/`:** +- Purpose: Frontend npm dependencies +- Generated: Yes (by npm install) +- Committed: No (gitignored) + +**`.venv/`:** +- Purpose: Python virtual environment +- Generated: Yes (by uv venv) +- Committed: No (gitignored) + +**`dist/` and `out/`:** +- Purpose: Build outputs +- Generated: Yes (by build scripts) +- Committed: No (gitignored) + +**`.planning/`:** +- Purpose: GSD planning documents +- Generated: Yes (by GSD commands) +- Committed: Optional (project choice) + +--- + +*Structure analysis: 2026-01-19* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 00000000..9fdd16de --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,485 @@ +# Testing Patterns + +**Analysis Date:** 2026-01-19 + +## Test Framework + +**Backend (Python):** +- Runner: pytest (>=7.0.0) +- Config: `tests/pytest.ini` +- Async support: pytest-asyncio (>=0.21.0) +- Coverage: pytest-cov (>=4.0.0) +- Mocking: pytest-mock (>=3.0.0) + +**Frontend (TypeScript):** +- Runner: Vitest (v4.0.16) +- Config: `apps/frontend/vitest.config.ts` +- DOM testing: @testing-library/react, @testing-library/dom +- Mocking: Vitest built-in `vi` + +**Run Commands:** +```bash +# Backend - all tests +apps/backend/.venv/bin/pytest tests/ -v + +# Backend - skip slow tests (recommended for development) +apps/backend/.venv/bin/pytest tests/ -m "not slow" -v + +# Backend - single test file +apps/backend/.venv/bin/pytest tests/test_security.py -v + +# Backend - specific test +apps/backend/.venv/bin/pytest tests/test_security.py::test_bash_command_validation -v + +# Frontend - all tests +cd apps/frontend && npm test + +# Frontend - watch mode +cd apps/frontend && npm run test:watch + +# Frontend - coverage +cd apps/frontend && npm run test:coverage + +# From root (convenience) +npm run test:backend +npm run test (frontend) +``` + +## Test File Organization + +**Backend Location:** Co-located at root `tests/` directory +``` +tests/ +├── pytest.ini # Pytest configuration +├── conftest.py # Shared fixtures +├── test_fixtures.py # Sample data constants +├── review_fixtures.py # Review system fixtures +├── qa_report_helpers.py # QA test helpers +├── requirements-test.txt # Test dependencies +├── test_security.py # Security module tests +├── test_client.py # SDK client tests +├── test_qa_loop.py # QA system tests +└── ... +``` + +**Frontend Location:** Co-located with source, in `__tests__/` directories +``` +apps/frontend/src/ +├── __tests__/ +│ ├── setup.ts # Test setup (mocks, globals) +│ └── integration/ # Integration tests +├── main/__tests__/ # Main process tests +│ ├── parsers.test.ts +│ ├── rate-limit-detector.test.ts +│ └── ... +├── renderer/__tests__/ # Renderer tests +│ ├── task-store.test.ts +│ └── ... +└── renderer/components/__tests__/ # Component tests +``` + +**Naming:** +- Python: `test_*.py` (e.g., `test_security.py`) +- TypeScript: `*.test.ts` or `*.test.tsx` (e.g., `parsers.test.ts`) + +## Test Structure + +**Python - pytest Pattern:** +```python +#!/usr/bin/env python3 +""" +Tests for Security System +========================= + +Tests the security.py module functionality including: +- Command extraction and parsing +- Command allowlist validation +""" + +import pytest +from security import validate_command, extract_commands + + +class TestCommandExtraction: + """Tests for command extraction from shell strings.""" + + def test_simple_command(self): + """Extracts single command correctly.""" + commands = extract_commands("ls -la") + assert commands == ["ls"] + + def test_piped_commands(self): + """Extracts all commands from pipeline.""" + commands = extract_commands("cat file.txt | grep pattern | wc -l") + assert commands == ["cat", "grep", "wc"] + + +class TestValidateCommand: + """Tests for full command validation.""" + + def test_base_commands_allowed(self, temp_dir): + """Base commands are always allowed.""" + for cmd in ["ls", "cat", "grep"]: + allowed, reason = validate_command(cmd, temp_dir) + assert allowed is True, f"{cmd} should be allowed" +``` + +**TypeScript - Vitest Pattern:** +```typescript +/** + * Phase Parsers Tests + * ==================== + * Unit tests for the specialized phase parsers. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ExecutionPhaseParser } from '../agent/parsers'; + +describe('ExecutionPhaseParser', () => { + const parser = new ExecutionPhaseParser(); + + const makeContext = (currentPhase: string): ExecutionParserContext => ({ + currentPhase, + isTerminal: currentPhase === 'complete' + }); + + describe('structured event parsing', () => { + it('should parse structured phase events', () => { + const log = '__EXEC_PHASE__:{"phase":"coding","message":"Starting"}'; + const result = parser.parse(log, makeContext('planning')); + + expect(result).toEqual({ + phase: 'coding', + message: 'Starting', + currentSubtask: undefined + }); + }); + }); + + describe('terminal state handling', () => { + it('should not change phase when current phase is complete', () => { + const log = 'Starting coder agent...'; + const result = parser.parse(log, makeContext('complete')); + + expect(result).toBeNull(); + }); + }); +}); +``` + +## Mocking + +**Python - pytest fixtures and unittest.mock:** +```python +from unittest.mock import MagicMock, patch + +@pytest.fixture +def mock_task_logger(): + """Mock TaskLogger for testing PhaseExecutor.""" + logger = MagicMock() + logger.log = MagicMock() + logger.start_phase = MagicMock() + logger.end_phase = MagicMock() + return logger + +# Using patch decorator +@patch('core.client.find_claude_cli') +def test_client_creation(mock_find_cli): + mock_find_cli.return_value = '/usr/local/bin/claude' + # Test code... + +# Using monkeypatch fixture +def test_with_env_var(monkeypatch): + monkeypatch.setenv("CLAUDE_CLI_PATH", "/custom/path") + # Test code... +``` + +**TypeScript - Vitest vi.mock:** +```typescript +// Mock at module level (hoisted) +vi.mock('../claude-profile-manager', () => ({ + getClaudeProfileManager: vi.fn(() => ({ + getActiveProfile: vi.fn(() => ({ + id: 'test-profile-id', + name: 'Test Profile' + })), + recordRateLimitEvent: vi.fn() + })) +})); + +describe('Rate Limit Detector', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should detect rate limit', async () => { + const { detectRateLimit } = await import('../rate-limit-detector'); + const result = detectRateLimit('Limit reached · resets Dec 17'); + expect(result.isRateLimited).toBe(true); + }); +}); +``` + +**What to Mock:** +- External APIs (Claude SDK, GitHub API) +- File system operations in unit tests +- Network requests +- System time (for time-sensitive tests) +- Heavy dependencies (databases, MCP servers) + +**What NOT to Mock:** +- Pure functions under test +- Simple data transformations +- Validation logic + +## Fixtures and Factories + +**Python Fixtures (conftest.py):** +```python +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory that's cleaned up after the test.""" + temp_path = Path(tempfile.mkdtemp()) + yield temp_path + shutil.rmtree(temp_path, ignore_errors=True) + +@pytest.fixture +def temp_git_repo(temp_dir: Path) -> Generator[Path, None, None]: + """Create a temporary git repository with initial commit.""" + # Clear git environment variables to isolate from parent repo + orig_env = {} + git_vars_to_clear = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"] + for key in git_vars_to_clear: + orig_env[key] = os.environ.get(key) + if key in os.environ: + del os.environ[key] + + try: + subprocess.run(["git", "init"], cwd=temp_dir, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=temp_dir) + # ... + yield temp_dir + finally: + # Restore environment + for key, value in orig_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + +@pytest.fixture +def python_project(temp_git_repo: Path) -> Path: + """Create a sample Python project structure.""" + (temp_git_repo / "pyproject.toml").write_text(toml_content) + (temp_git_repo / "app" / "__init__.py").write_text("# App module\n") + return temp_git_repo +``` + +**TypeScript Setup (setup.ts):** +```typescript +import { vi, beforeEach, afterEach } from 'vitest'; + +// Mock localStorage for tests +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + clear: vi.fn(() => { store = {}; }) + }; +})(); + +Object.defineProperty(global, 'localStorage', { value: localStorageMock }); + +// Mock window.electronAPI for renderer tests +if (typeof window !== 'undefined') { + (window as any).electronAPI = { + getTasks: vi.fn(), + createTask: vi.fn(), + getSettings: vi.fn(), + // ... + }; +} + +beforeEach(() => { + localStorageMock.clear(); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); +}); +``` + +**Sample Data (test_fixtures.py):** +```python +SAMPLE_REACT_COMPONENT = '''import React from 'react'; +import { useState } from 'react'; + +function App() { + const [count, setCount] = useState(0); + return

Hello World

; +} +export default App; +''' + +SAMPLE_PYTHON_MODULE = '''"""Sample Python module.""" +import os +from pathlib import Path + +def hello(): + """Say hello.""" + print("Hello") +''' +``` + +## Coverage + +**Requirements:** No enforced minimum threshold, but aim for meaningful coverage + +**View Coverage:** +```bash +# Backend +apps/backend/.venv/bin/pytest tests/ --cov=apps/backend --cov-report=html + +# Frontend +cd apps/frontend && npm run test:coverage +``` + +**Coverage Output:** +- Backend: `.coverage` file, HTML report in `htmlcov/` +- Frontend: `coverage/` directory with JSON, text, and HTML reports + +## Test Types + +**Unit Tests:** +- Test individual functions/classes in isolation +- Mock external dependencies +- Fast execution (sub-second) +- Location: `tests/test_*.py`, `src/**/*.test.ts` + +**Integration Tests:** +- Test interactions between components +- May use real file system, git repos +- Slower execution +- Markers: `@pytest.mark.integration` (Python) +- Location: `tests/` (Python), `src/__tests__/integration/` (TypeScript) + +**E2E Tests (Frontend):** +- Framework: Playwright (configured but limited use) +- Config: `apps/frontend/e2e/playwright.config.ts` +- Run: `npm run test:e2e` + +## Common Patterns + +**Async Testing (Python):** +```python +import pytest + +@pytest.mark.asyncio +async def test_async_function(): + result = await some_async_operation() + assert result is not None + +# pytest.ini enables asyncio_mode = auto +# No need to manually mark simple async tests +``` + +**Async Testing (TypeScript):** +```typescript +it('should handle async operation', async () => { + const { detectRateLimit } = await import('../rate-limit-detector'); + const result = detectRateLimit('some output'); + expect(result.isRateLimited).toBe(false); +}); +``` + +**Error Testing (Python):** +```python +def test_blocked_dangerous_command(self, temp_dir): + """Dangerous commands not in allowlist are blocked.""" + allowed, reason = validate_command("rm -rf /", temp_dir) + assert allowed is False + assert "not allowed for safety" in reason + +def test_raises_on_invalid_input(): + """Should raise ValueError on invalid input.""" + with pytest.raises(ValueError, match="Invalid configuration"): + process_config(None) +``` + +**Error Testing (TypeScript):** +```typescript +it('should return false for empty output', async () => { + const { detectRateLimit } = await import('../rate-limit-detector'); + const result = detectRateLimit(''); + expect(result.isRateLimited).toBe(false); +}); + +it('should handle malformed input gracefully', () => { + expect(() => parser.parse(null as any)).not.toThrow(); +}); +``` + +**Parameterized Tests (Python):** +```python +@pytest.mark.parametrize("cmd,expected", [ + ("ls -la", ["ls"]), + ("cat file | grep pattern", ["cat", "grep"]), + ("", []), +]) +def test_extract_commands(cmd, expected): + assert extract_commands(cmd) == expected +``` + +**Parameterized Tests (TypeScript):** +```typescript +const testCases = [ + 'rate limit exceeded', + 'usage limit reached', + 'too many requests' +]; + +for (const output of testCases) { + const result = detectRateLimit(output); + expect(result.isRateLimited).toBe(true); +} +``` + +## Pre-commit Testing + +**Configuration:** `.pre-commit-config.yaml` + +Tests run automatically on commit: +- Python: `pytest -m "not slow and not integration"` (fast tests only) +- TypeScript: Biome lint + TypeScript type check + +Skipped tests in pre-commit: +- `test_graphiti.py` (external dependencies) +- `test_worktree.py` (git-sensitive) +- `test_workspace.py` (Windows path issues) + +## Test Markers (Python) + +```python +@pytest.mark.slow # Long-running tests +@pytest.mark.integration # Integration tests +@pytest.mark.asyncio # Async tests (auto-applied via config) +``` + +**Run specific markers:** +```bash +# Skip slow tests +pytest tests/ -m "not slow" + +# Run only integration tests +pytest tests/ -m "integration" +``` + +--- + +*Testing analysis: 2026-01-19* diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 00000000..f23a5804 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,6 @@ +{ + "mode": "yolo", + "depth": "comprehensive", + "parallelization": true, + "created": "2026-01-19" +} diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.test.ts b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts new file mode 100644 index 00000000..17c0f7b3 --- /dev/null +++ b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts @@ -0,0 +1,1709 @@ +/** + * Tests for usage-monitor.ts + * + * Red phase - write failing tests first + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { detectProvider, getUsageEndpoint, UsageMonitor, getUsageMonitor } from './usage-monitor'; +import type { ApiProvider } from './usage-monitor'; +import { hasHardcodedText } from '../../shared/utils/format-time'; + +// Mock getClaudeProfileManager +vi.mock('../claude-profile-manager', () => ({ + getClaudeProfileManager: vi.fn(() => ({ + getAutoSwitchSettings: vi.fn(() => ({ + enabled: true, + proactiveSwapEnabled: true, + usageCheckInterval: 30000, + sessionThreshold: 80, + weeklyThreshold: 80 + })), + getActiveProfile: vi.fn(() => ({ + id: 'test-profile-1', + name: 'Test Profile', + baseUrl: 'https://api.anthropic.com', + oauthToken: 'mock-oauth-token' + })), + getProfile: vi.fn((id: string) => ({ + id, + name: 'Test Profile', + baseUrl: 'https://api.anthropic.com', + oauthToken: 'mock-oauth-token' + })), + getProfilesSortedByAvailability: vi.fn(() => [ + { id: 'profile-2', name: 'Profile 2' }, + { id: 'profile-3', name: 'Profile 3' } + ]), + setActiveProfile: vi.fn(), + getProfileToken: vi.fn(() => 'mock-decrypted-token') + })) +})); + +// Mock loadProfilesFile +const mockLoadProfilesFile = vi.fn(async () => ({ + profiles: [] as Array<{ + id: string; + name: string; + baseUrl: string; + apiKey: string; + }>, + activeProfileId: null as string | null, + version: 1 +})); + +vi.mock('../services/profile/profile-manager', () => ({ + loadProfilesFile: () => mockLoadProfilesFile() +})); + +// Mock global fetch +global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + 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' + }) + } as unknown as Response) +) as any; + +describe('usage-monitor', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + // Note: detectProvider tests removed - now using shared/utils/provider-detection.ts + // which has its own comprehensive test suite + + describe('getUsageEndpoint', () => { + it('should return correct endpoint for Anthropic', () => { + const result = getUsageEndpoint('anthropic', 'https://api.anthropic.com'); + expect(result).toBe('https://api.anthropic.com/api/oauth/usage'); + }); + + it('should return correct endpoint for Anthropic with path', () => { + const result = getUsageEndpoint('anthropic', 'https://api.anthropic.com/v1'); + expect(result).toBe('https://api.anthropic.com/api/oauth/usage'); + }); + + it('should return correct endpoint for zai', () => { + const result = getUsageEndpoint('zai', 'https://api.z.ai/api/anthropic'); + // quota/limit endpoint doesn't require query parameters + expect(result).toBe('https://api.z.ai/api/monitor/usage/quota/limit'); + }); + + it('should return correct endpoint for zhipu', () => { + const result = getUsageEndpoint('zhipu', 'https://open.bigmodel.cn/api/paas/v4'); + // quota/limit endpoint doesn't require query parameters + expect(result).toBe('https://open.bigmodel.cn/api/monitor/usage/quota/limit'); + }); + + it('should return null for unknown provider', () => { + const result = getUsageEndpoint('unknown' as ApiProvider, 'https://example.com'); + expect(result).toBeNull(); + }); + + it('should return null for invalid baseUrl', () => { + const result = getUsageEndpoint('anthropic', 'not-a-url'); + expect(result).toBeNull(); + }); + }); + + describe('UsageMonitor', () => { + it('should return singleton instance', () => { + const monitor1 = UsageMonitor.getInstance(); + const monitor2 = UsageMonitor.getInstance(); + + expect(monitor1).toBe(monitor2); + }); + + it('should return same instance from getUsageMonitor()', () => { + const monitor1 = getUsageMonitor(); + const monitor2 = getUsageMonitor(); + + expect(monitor1).toBe(monitor2); + }); + + it('should start monitoring when settings allow', () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + monitor.start(); + + // Check that console.warn was called (monitor logs when starting) + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + monitor.stop(); + }); + + it('should not start if already running', () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + monitor.start(); + monitor.start(); // Second call should be ignored + + // Should have logged a warning that it's already running + expect(consoleSpy.mock.calls.length).toBeGreaterThan(0); + + consoleSpy.mockRestore(); + monitor.stop(); + }); + + it('should stop monitoring', () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + monitor.start(); + monitor.stop(); + + // Verify stop completed without errors + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should return current usage snapshot', () => { + const monitor = getUsageMonitor(); + + // Seed the monitor with known test data for deterministic behavior + const seeded = { + sessionPercent: 10, + weeklyPercent: 20, + profileId: 'test-profile', + profileName: 'Test Profile', + fetchedAt: new Date() + }; + monitor['currentUsage'] = seeded as any; + + const usage = monitor.getCurrentUsage(); + + // getCurrentUsage returns the seeded usage snapshot + expect(usage).toBe(seeded); + expect(usage).toHaveProperty('sessionPercent'); + expect(usage).toHaveProperty('weeklyPercent'); + expect(usage).toHaveProperty('profileId'); + expect(usage).toHaveProperty('profileName'); + // Verify types of critical properties + expect(typeof usage?.sessionPercent).toBe('number'); + expect(typeof usage?.weeklyPercent).toBe('number'); + }); + + it('should emit events when listeners are attached', () => { + const monitor = getUsageMonitor(); + const usageHandler = vi.fn(); + + monitor.on('usage-updated', usageHandler); + + // Verify event handler is attached + expect(monitor.listenerCount('usage-updated')).toBe(1); + + // Clean up + monitor.off('usage-updated', usageHandler); + }); + + it('should allow removing event listeners', () => { + const monitor = getUsageMonitor(); + const usageHandler = vi.fn(); + + monitor.on('usage-updated', usageHandler); + expect(monitor.listenerCount('usage-updated')).toBe(1); + + monitor.off('usage-updated', usageHandler); + expect(monitor.listenerCount('usage-updated')).toBe(0); + }); + }); + + describe('UsageMonitor error handling', () => { + it('should emit event when swap fails', () => { + const monitor = getUsageMonitor(); + const swapFailedHandler = vi.fn(); + + monitor.on('proactive-swap-failed', swapFailedHandler); + + // Manually trigger the swap logic by calling the private method through a test scenario + // Since we can't directly call private methods, we'll verify the event system works + monitor.emit('proactive-swap-failed', { + reason: 'no_alternative', + currentProfile: 'test-profile' + }); + + expect(swapFailedHandler).toHaveBeenCalledWith({ + reason: 'no_alternative', + currentProfile: 'test-profile' + }); + + monitor.off('proactive-swap-failed', swapFailedHandler); + }); + }); + + describe('Anthropic response normalization', () => { + it('should normalize Anthropic response with utilization values', () => { + const monitor = getUsageMonitor(); + const rawData = { + 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' + }; + + const usage = monitor['normalizeAnthropicResponse'](rawData, 'test-profile-1', 'Anthropic Profile'); + + expect(usage).not.toBeNull(); + expect(usage.sessionPercent).toBe(72); // 0.72 * 100 + expect(usage.weeklyPercent).toBe(45); // 0.45 * 100 + expect(usage.limitType).toBe('session'); // 0.45 (weekly) < 0.72 (session), so session is higher + expect(usage.profileId).toBe('test-profile-1'); + expect(usage.profileName).toBe('Anthropic Profile'); + expect(usage.sessionResetTimestamp).toBe('2025-01-17T15:00:00Z'); + expect(usage.weeklyResetTimestamp).toBe('2025-01-20T12:00:00Z'); + }); + + it('should handle missing optional fields in Anthropic response', () => { + const monitor = getUsageMonitor(); + const rawData = { + five_hour_utilization: 0.50 + // Missing: seven_day_utilization, reset times + }; + + const usage = monitor['normalizeAnthropicResponse'](rawData, 'test-profile-1', 'Test Profile'); + + expect(usage).not.toBeNull(); + expect(usage.sessionPercent).toBe(50); + expect(usage.weeklyPercent).toBe(0); // Missing field defaults to 0 + // sessionResetTime/weeklyResetTime are now undefined - renderer uses timestamps + expect(usage.sessionResetTime).toBeUndefined(); + expect(usage.weeklyResetTime).toBeUndefined(); + expect(usage.sessionResetTimestamp).toBeUndefined(); + expect(usage.weeklyResetTimestamp).toBeUndefined(); + }); + }); + + describe('z.ai response normalization', () => { + + it('should normalize z.ai response with usage/limit fields', () => { + const monitor = getUsageMonitor(); + // Create future dates for reset times (use relative time from now) + const now = new Date(); + const sessionReset = new Date(now.getTime() + 2 * 60 * 60 * 1000); // 2 hours from now + + // Use quota/limit format with limits array + const rawData = { + limits: [ + { + type: 'TOKENS_LIMIT', + percentage: 72, + nextResetTime: sessionReset.getTime() + }, + { + type: 'TIME_LIMIT', + percentage: 51, + currentValue: 180000, + usage: 350000 + } + ] + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'zai-profile-1', 'z.ai Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(72); // TOKENS_LIMIT percentage + expect(usage?.weeklyPercent).toBe(51); // TIME_LIMIT percentage + // sessionResetTime/weeklyResetTime are now undefined - renderer uses timestamps + expect(usage?.sessionResetTime).toBeUndefined(); + expect(usage?.weeklyResetTime).toBeUndefined(); + // Verify timestamps are provided for renderer + expect(usage?.sessionResetTimestamp).toBeDefined(); + expect(usage?.weeklyResetTimestamp).toBeDefined(); + expect(usage?.limitType).toBe('session'); // 51 (weekly) < 72 (session), so session is higher + }); + + it('should try alternative field names for z.ai response', () => { + const monitor = getUsageMonitor(); + // Use quota/limit format with limits array + const rawData = { + limits: [ + { + type: 'TOKENS_LIMIT', + percentage: 25 + }, + { + type: 'TIME_LIMIT', + percentage: 50, + currentValue: 150000, + usage: 300000 + } + ] + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'zai-profile-1', 'z.ai Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(25); // TOKENS_LIMIT percentage + expect(usage?.weeklyPercent).toBe(50); // TIME_LIMIT percentage + // sessionResetTime/weeklyResetTime are now undefined - renderer uses timestamps + expect(usage?.sessionResetTime).toBeUndefined(); + expect(usage?.weeklyResetTime).toBeUndefined(); + // Verify timestamps are provided for renderer + expect(usage?.sessionResetTimestamp).toBeDefined(); + expect(usage?.weeklyResetTimestamp).toBeDefined(); + }); + + it('should return null when no data can be extracted from z.ai', () => { + const monitor = getUsageMonitor(); + const rawData = { + unknown_field: 'some_value', + another_field: 123 + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'zai-profile-1', 'z.ai Profile'); + + expect(usage).toBeNull(); + }); + }); + + describe('z.ai quota/limit endpoint normalization', () => { + it('should normalize z.ai quota/limit response with limits array', () => { + const monitor = getUsageMonitor(); + // Create a future reset time (3 hours from now) + const now = Date.now(); + const nextResetTime = now + 3 * 60 * 60 * 1000; // 3 hours from now + + const rawData = { + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + usage: 1000, + currentValue: 660, + remaining: 340, + percentage: 66, + usageDetails: [ + { modelCode: 'search-prime', usage: 599 }, + { modelCode: 'web-reader', usage: 88 } + ] + }, + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + usage: 200000000, + currentValue: 20926987, + remaining: 179073013, + percentage: 10, + nextResetTime: nextResetTime + } + ] + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'zai-profile-1', 'z.ai Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(10); // TOKENS_LIMIT percentage + expect(usage?.weeklyPercent).toBe(66); // TIME_LIMIT percentage + expect(usage?.sessionUsageValue).toBe(20926987); // current token usage + expect(usage?.sessionUsageLimit).toBe(200000000); // total token limit + expect(usage?.weeklyUsageValue).toBe(660); // current tool usage + expect(usage?.weeklyUsageLimit).toBe(1000); // total tool limit + expect(usage?.sessionResetTimestamp).toBeDefined(); + expect(usage?.limitType).toBe('weekly'); // 66 > 10 + expect(usage?.usageWindows?.sessionWindowLabel).toBe('common:usage.window5HoursQuota'); + expect(usage?.usageWindows?.weeklyWindowLabel).toBe('common:usage.windowMonthlyToolsQuota'); + }); + + it('should handle missing nextResetTime gracefully', () => { + const monitor = getUsageMonitor(); + + const rawData = { + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + usage: 1000, + currentValue: 500, + remaining: 500, + percentage: 50 + }, + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + usage: 200000000, + currentValue: 100000000, + remaining: 100000000, + percentage: 50 + // Missing nextResetTime - should fall back to now + 5 hours + } + ] + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'zai-profile-1', 'z.ai Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(50); + expect(usage?.weeklyPercent).toBe(50); + expect(usage?.sessionResetTimestamp).toBeDefined(); // Should have fallback timestamp + }); + + it('should handle missing currentValue and usage fields', () => { + const monitor = getUsageMonitor(); + + const rawData = { + limits: [ + { + type: 'TIME_LIMIT', + percentage: 75 + // Missing currentValue, usage + }, + { + type: 'TOKENS_LIMIT', + percentage: 25 + // Missing currentValue, usage, nextResetTime + } + ] + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'zai-profile-1', 'z.ai Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(25); + expect(usage?.weeklyPercent).toBe(75); + expect(usage?.sessionUsageValue).toBeUndefined(); // No currentValue in response + expect(usage?.sessionUsageLimit).toBeUndefined(); // No usage in response + expect(usage?.weeklyUsageValue).toBeUndefined(); + expect(usage?.weeklyUsageLimit).toBeUndefined(); + }); + }); + + describe('ZHIPU response normalization', () => { + it('should normalize ZHIPU response with usage/limit fields', () => { + const monitor = getUsageMonitor(); + // Use quota/limit format with limits array + const rawData = { + limits: [ + { + type: 'TOKENS_LIMIT', + percentage: 90, + nextResetTime: Date.now() + 2 * 60 * 60 * 1000 // 2 hours from now + }, + { + type: 'TIME_LIMIT', + percentage: 80, + currentValue: 280000, + usage: 350000 + } + ] + }; + + const usage = monitor['normalizeZhipuResponse'](rawData, 'zhipu-profile-1', 'ZHIPU Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(90); // TOKENS_LIMIT percentage + expect(usage?.weeklyPercent).toBe(80); // TIME_LIMIT percentage + expect(usage?.limitType).toBe('session'); // 80 (weekly) < 90 (session), so session is higher + expect(usage?.profileId).toBe('zhipu-profile-1'); + expect(usage?.profileName).toBe('ZHIPU Profile'); + }); + + it('should try alternative field names for ZHIPU response', () => { + const monitor = getUsageMonitor(); + // Use quota/limit format with limits array + const rawData = { + limits: [ + { + type: 'TOKENS_LIMIT', + percentage: 50 + }, + { + type: 'TIME_LIMIT', + percentage: 48, + currentValue: 200000, + usage: 420000 + } + ] + }; + + const usage = monitor['normalizeZhipuResponse'](rawData, 'zhipu-profile-1', 'ZHIPU Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(50); // TOKENS_LIMIT percentage + expect(usage?.weeklyPercent).toBe(48); // TIME_LIMIT percentage + }); + }); + + describe('ZHIPU quota/limit endpoint normalization', () => { + it('should normalize ZHIPU quota/limit response with limits array', () => { + const monitor = getUsageMonitor(); + // Create a future reset time (2 hours from now) + const now = Date.now(); + const nextResetTime = now + 2 * 60 * 60 * 1000; // 2 hours from now + + const rawData = { + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + usage: 1000, + currentValue: 800, + remaining: 200, + percentage: 80, + usageDetails: [ + { modelCode: 'search-prime', usage: 700 }, + { modelCode: 'web-reader', usage: 100 } + ] + }, + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + usage: 200000000, + currentValue: 40000000, + remaining: 160000000, + percentage: 20, + nextResetTime: nextResetTime + } + ] + }; + + const usage = monitor['normalizeZhipuResponse'](rawData, 'zhipu-profile-1', 'ZHIPU Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(20); // TOKENS_LIMIT percentage + expect(usage?.weeklyPercent).toBe(80); // TIME_LIMIT percentage + expect(usage?.sessionUsageValue).toBe(40000000); // current token usage + expect(usage?.sessionUsageLimit).toBe(200000000); // total token limit + expect(usage?.weeklyUsageValue).toBe(800); // current tool usage + expect(usage?.weeklyUsageLimit).toBe(1000); // total tool limit + expect(usage?.sessionResetTimestamp).toBeDefined(); + expect(usage?.limitType).toBe('weekly'); // 80 > 20 + expect(usage?.usageWindows?.sessionWindowLabel).toBe('common:usage.window5HoursQuota'); + expect(usage?.usageWindows?.weeklyWindowLabel).toBe('common:usage.windowMonthlyToolsQuota'); + }); + + it('should handle ZHIPU quota/limit response without nextResetTime', () => { + const monitor = getUsageMonitor(); + + const rawData = { + limits: [ + { + type: 'TIME_LIMIT', + percentage: 45 + }, + { + type: 'TOKENS_LIMIT', + percentage: 55 + // Missing nextResetTime, currentValue, usage + } + ] + }; + + const usage = monitor['normalizeZhipuResponse'](rawData, 'zhipu-profile-1', 'ZHIPU Profile'); + + expect(usage).not.toBeNull(); + expect(usage?.sessionPercent).toBe(55); + expect(usage?.weeklyPercent).toBe(45); + expect(usage?.sessionResetTimestamp).toBeDefined(); // Should have fallback timestamp + expect(usage?.sessionUsageValue).toBeUndefined(); + expect(usage?.sessionUsageLimit).toBeUndefined(); + expect(usage?.weeklyUsageValue).toBeUndefined(); + expect(usage?.weeklyUsageLimit).toBeUndefined(); + }); + }); + + describe('Percentage calculation', () => { + it('should calculate percentages correctly from usage/limit values', () => { + const monitor = getUsageMonitor(); + // Use quota/limit format - percentages are pre-calculated by the API + const rawData = { + limits: [ + { + type: 'TOKENS_LIMIT', + percentage: 25 // 25% + }, + { + type: 'TIME_LIMIT', + percentage: 50 // 50% + } + ] + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'test-profile', 'Test Profile'); + + expect(usage?.sessionPercent).toBe(25); // TOKENS_LIMIT percentage + expect(usage?.weeklyPercent).toBe(50); // TIME_LIMIT percentage + }); + + it('should handle division by zero (zero limit)', () => { + const monitor = getUsageMonitor(); + // When percentage is 0 or missing, default to 0 + const rawData = { + limits: [ + { + type: 'TOKENS_LIMIT', + percentage: 0 // Zero usage + }, + { + type: 'TIME_LIMIT', + percentage: 50 + } + ] + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'test-profile', 'Test Profile'); + + expect(usage?.sessionPercent).toBe(0); // Zero percentage + expect(usage?.weeklyPercent).toBe(50); // TIME_LIMIT percentage + }); + }); + + describe('Malformed response handling', () => { + it('should handle non-numeric usage values gracefully', () => { + const monitor = getUsageMonitor(); + // Missing limits array - should return null + const rawData = { + session_usage: 'not a number', + session_limit: 'also not a number', + weekly_usage: null, + weekly_limit: undefined + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'test-profile', 'Test Profile'); + + // Should return null when response doesn't match expected quota/limit format + expect(usage).toBeNull(); + }); + + it('should handle completely unknown response structure', () => { + const monitor = getUsageMonitor(); + // Unknown structure without limits array - should return null + const rawData = { + unknown_field: 'some_value', + another_field: 123, + nested: { + data: 'value' + } + }; + + const usage = monitor['normalizeZAIResponse'](rawData, 'test-profile', 'Test Profile'); + + // Should return null when response doesn't match expected quota/limit format + expect(usage).toBeNull(); + }); + }); + + describe('API error handling', () => { + it('should handle 401 Unauthorized responses', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ error: 'Invalid token' }) + } as unknown as Response); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // 401 errors should throw + await expect( + monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile') + ).rejects.toThrow('API Auth Failure: 401'); + + expect(consoleSpy).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Authorization': 'Bearer invalid-token' + }) + }) + ); + + consoleSpy.mockRestore(); + }); + + it('should handle 403 Forbidden responses', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({ error: 'Access denied' }) + } as unknown as Response); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // 403 errors should throw + await expect( + monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile') + ).rejects.toThrow('API Auth Failure: 403'); + + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle 500 Internal Server Error', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'Server error' }) + } as unknown as Response); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + + expect(usage).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle network timeout/failure', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockRejectedValueOnce(new Error('Network timeout')); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + + expect(usage).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle invalid JSON response', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => { + throw new SyntaxError('Invalid JSON'); + } + } as unknown as Response); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + + expect(usage).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle auth errors with clear messages in response body', async () => { + const mockFetch = vi.mocked(global.fetch); + // Mock a 401 response with detailed error message in body + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ error: 'authentication failed', detail: 'invalid credentials' }) + } as unknown as Response); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // 401 errors should throw with proper message + await expect( + monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile') + ).rejects.toThrow('API Auth Failure: 401'); + + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe('Credential error handling', () => { + it('should handle missing credential gracefully', async () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Call fetchUsage without credential + const usage = await monitor['fetchUsage']('test-profile-1', undefined); + + // Should fall back to CLI method (which returns null) + expect(usage).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle empty credential string', async () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const usage = await monitor['fetchUsage']('test-profile-1', ''); + + // Should fall back to CLI method + expect(usage).toBeNull(); + + consoleSpy.mockRestore(); + }); + }); + + describe('Profile error handling', () => { + it('should handle null active profile', async () => { + // Get the mocked getClaudeProfileManager function + const { getClaudeProfileManager } = await import('../claude-profile-manager'); + const mockGetManager = vi.mocked(getClaudeProfileManager); + + // Mock to return null for active profile + mockGetManager.mockReturnValueOnce({ + getAutoSwitchSettings: vi.fn(() => ({ + enabled: true, + proactiveSwapEnabled: true, + usageCheckInterval: 30000, + sessionThreshold: 80, + weeklyThreshold: 80 + })), + getActiveProfile: vi.fn(() => null), // Return null + getProfile: vi.fn(() => null), + getProfilesSortedByAvailability: vi.fn(() => []), + setActiveProfile: vi.fn(), + getProfileToken: vi.fn(() => null) + } as any); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Call checkUsageAndSwap directly to test null profile handling + await monitor['checkUsageAndSwap'](); + + // Should log a warning about no active profile + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No active profile')); + + consoleSpy.mockRestore(); + }); + + it('should handle profile with missing required fields', async () => { + const monitor = getUsageMonitor(); + const rawData = { + // Missing all required fields + }; + + const usage = monitor['normalizeAnthropicResponse'](rawData, 'test-profile-1', 'Test Profile'); + + // Should still return a valid snapshot with defaults + expect(usage).not.toBeNull(); + expect(usage.sessionPercent).toBe(0); + expect(usage.weeklyPercent).toBe(0); + // sessionResetTime/weeklyResetTime are now undefined - renderer uses timestamps + expect(usage.sessionResetTime).toBeUndefined(); + expect(usage.weeklyResetTime).toBeUndefined(); + }); + }); + + describe('Provider-specific error handling', () => { + it('should handle zai API errors', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({ error: 'z.ai service unavailable' }) + } as unknown as Response); + + // Mock API profile with zai baseUrl + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [{ + id: 'zai-profile-1', + name: 'z.ai Profile', + baseUrl: 'https://api.z.ai/api/anthropic', + apiKey: 'zai-api-key' + }], + activeProfileId: 'zai-profile-1', + version: 1 + }); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile'); + + expect(usage).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle ZHIPU API errors', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + json: async () => ({ error: 'ZHIPU gateway error' }) + } as unknown as Response); + + // Mock API profile with ZHIPU baseUrl + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [{ + id: 'zhipu-profile-1', + name: 'ZHIPU Profile', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + apiKey: 'zhipu-api-key' + }], + activeProfileId: 'zhipu-profile-1', + version: 1 + }); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile'); + + expect(usage).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should handle unknown provider gracefully', async () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Create an active profile object with unknown provider + const unknownProviderProfile = { + isAPIProfile: true, + profileId: 'unknown-profile-1', + profileName: 'Unknown Provider Profile', + baseUrl: 'https://unknown-provider.com/api' + }; + + // Mock API profile with unknown provider baseUrl + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [{ + id: 'unknown-profile-1', + name: 'Unknown Provider Profile', + baseUrl: 'https://unknown-provider.com/api', + apiKey: 'unknown-api-key' + }], + activeProfileId: 'unknown-profile-1', + version: 1 + }); + + const usage = await monitor['fetchUsageViaAPI']( + 'unknown-api-key', + 'unknown-profile-1', + 'Unknown Profile', + unknownProviderProfile + ); + + // Unknown provider should return null + expect(usage).toBeNull(); + // Verify console.warn was called with "Unknown provider - no usage endpoint configured:" message + expect(consoleSpy).toHaveBeenCalledWith( + '[UsageMonitor] Unknown provider - no usage endpoint configured:', + expect.objectContaining({ + provider: 'unknown', + baseUrl: 'https://unknown-provider.com/api', + profileId: 'unknown-profile-1' + }) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('Concurrent check prevention', () => { + it('should prevent concurrent usage checks', async () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Start first check (it will take some time) + const firstCheck = monitor['checkUsageAndSwap'](); + + // Try to start second check immediately (should be ignored) + const secondCheck = monitor['checkUsageAndSwap'](); + + // Both should resolve + await firstCheck; + await secondCheck; + + // Verify checks completed + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe('backward compatibility', () => { + describe('Legacy OAuth-only profile support', () => { + it('should work with legacy OAuth profiles (no API profile support)', async () => { + // Mock loadProfilesFile to return empty profiles (API profiles not configured) + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [], + activeProfileId: null, + version: 1 + }); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Should fall back to OAuth profile + const credential = await monitor['getCredential'](); + + // Should get OAuth token from profile manager + expect(credential).toBe('mock-decrypted-token'); + + consoleSpy.mockRestore(); + }); + + it('should prioritize API profile when available', async () => { + // Mock API profile is configured + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [{ + id: 'api-profile-1', + name: 'API Profile', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-ant-api-key' + }], + activeProfileId: 'api-profile-1', + version: 1 + }); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const credential = await monitor['getCredential'](); + + // Should prefer API key over OAuth token + expect(credential).toBe('sk-ant-api-key'); + + consoleSpy.mockRestore(); + }); + + it('should handle missing API profile gracefully', async () => { + // Mock activeProfileId points to non-existent profile + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [], + activeProfileId: 'nonexistent-profile', + version: 1 + }); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const credential = await monitor['getCredential'](); + + // Should fall back to OAuth + expect(credential).toBe('mock-decrypted-token'); + + consoleSpy.mockRestore(); + }); + }); + + describe('Settings backward compatibility', () => { + it('should handle settings with missing optional fields', async () => { + // Get the mocked getClaudeProfileManager function + const { getClaudeProfileManager } = await import('../claude-profile-manager'); + const mockGetManager = vi.mocked(getClaudeProfileManager); + + // Mock settings with missing optional fields + mockGetManager.mockReturnValueOnce({ + getAutoSwitchSettings: vi.fn(() => ({ + enabled: true, + proactiveSwapEnabled: true + // Missing: usageCheckInterval, sessionThreshold, weeklyThreshold + })), + getActiveProfile: vi.fn(() => ({ + id: 'test-profile-1', + name: 'Test Profile', + baseUrl: 'https://api.anthropic.com', + oauthToken: 'mock-oauth-token' + })), + getProfile: vi.fn(() => ({ + id: 'test-profile-1', + name: 'Test Profile', + baseUrl: 'https://api.anthropic.com', + oauthToken: 'mock-oauth-token' + })), + getProfilesSortedByAvailability: vi.fn(() => []), + setActiveProfile: vi.fn(), + getProfileToken: vi.fn(() => 'mock-decrypted-token') + } as any); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Should start with default values for missing fields + monitor.start(); + + // Default usageCheckInterval is 30000ms + expect(consoleSpy).toHaveBeenCalledWith('[UsageMonitor] Starting with interval:', 30000, 'ms (30-second updates for accurate usage stats)'); + + consoleSpy.mockRestore(); + monitor.stop(); + }); + + it('should use default thresholds when not specified in settings', async () => { + // Get the mocked getClaudeProfileManager function + const { getClaudeProfileManager } = await import('../claude-profile-manager'); + const mockGetManager = vi.mocked(getClaudeProfileManager); + + // Mock settings without thresholds + mockGetManager.mockReturnValueOnce({ + getAutoSwitchSettings: vi.fn(() => ({ + enabled: true, + proactiveSwapEnabled: true, + usageCheckInterval: 30000 + // Missing: sessionThreshold, weeklyThreshold + })), + getActiveProfile: vi.fn(() => ({ + id: 'test-profile-1', + name: 'Test Profile', + baseUrl: 'https://api.anthropic.com', + oauthToken: 'mock-oauth-token' + })), + getProfile: vi.fn(() => ({ + id: 'test-profile-1', + name: 'Test Profile', + baseUrl: 'https://api.anthropic.com', + oauthToken: 'mock-oauth-token' + })), + getProfilesSortedByAvailability: vi.fn(() => []), + setActiveProfile: vi.fn(), + getProfileToken: vi.fn(() => 'mock-decrypted-token') + } as any); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Should not crash when checking thresholds + monitor.start(); + + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + monitor.stop(); + }); + }); + + describe('Anthropic response format backward compatibility', () => { + it('should handle legacy Anthropic response format', () => { + const monitor = getUsageMonitor(); + + // Legacy format with field names that might have changed + const legacyData = { + five_hour_utilization: 0.60, + seven_day_utilization: 0.40, + five_hour_reset_at: '2025-01-17T15:00:00Z', + seven_day_reset_at: '2025-01-20T12:00:00Z' + }; + + const usage = monitor['normalizeAnthropicResponse'](legacyData, 'test-profile-1', 'Legacy Profile'); + + expect(usage).not.toBeNull(); + expect(usage.sessionPercent).toBe(60); + expect(usage.weeklyPercent).toBe(40); + expect(usage.limitType).toBe('session'); // 60% > 40%, so session is the higher limit + }); + + it('should handle response with only utilization values (no reset times)', () => { + const monitor = getUsageMonitor(); + + const minimalData = { + five_hour_utilization: 0.75, + seven_day_utilization: 0.50 + // Missing reset times + }; + + const usage = monitor['normalizeAnthropicResponse'](minimalData, 'test-profile-1', 'Minimal Profile'); + + expect(usage).not.toBeNull(); + expect(usage.sessionPercent).toBe(75); + expect(usage.weeklyPercent).toBe(50); + // sessionResetTime/weeklyResetTime are now undefined - renderer uses timestamps + expect(usage.sessionResetTime).toBeUndefined(); + expect(usage.weeklyResetTime).toBeUndefined(); + }); + + it('should handle response with zero utilization values', () => { + const monitor = getUsageMonitor(); + + const zeroData = { + five_hour_utilization: 0, + seven_day_utilization: 0, + five_hour_reset_at: '2025-01-17T15:00:00Z', + seven_day_reset_at: '2025-01-20T12:00:00Z' + }; + + const usage = monitor['normalizeAnthropicResponse'](zeroData, 'test-profile-1', 'Zero Usage Profile'); + + expect(usage).not.toBeNull(); + expect(usage.sessionPercent).toBe(0); + expect(usage.weeklyPercent).toBe(0); + }); + + it('should handle response with only five_hour data (no seven_day)', () => { + const monitor = getUsageMonitor(); + + const partialData = { + five_hour_utilization: 0.80, + five_hour_reset_at: '2025-01-17T15:00:00Z' + // Missing seven_day data + }; + + const usage = monitor['normalizeAnthropicResponse'](partialData, 'test-profile-1', 'Partial Profile'); + + expect(usage).not.toBeNull(); + expect(usage.sessionPercent).toBe(80); + expect(usage.weeklyPercent).toBe(0); // Defaults to 0 + // sessionResetTime/weeklyResetTime are now undefined - renderer uses timestamps + expect(usage.sessionResetTime).toBeUndefined(); + expect(usage.weeklyResetTime).toBeUndefined(); + // Verify timestamps are still provided for renderer + expect(usage.sessionResetTimestamp).toBe('2025-01-17T15:00:00Z'); + }); + }); + + describe('Provider detection backward compatibility', () => { + it('should handle Anthropic OAuth profiles (no baseUrl in OAuth profiles)', async () => { + // OAuth profiles don't have baseUrl - they should default to Anthropic provider + // This test verifies the backward compatibility by checking that: + // 1. OAuth profiles (without baseUrl) are supported + // 2. They default to using Anthropic's OAuth usage endpoint + + const endpoint = getUsageEndpoint('anthropic', 'https://api.anthropic.com'); + expect(endpoint).toBe('https://api.anthropic.com/api/oauth/usage'); + + // Verify that when no baseUrl is provided (OAuth profile scenario), + // the system defaults to Anthropic's standard endpoint + const provider = detectProvider('https://api.anthropic.com'); + expect(provider).toBe('anthropic'); + }); + + it('should handle legacy baseUrl formats for zai', () => { + // Test various legacy zai baseUrl formats + const legacyUrls = [ + 'https://api.z.ai/api/anthropic', + 'https://z.ai/api/anthropic', + 'https://api.z.ai/v1', + 'https://z.ai' + ]; + + legacyUrls.forEach(url => { + const provider = detectProvider(url); + expect(provider).toBe('zai'); + }); + }); + + it('should handle legacy baseUrl formats for ZHIPU', () => { + // Test various legacy ZHIPU baseUrl formats + const legacyUrls = [ + 'https://open.bigmodel.cn/api/paas/v4', + 'https://dev.bigmodel.cn/api/paas/v4', + 'https://bigmodel.cn/api/paas/v4', + 'https://open.bigmodel.cn' + ]; + + legacyUrls.forEach(url => { + const provider = detectProvider(url); + expect(provider).toBe('zhipu'); + }); + }); + + it('should handle Anthropic OAuth default baseUrl', () => { + // OAuth profiles don't have baseUrl, should default to Anthropic + const endpoint = getUsageEndpoint('anthropic', 'https://api.anthropic.com'); + expect(endpoint).toBe('https://api.anthropic.com/api/oauth/usage'); + }); + }); + + describe('Mixed OAuth/API profile environments', () => { + it('should handle environment with both OAuth and API profiles', async () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Mock both OAuth and API profiles + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [ + { + id: 'api-profile-1', + name: 'API Profile', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-ant-api-key' + }, + { + id: 'api-profile-2', + name: 'z.ai API Profile', + baseUrl: 'https://api.z.ai/api/anthropic', + apiKey: 'zai-api-key' + } + ], + activeProfileId: 'api-profile-1', + version: 1 + }); + + const credential = await monitor['getCredential'](); + + // Should use API profile when active + expect(credential).toBe('sk-ant-api-key'); + + consoleSpy.mockRestore(); + }); + + it('should switch from API profile back to OAuth profile', async () => { + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // First, active API profile + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [{ + id: 'api-profile-1', + name: 'API Profile', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-ant-api-key' + }], + activeProfileId: 'api-profile-1', + version: 1 + }); + + let credential = await monitor['getCredential'](); + expect(credential).toBe('sk-ant-api-key'); + + // Then, no active API profile (should fall back to OAuth) + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [], + activeProfileId: null, + version: 1 + }); + + credential = await monitor['getCredential'](); + expect(credential).toBe('mock-decrypted-token'); + + consoleSpy.mockRestore(); + }); + }); + + describe('Graceful degradation for unknown providers', () => { + it('should return null for unknown provider instead of throwing', () => { + const endpoint = getUsageEndpoint('unknown' as ApiProvider, 'https://unknown-provider.com'); + expect(endpoint).toBeNull(); + }); + + it('should handle invalid baseUrl gracefully', () => { + const endpoint = getUsageEndpoint('anthropic', 'not-a-url'); + expect(endpoint).toBeNull(); + }); + + it('should detect unknown provider from unrecognized baseUrl', () => { + const provider = detectProvider('https://unknown-api-provider.com/v1'); + expect(provider).toBe('unknown'); + }); + }); + }); + + describe('Cooldown-based API retry mechanism', () => { + beforeEach(() => { + // Clear any existing failure timestamps before each test + const monitor = getUsageMonitor(); + monitor['apiFailureTimestamps'].clear(); + }); + + it('should record API failure timestamp on error', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'Server error' }) + } as unknown as Response); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const profileId = 'test-profile-cooldown'; + + // Call fetchUsageViaAPI which should fail and record timestamp + await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile'); + + // Verify failure timestamp was recorded + const failureTimestamp = monitor['apiFailureTimestamps'].get(profileId); + expect(failureTimestamp).toBeDefined(); + expect(typeof failureTimestamp).toBe('number'); + // Should be recent (within last second) + expect(Date.now() - failureTimestamp!).toBeLessThan(1000); + + consoleSpy.mockRestore(); + }); + + it('should allow API retry after cooldown expires', async () => { + const monitor = getUsageMonitor(); + const profileId = 'test-profile-retry'; + const now = Date.now(); + + // Set a failure timestamp that's just before the cooldown period + const expiredFailureTime = now - UsageMonitor['API_FAILURE_COOLDOWN_MS'] - 1000; // 1 second past cooldown + monitor['apiFailureTimestamps'].set(profileId, expiredFailureTime); + + // shouldUseApiMethod should return true (cooldown expired) + const shouldUseApi = monitor['shouldUseApiMethod'](profileId); + expect(shouldUseApi).toBe(true); + }); + + it('should prevent API retry during cooldown period', async () => { + const monitor = getUsageMonitor(); + const profileId = 'test-profile-cooldown-active'; + const now = Date.now(); + + // Set a recent failure timestamp (well within cooldown period) + const recentFailureTime = now - 1000; // 1 second ago + monitor['apiFailureTimestamps'].set(profileId, recentFailureTime); + + // shouldUseApiMethod should return false (still in cooldown) + const shouldUseApi = monitor['shouldUseApiMethod'](profileId); + expect(shouldUseApi).toBe(false); + }); + + it('should allow API call when no previous failure recorded', async () => { + const monitor = getUsageMonitor(); + const profileId = 'test-profile-no-failure'; + + // No failure timestamp recorded for this profile + expect(monitor['apiFailureTimestamps'].has(profileId)).toBe(false); + + // shouldUseApiMethod should return true (no previous failure) + const shouldUseApi = monitor['shouldUseApiMethod'](profileId); + expect(shouldUseApi).toBe(true); + }); + + it('should handle edge case exactly at cooldown boundary', async () => { + const monitor = getUsageMonitor(); + const profileId = 'test-profile-boundary'; + const now = Date.now(); + + // Set failure timestamp exactly at cooldown boundary + const boundaryTime = now - UsageMonitor['API_FAILURE_COOLDOWN_MS']; + monitor['apiFailureTimestamps'].set(profileId, boundaryTime); + + // At exact boundary, should allow retry (cooldown period has passed) + const shouldUseApi = monitor['shouldUseApiMethod'](profileId); + expect(shouldUseApi).toBe(true); + }); + + it('should track failures independently for different profiles', async () => { + const monitor = getUsageMonitor(); + const profile1 = 'profile-1'; + const profile2 = 'profile-2'; + const now = Date.now(); + + // Set recent failure for profile1 + monitor['apiFailureTimestamps'].set(profile1, now - 1000); + // Set expired failure for profile2 + monitor['apiFailureTimestamps'].set(profile2, now - UsageMonitor['API_FAILURE_COOLDOWN_MS'] - 1000); + + // Profile 1 should be in cooldown + expect(monitor['shouldUseApiMethod'](profile1)).toBe(false); + // Profile 2 should be allowed + expect(monitor['shouldUseApiMethod'](profile2)).toBe(true); + }); + }); + + describe('Race condition prevention via activeProfile parameter', () => { + it('should use passed activeProfile instead of re-detecting', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + 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' + }) + } as unknown as Response); + + // Mock API profile + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [{ + id: 'api-profile-1', + name: 'API Profile', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-ant-api-key' + }], + activeProfileId: 'api-profile-1', + version: 1 + }); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Pre-determined active profile (simulating profile at time of checkUsageAndSwap) + const predeterminedProfile = { + isAPIProfile: true, + profileId: 'api-profile-1', + profileName: 'API Profile', + baseUrl: 'https://api.anthropic.com' + }; + + // Call fetchUsageViaAPI with predetermined profile + const usage = await monitor['fetchUsageViaAPI']( + 'sk-ant-api-key', + 'api-profile-1', + 'API Profile', + predeterminedProfile + ); + + // Should successfully fetch usage using the passed profile + expect(usage).not.toBeNull(); + expect(usage?.profileId).toBe('api-profile-1'); + expect(usage?.sessionPercent).toBe(50); + + consoleSpy.mockRestore(); + }); + + it('should fall back to profile detection when activeProfile not provided', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + 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' + }) + } as unknown as Response); + + // Mock API profile + mockLoadProfilesFile.mockResolvedValueOnce({ + profiles: [{ + id: 'api-profile-1', + name: 'API Profile', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-ant-api-key' + }], + activeProfileId: 'api-profile-1', + version: 1 + }); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Call fetchUsageViaAPI WITHOUT predetermined profile + // Should fall back to detecting profile from activeProfileId + const usage = await monitor['fetchUsageViaAPI']( + 'sk-ant-api-key', + 'api-profile-1', + 'API Profile', + undefined // No activeProfile passed + ); + + // Should still work by detecting the profile + expect(usage).not.toBeNull(); + expect(usage?.profileId).toBe('api-profile-1'); + + consoleSpy.mockRestore(); + }); + + it('should handle OAuth profile in activeProfile parameter', async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + 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' + }) + } as unknown as Response); + + const monitor = getUsageMonitor(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Pre-determined OAuth profile + const oauthProfile = { + isAPIProfile: false, + profileId: 'oauth-profile', + profileName: 'OAuth Profile', + baseUrl: 'https://api.anthropic.com' + }; + + // Call fetchUsageViaAPI with OAuth profile + const usage = await monitor['fetchUsageViaAPI']( + 'oauth-token', + 'oauth-profile', + 'OAuth Profile', + oauthProfile + ); + + // Should successfully fetch usage for OAuth profile + expect(usage).not.toBeNull(); + expect(usage?.profileId).toBe('oauth-profile'); + + consoleSpy.mockRestore(); + }); + }); + + describe('Shared utility - hasHardcodedText', () => { + it('should return true for empty string', () => { + expect(hasHardcodedText('')).toBe(true); + }); + + it('should return true for null', () => { + expect(hasHardcodedText(null)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(hasHardcodedText(undefined)).toBe(true); + }); + + it('should return true for "Unknown"', () => { + expect(hasHardcodedText('Unknown')).toBe(true); + }); + + it('should return true for "Expired"', () => { + expect(hasHardcodedText('Expired')).toBe(true); + }); + + it('should return false for valid time strings', () => { + expect(hasHardcodedText('2 hours remaining')).toBe(false); + expect(hasHardcodedText('1 day left')).toBe(false); + expect(hasHardcodedText('30 minutes')).toBe(false); + }); + + it('should be case-sensitive for "Unknown" and "Expired"', () => { + // Lowercase versions should not trigger the filter + expect(hasHardcodedText('unknown')).toBe(false); + expect(hasHardcodedText('expired')).toBe(false); + expect(hasHardcodedText('UNKNOWN')).toBe(false); + expect(hasHardcodedText('EXPIRED')).toBe(false); + }); + + it('should handle strings with only whitespace', () => { + // Whitespace-only strings are falsy when trimmed + expect(hasHardcodedText(' ')).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 91c1e12d..55f72d70 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -12,18 +12,169 @@ 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 private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown - + // Debug flag for verbose logging private readonly isDebug = process.env.DEBUG === 'true'; @@ -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(); + + 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 + // 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: 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), + sessionPercent, + weeklyPercent, + // Omit sessionResetTime/weeklyResetTime - renderer uses timestamps with formatTimeRemaining + sessionResetTime: undefined, + weeklyResetTime: undefined, + sessionResetTimestamp, + weeklyResetTimestamp, profileId, profileName, fetchedAt: new Date(), - limitType: (data.seven_day_utilization || 0) > (data.five_hour_utilization || 0) - ? 'weekly' - : 'session' + 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: any) { - // Re-throw auth failures to be handled by checkUsageAndSwap - if (error?.statusCode === 401 || error?.statusCode === 403) { - throw error; - } - - console.error('[UsageMonitor] API fetch failed:', error); + } 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 @@ -347,12 +1172,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 +1187,7 @@ export class UsageMonitor extends EventEmitter { }); return; } - + // Use the best available from eligible profiles const bestProfile = eligibleProfiles[0]; diff --git a/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx b/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx index 98befff0..7efb1fb0 100644 --- a/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx +++ b/apps/frontend/src/renderer/components/AuthStatusIndicator.test.tsx @@ -10,13 +10,44 @@ import '@testing-library/jest-dom/vitest'; import { render, screen } from '@testing-library/react'; import { AuthStatusIndicator } from './AuthStatusIndicator'; import { useSettingsStore } from '../stores/settings-store'; -import type { APIProfile } from '@shared/types/profile'; +import type { APIProfile } from '../../shared/types/profile'; // Mock the settings store vi.mock('../stores/settings-store', () => ({ useSettingsStore: vi.fn() })); +// Mock i18n translation function +vi.mock('react-i18next', () => ({ + useTranslation: vi.fn(() => ({ + t: (key: string, params?: Record) => { + // For translation keys, return values for testing + const translations: Record = { + 'common:usage.authentication': 'Authentication', + 'common:usage.oauth': 'OAuth', + 'common:usage.apiProfile': 'API Profile', + 'common:usage.provider': 'Provider', + 'common:usage.providerAnthropic': 'Anthropic', + 'common:usage.providerZai': 'z.ai', + 'common:usage.providerZhipu': 'ZHIPU AI', + 'common:usage.authenticationAriaLabel': 'Authentication: {{provider}}', + 'common:usage.profile': 'Profile', + 'common:usage.id': 'ID', + 'common:usage.apiEndpoint': 'API Endpoint' + }; + // Handle interpolation (e.g., "Authentication: {{provider}}") + if (params && Object.keys(params).length > 0) { + const translated = translations[key] || key; + if (translated.includes('{{provider}}')) { + return translated.replace('{{provider}}', String(params.provider)); + } + return translated; + } + return translations[key] || key; + } + })) +})); + /** * Creates a mock settings store with optional overrides * @param overrides - Partial store state to override defaults @@ -65,12 +96,35 @@ const testProfiles: APIProfile[] = [ models: undefined, createdAt: Date.now(), updatedAt: Date.now() + }, + { + id: 'profile-3', + name: 'z.ai Global', + baseUrl: 'https://api.z.ai/api/anthropic', + apiKey: 'sk-zai-key-1234', + models: undefined, + createdAt: Date.now(), + updatedAt: Date.now() + }, + { + id: 'profile-4', + name: 'ZHIPU China', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + apiKey: 'zhipu-key-5678', + models: undefined, + createdAt: Date.now(), + updatedAt: Date.now() } ]; describe('AuthStatusIndicator', () => { beforeEach(() => { vi.clearAllMocks(); + // Mock window.electronAPI usage functions + (window as any).electronAPI = { + onUsageUpdated: vi.fn(() => vi.fn()), // Returns unsubscribe function + requestUsageUpdate: vi.fn().mockResolvedValue({ success: false, data: null }) + }; }); describe('when using OAuth (no active profile)', () => { @@ -80,17 +134,17 @@ describe('AuthStatusIndicator', () => { ); }); - it('should display OAuth with Lock icon', () => { + it('should display Anthropic provider with Lock icon', () => { render(); - expect(screen.getByText('OAuth')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /authentication method: oauth/i })).toBeInTheDocument(); + expect(screen.getByText('Anthropic')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument(); }); it('should have correct aria-label for OAuth', () => { render(); - expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication method: OAuth'); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic'); }); }); @@ -101,17 +155,17 @@ describe('AuthStatusIndicator', () => { ); }); - it('should display profile name with Key icon', () => { + it('should display provider label (Anthropic) with Key icon', () => { render(); - expect(screen.getByText('Production API')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /authentication method: production api/i })).toBeInTheDocument(); + expect(screen.getByText('Anthropic')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument(); }); it('should have correct aria-label for profile', () => { render(); - expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication method: Production API'); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic'); }); }); @@ -122,10 +176,63 @@ describe('AuthStatusIndicator', () => { ); }); - it('should fallback to OAuth display', () => { + it('should fallback to Anthropic provider display', () => { render(); - expect(screen.getByText('OAuth')).toBeInTheDocument(); + expect(screen.getByText('Anthropic')).toBeInTheDocument(); + }); + }); + + describe('provider detection for different API profiles', () => { + it('should display z.ai provider label for z.ai profile', () => { + vi.mocked(useSettingsStore).mockReturnValue( + createUseSettingsStoreMock({ activeProfileId: 'profile-3' }) + ); + + render(); + + expect(screen.getByText('z.ai')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: z.ai'); + }); + + it('should display ZHIPU AI provider label for ZHIPU profile', () => { + vi.mocked(useSettingsStore).mockReturnValue( + createUseSettingsStoreMock({ activeProfileId: 'profile-4' }) + ); + + render(); + + expect(screen.getByText('ZHIPU AI')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: ZHIPU AI'); + }); + + it('should apply correct color classes for each provider', () => { + // Test Anthropic (orange) + vi.mocked(useSettingsStore).mockReturnValue( + createUseSettingsStoreMock({ activeProfileId: 'profile-1' }) + ); + + const { rerender } = render(); + const anthropicButton = screen.getByRole('button'); + expect(anthropicButton.className).toContain('text-orange-500'); + + // Test z.ai (blue) + vi.mocked(useSettingsStore).mockReturnValue( + createUseSettingsStoreMock({ activeProfileId: 'profile-3' }) + ); + + rerender(); + const zaiButton = screen.getByRole('button'); + expect(zaiButton.className).toContain('text-blue-500'); + + // Test ZHIPU (purple) + vi.mocked(useSettingsStore).mockReturnValue( + createUseSettingsStoreMock({ activeProfileId: 'profile-4' }) + ); + + rerender(); + const zhipuButton = screen.getByRole('button'); + expect(zhipuButton.className).toContain('text-purple-500'); }); }); diff --git a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx index c9484b83..5faf2c4c 100644 --- a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx +++ b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx @@ -1,73 +1,299 @@ /** * AuthStatusIndicator - Display current authentication method in header * - * Shows the active authentication method: - * - API Profile name with Key icon when a profile is active - * - "OAuth" with Lock icon when using OAuth authentication + * Shows the active authentication method and provider: + * - OAuth: Shows "OAuth Anthropic" with Lock icon + * - API Profile: Shows provider name (z.ai, ZHIPU AI) with Key icon and provider-specific colors + * + * Provider detection is based on the profile's baseUrl: + * - api.anthropic.com → Anthropic + * - api.z.ai → z.ai + * - open.bigmodel.cn, dev.bigmodel.cn → ZHIPU AI + * + * Usage warning badge: Shows to the left of provider badge when usage exceeds 90% */ -import { useMemo } from 'react'; -import { Key, Lock } from 'lucide-react'; +import { useMemo, useState, useEffect } from 'react'; +import { AlertTriangle, Key, Lock, Shield, Server, Fingerprint, ExternalLink } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from './ui/tooltip'; +import { useTranslation } from 'react-i18next'; import { useSettingsStore } from '../stores/settings-store'; +import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection'; +import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time'; +import type { ClaudeUsageSnapshot } from '../../shared/types/agent'; + +/** + * Type-safe mapping from ApiProvider to translation keys + */ +const PROVIDER_TRANSLATION_KEYS: Readonly> = { + anthropic: 'common:usage.providerAnthropic', + zai: 'common:usage.providerZai', + zhipu: 'common:usage.providerZhipu', + unknown: 'common:usage.providerUnknown' +} as const; + +/** + * OAuth fallback state when no profile is active or profile not found + */ +const OAUTH_FALLBACK = { + type: 'oauth' as const, + name: 'OAuth', + provider: 'anthropic' as const, + providerLabel: 'Anthropic', + badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15' +} as const; export function AuthStatusIndicator() { // Subscribe to profile state from settings store const { profiles, activeProfileId } = useSettingsStore(); + const { t } = useTranslation(['common']); - // Compute auth status directly using useMemo to avoid unnecessary re-renders + // Track usage data for warning badge + const [usage, setUsage] = useState(null); + const [isLoadingUsage, setIsLoadingUsage] = useState(true); + + // Listen for usage updates + useEffect(() => { + const unsubscribe = window.electronAPI.onUsageUpdated((snapshot: ClaudeUsageSnapshot) => { + setUsage(snapshot); + setIsLoadingUsage(false); + }); + + // Request initial usage + window.electronAPI.requestUsageUpdate() + .then((result) => { + if (result.success && result.data) { + setUsage(result.data); + } + }) + .catch((error) => { + console.warn('[AuthStatusIndicator] Failed to fetch usage:', error); + }) + .finally(() => { + setIsLoadingUsage(false); + }); + + return () => { + unsubscribe(); + }; + }, []); + + // Determine if usage warning badge should be shown + const shouldShowUsageWarning = usage && !isLoadingUsage && ( + usage.sessionPercent >= 90 || usage.weeklyPercent >= 90 + ); + + // Get the higher usage percentage for the warning badge + const warningBadgePercent = usage + ? Math.max(usage.sessionPercent, usage.weeklyPercent) + : 0; + + // Get formatted reset times (calculated dynamically from timestamps) + // Only fall back to sessionResetTime if it doesn't contain placeholder/hardcoded text + const sessionResetTime = usage?.sessionResetTimestamp + ? (formatTimeRemaining(usage.sessionResetTimestamp, t) ?? + (hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime)) + : (hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime); + + // Compute auth status and provider detection using useMemo to avoid unnecessary re-renders const authStatus = useMemo(() => { if (activeProfileId) { const activeProfile = profiles.find(p => p.id === activeProfileId); if (activeProfile) { - return { type: 'profile' as const, name: activeProfile.name }; + // Detect provider from profile's baseUrl + const provider = detectProvider(activeProfile.baseUrl); + const providerLabel = getProviderLabel(provider); + return { + type: 'profile' as const, + name: activeProfile.name, + id: activeProfile.id, + baseUrl: activeProfile.baseUrl, + createdAt: activeProfile.createdAt, + provider, + providerLabel, + badgeColor: getProviderBadgeColor(provider) + }; } // Profile ID set but profile not found - fallback to OAuth - return { type: 'oauth' as const, name: 'OAuth' }; + return OAUTH_FALLBACK; } - return { type: 'oauth' as const, name: 'OAuth' }; + // No active profile - using OAuth + return OAUTH_FALLBACK; }, [activeProfileId, profiles]); + // Helper function to truncate ID for display + const truncateId = (id: string): string => { + return id.slice(0, 8); + }; + + // Get localized provider label for display + // Uses type-safe mapping with fallback to getProviderLabel for unknown providers + const getLocalizedProviderLabel = (provider: ApiProvider): string => { + const translationKey = PROVIDER_TRANSLATION_KEYS[provider]; + + // If we have a translation key (including providerUnknown), use it + if (translationKey) { + const translated = t(translationKey); + // If translation returns the key itself (not found), use getProviderLabel fallback + if (translated !== translationKey) { + return translated; + } + } + + // Fallback to getProviderLabel for providers without translation keys + return getProviderLabel(provider); + }; + const isOAuth = authStatus.type === 'oauth'; const Icon = isOAuth ? Lock : Key; + // Compute once and reuse for aria-label and displayed text + const localizedProviderLabel = getLocalizedProviderLabel(authStatus.provider); return ( - - - - - - -
-
- Authentication - {isOAuth ? 'OAuth' : 'API Profile'} -
- {!isOAuth && authStatus.name && ( - <> +
+ {/* Usage Warning Badge (shown when usage >= 90%) */} + {shouldShowUsageWarning && ( + + + +
+ +
+
+ +
+
+ {t('common:usage.usageAlert')} + {Math.round(warningBadgePercent)}% +
- Using profile: {authStatus.name} + {t('common:usage.accountExceedsThreshold')}
- - )} -
- - - +
+
+
+
+ )} + + {/* Provider Badge */} + + + + + + +
+ {/* Header section */} +
+
+ + {t('common:usage.authenticationDetails')} +
+
+ {isOAuth ? t('common:usage.oauth') : t('common:usage.apiProfile')} +
+
+ + {/* Provider info */} +
+
+ + {t('common:usage.provider')} +
+ {localizedProviderLabel} +
+ + {/* Profile details for API profiles */} + {!isOAuth && ( + <> +
+ {/* Profile name with icon */} +
+
+ + {t('common:usage.profile')} +
+ {authStatus.name} +
+ + {/* Profile ID with icon */} +
+
+ + {t('common:usage.id')} +
+ + {truncateId(authStatus.id)} + +
+ + {/* API Endpoint with better styling */} + {authStatus.baseUrl && ( +
+
+ + {t('common:usage.apiEndpoint')} +
+
+ {authStatus.baseUrl} +
+
+ )} +
+ + )} +
+
+
+
+ + {/* 5 Hour Usage Badge (shown when session usage >= 90%) */} + {usage && !isLoadingUsage && usage.sessionPercent >= 90 && ( + + + +
+ {Math.round(usage.sessionPercent)}% +
+
+ +
+
+ {localizeUsageWindowLabel(usage?.usageWindows?.sessionWindowLabel, t)} + {Math.round(usage.sessionPercent)}% +
+ {sessionResetTime && ( + <> +
+
+ {sessionResetTime} +
+ + )} +
+ + + + )} +
); } diff --git a/apps/frontend/src/renderer/components/ProjectTabBar.tsx b/apps/frontend/src/renderer/components/ProjectTabBar.tsx index 7836b8c7..df41baf1 100644 --- a/apps/frontend/src/renderer/components/ProjectTabBar.tsx +++ b/apps/frontend/src/renderer/components/ProjectTabBar.tsx @@ -5,6 +5,7 @@ import { cn } from '../lib/utils'; import { Button } from './ui/button'; import { SortableProjectTab } from './SortableProjectTab'; import { UsageIndicator } from './UsageIndicator'; +import { AuthStatusIndicator } from './AuthStatusIndicator'; import type { Project } from '../../shared/types'; interface ProjectTabBarProps { @@ -112,6 +113,7 @@ export function ProjectTabBar({
+ - -
- {/* Session usage */} -
-
- Session Usage - {Math.round(usage.sessionPercent)}% + +
+ {/* Header with overall status */} +
+ + {t('common:usage.usageBreakdown')} +
+ + {/* Session/5-hour usage */} +
+
+ + + {sessionLabel} + + = 95 ? 'text-red-500' : + usage.sessionPercent >= 91 ? 'text-orange-500' : + usage.sessionPercent >= 71 ? 'text-yellow-600' : + 'text-green-600' + }`}> + {Math.round(usage.sessionPercent)}% +
- {usage.sessionResetTime && ( -
- Resets: {usage.sessionResetTime} + {sessionResetTime && ( +
+ + {sessionResetTime}
)} - {/* Progress bar */} -
+ {/* Enhanced progress bar with gradient */} +
= 95 ? 'bg-red-500' : - usage.sessionPercent >= 91 ? 'bg-orange-500' : - usage.sessionPercent >= 71 ? 'bg-yellow-500' : - 'bg-green-500' + className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${ + usage.sessionPercent >= 95 ? 'bg-gradient-to-r from-red-600 to-red-500' : + usage.sessionPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' : + usage.sessionPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' : + 'bg-gradient-to-r from-green-600 to-green-500' }`} style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }} - /> + > + {/* Subtle shine effect */} +
+
-
- -
- - {/* Weekly usage */} -
-
- Weekly Usage - {Math.round(usage.weeklyPercent)}% -
- {usage.weeklyResetTime && ( -
- Resets: {usage.weeklyResetTime} + {/* Raw usage value with better styling */} + {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} +
)} - {/* Progress bar */} -
-
= 99 ? 'bg-red-500' : - usage.weeklyPercent >= 91 ? 'bg-orange-500' : - usage.weeklyPercent >= 71 ? 'bg-yellow-500' : - 'bg-green-500' - }`} - style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }} - /> -
-
+ {/* Weekly/Monthly usage */} +
+
+ + + {weeklyLabel} + + = 99 ? 'text-red-500' : + usage.weeklyPercent >= 91 ? 'text-orange-500' : + usage.weeklyPercent >= 71 ? 'text-yellow-600' : + 'text-green-600' + }`}> + {Math.round(usage.weeklyPercent)}% + +
+ {weeklyResetTime && ( +
+ + {weeklyResetTime} +
+ )} + {/* Enhanced progress bar with gradient */} +
+
= 99 ? 'bg-gradient-to-r from-red-600 to-red-500' : + usage.weeklyPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' : + usage.weeklyPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' : + 'bg-gradient-to-r from-green-600 to-green-500' + }`} + style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }} + > + {/* Subtle shine effect */} +
+
+
+ {/* Raw usage value with better styling */} + {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} + +
+ )} +
- {/* Active profile */} -
- Active Account - {usage.profileName} + {/* Active account footer */} +
+
+ + {t('common:usage.activeAccount')} +
+
+ {usage.profileName} + +
diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index a39a298d..b6582b7c 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -408,6 +408,41 @@ "scrollForMore": "Scroll for more", "allLoaded": "All issues loaded" }, + "usage": { + "dataUnavailable": "Usage data unavailable", + "dataUnavailableDescription": "The usage monitoring endpoint for this provider is not available or not supported.", + "activeAccount": "Active Account", + "usageAlert": "Usage Alert", + "accountExceedsThreshold": "Account usage exceeds 90% threshold", + "authentication": "Authentication", + "authenticationAriaLabel": "Authentication: {{provider}}", + "authenticationDetails": "Authentication Details", + "apiProfile": "API Profile", + "oauth": "OAuth", + "provider": "Provider", + "providerAnthropic": "Anthropic", + "providerZai": "z.ai", + "providerZhipu": "ZHIPU AI", + "providerUnknown": "Unknown", + "profile": "Profile", + "id": "ID", + "created": "Created", + "apiEndpoint": "API Endpoint", + "sessionQuota": "Session Quota", + "notAvailable": "N/A", + "usageStatusAriaLabel": "Usage status", + "usageBreakdown": "Usage Breakdown", + "used": "used", + "loading": "Loading...", + "sessionDefault": "Session", + "weeklyDefault": "Weekly", + "resetsInHours": "Resets in {{hours}}h {{minutes}}m", + "resetsInDays": "Resets in {{days}}d {{hours}}h", + "window5Hour": "5-hour window", + "window7Day": "7-day window", + "window5HoursQuota": "5 Hours Quota", + "windowMonthlyToolsQuota": "Monthly Tools Quota" + }, "oauth": { "enterCode": "Manual Code Entry (Fallback)", "enterCodeDescription": "This dialog is only needed if the browser didn't redirect automatically. If authentication already completed in your browser, you can close this dialog.", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index 495243a2..be83e517 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -408,6 +408,41 @@ "scrollForMore": "Défiler pour plus", "allLoaded": "Toutes les issues chargées" }, + "usage": { + "dataUnavailable": "Données d'utilisation non disponibles", + "dataUnavailableDescription": "Le point de terminaison de surveillance d'utilisation pour ce fournisseur n'est pas disponible ou n'est pas pris en charge.", + "activeAccount": "Compte actif", + "usageAlert": "Alerte d'utilisation", + "accountExceedsThreshold": "L'utilisation du compte dépasse le seuil de 90 %", + "authentication": "Authentification", + "authenticationAriaLabel": "Authentification : {{provider}}", + "authenticationDetails": "Détails de l'authentification", + "apiProfile": "Profil API", + "oauth": "OAuth", + "provider": "Fournisseur", + "providerAnthropic": "Anthropic", + "providerZai": "z.ai", + "providerZhipu": "ZHIPU AI", + "providerUnknown": "Inconnu", + "profile": "Profil", + "id": "ID", + "created": "Créé", + "apiEndpoint": "Point de terminaison API", + "sessionQuota": "Quota de session", + "notAvailable": "N/A", + "usageStatusAriaLabel": "Statut d'utilisation", + "usageBreakdown": "Répartition de l'utilisation", + "used": "utilisé", + "loading": "Chargement...", + "sessionDefault": "Session", + "weeklyDefault": "Hebdomadaire", + "resetsInHours": "Réinitialisation dans {{hours}}h {{minutes}}m", + "resetsInDays": "Réinitialisation dans {{days}}j {{hours}}h", + "window5Hour": "Fenêtre de 5 heures", + "window7Day": "Fenêtre de 7 jours", + "window5HoursQuota": "Quota de 5 heures", + "windowMonthlyToolsQuota": "Quota mensuel d'outils" + }, "oauth": { "enterCode": "Saisie manuelle du code (secours)", "enterCodeDescription": "Ce dialogue n'est nécessaire que si le navigateur n'a pas redirigé automatiquement. Si l'authentification est déjà terminée dans votre navigateur, vous pouvez fermer ce dialogue.", diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index 0922e52f..35f60f28 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -29,14 +29,29 @@ export interface ClaudeUsageData { * Returned from API or CLI usage check */ export interface ClaudeUsageSnapshot { - /** Session usage percentage (0-100) */ + /** Session usage percentage (0-100) - represents 5-hour window for most providers */ sessionPercent: number; - /** Weekly usage percentage (0-100) */ + /** Weekly usage percentage (0-100) - represents 7-day window for Anthropic, monthly for z.ai */ weeklyPercent: number; - /** When the session limit resets (human-readable or ISO) */ + /** + * When the session limit resets (human-readable or ISO) + * + * NOTE: This value may contain hardcoded English strings ('Unknown', 'Expired', 'Resets in ...') + * from the main process. Renderer components should use the sessionResetTimestamp field + * with formatTimeRemaining() to generate localized countdown text when available. + */ sessionResetTime?: string; - /** When the weekly limit resets (human-readable or ISO) */ + /** + * When the weekly limit resets (human-readable or ISO) + * + * NOTE: This value may contain hardcoded English strings ('Unknown', '1st of January', etc.) + * from the main process. Renderer components should localize these values before display. + */ weeklyResetTime?: string; + /** ISO timestamp of when the session limit resets (for dynamic countdown calculation) */ + sessionResetTimestamp?: string; + /** ISO timestamp of when the weekly limit resets (for dynamic countdown calculation) */ + weeklyResetTimestamp?: string; /** Profile ID this snapshot belongs to */ profileId: string; /** Profile name for display */ @@ -45,6 +60,21 @@ export interface ClaudeUsageSnapshot { fetchedAt: Date; /** Which limit is closest to threshold ('session' or 'weekly') */ limitType?: 'session' | 'weekly'; + /** Usage window types for this provider */ + usageWindows?: { + /** Label for the session window (e.g., '5-hour', '5-hour window') */ + sessionWindowLabel: string; + /** Label for the weekly window (e.g., '7-day', 'monthly', 'calendar month') */ + weeklyWindowLabel: string; + }; + /** Raw session usage value (e.g., tokens used) */ + sessionUsageValue?: number; + /** Session usage limit (total quota) */ + sessionUsageLimit?: number; + /** Raw weekly usage value (e.g., tools used) */ + weeklyUsageValue?: number; + /** Weekly usage limit (total quota) */ + weeklyUsageLimit?: number; } /** diff --git a/apps/frontend/src/shared/utils/format-time.ts b/apps/frontend/src/shared/utils/format-time.ts new file mode 100644 index 00000000..83568593 --- /dev/null +++ b/apps/frontend/src/shared/utils/format-time.ts @@ -0,0 +1,203 @@ +/** + * Time Formatting Utilities + * + * Shared utilities for formatting time differences and durations. + * Designed for use with i18n translation functions. + */ + +/** + * Known hardcoded English patterns from main process to filter out + * + * The main process may send these sentinel values when time data is unavailable. + * This helper is used to filter them out before displaying to users. + * + * @param text - The text to check + * @returns true if text is a hardcoded sentinel value (undefined, null, 'Unknown', 'Expired', or whitespace-only) + * + * @example + * hasHardcodedText('Unknown') // true + * hasHardcodedText('Expired') // true + * hasHardcodedText(' ') // true (whitespace-only) + * hasHardcodedText('Resets in 2h') // false + */ +export function hasHardcodedText(text?: string | null): boolean { + // Trim whitespace before checking - whitespace-only strings are treated as empty + const trimmed = text?.trim(); + return !trimmed || trimmed === 'Unknown' || trimmed === 'Expired'; +} + +/** + * Translation key mapping for backend usage window labels + * Maps backend-provided English strings to i18n translation keys + */ +const USAGE_WINDOW_LABEL_MAP: Readonly> = { + '5-hour window': 'window5Hour', + '7-day window': 'window7Day', + '5 Hours Quota': 'window5HoursQuota', + 'Monthly Tools Quota': 'windowMonthlyToolsQuota' +} as const; + +/** + * Map backend-provided usage window labels to localized translation keys + * + * The backend now provides i18n translation keys like "common:usage.window5Hour". + * For backward compatibility, also handles legacy English strings like "5-hour window". + * + * @param backendLabel - The translation key or legacy English label from the backend API + * @param t - i18next translation function + * @param defaultKey - Optional default translation key (default: 'common:usage.sessionDefault') + * @returns Localized label string + * + * @example + * localizeUsageWindowLabel('common:usage.window5Hour', t) + * // Returns: t('common:usage.window5Hour') → "5-hour window" (en) or localized equivalent + * + * @example + * // Legacy backward compatibility + * localizeUsageWindowLabel('5-hour window', t) + * // Returns: t('common:usage.window5Hour') → "5-hour window" (en) or localized equivalent + * + * @example + * localizeUsageWindowLabel('Unknown Label', t, 'common:usage.weeklyDefault') + * // Returns: t('common:usage.weeklyDefault') → localized fallback, not the raw backend label + */ +export function localizeUsageWindowLabel( + backendLabel: string | undefined, + t: (key: string, params?: Record) => string, + defaultKey: string = 'common:usage.sessionDefault' +): string { + if (!backendLabel) return t(defaultKey); + + // Check if backendLabel is already a translation key (contains colon) + // New format: backend sends "common:usage.window5Hour" directly + if (backendLabel.includes(':')) { + const translated = t(backendLabel); + // If translation returns the key itself (not found), use default + return translated === backendLabel ? t(defaultKey) : translated; + } + + // Legacy backward compatibility: map old hardcoded English strings to translation keys + const translationKey = USAGE_WINDOW_LABEL_MAP[backendLabel]; + if (translationKey) { + const translated = t(`common:usage.${translationKey}`); + // If translation returns the key itself (not found), use backend label as fallback + return translated === `common:usage.${translationKey}` ? backendLabel : translated; + } + + // Unknown label - use localized default instead of raw backend text + return t(defaultKey); +} + +export interface FormatTimeRemainingOptions { + /** Translation key for hours/minutes format (default: 'common:usage.resetsInHours') */ + hoursKey?: string; + /** Translation key for days/hours format (default: 'common:usage.resetsInDays') */ + daysKey?: string; +} + +/** + * Format a timestamp as a human-readable "time remaining" string + * + * Calculates the time difference between the given timestamp and now, + * then formats it using the provided translation function. + * + * @param timestamp - ISO timestamp string to format + * @param t - i18next translation function + * @param options - Optional configuration + * @returns Formatted time string, or undefined if timestamp is invalid + * + * @example + * formatTimeRemaining('2025-01-20T15:00:00Z', t) + * // Returns: "Resets in 2h 30m" or "Resets in 3d 5h" depending on time difference + * + * @example + * formatTimeRemaining('2025-01-20T15:00:00Z', t, { + * hoursKey: 'common:usage.resetsInHours', + * daysKey: 'common:usage.resetsInDays' + * }) + */ +export function formatTimeRemaining( + timestamp: string | undefined, + t: (key: string, params?: Record) => string, + options: FormatTimeRemainingOptions = {} +): string | undefined { + if (!timestamp) return undefined; + + const { hoursKey = 'common:usage.resetsInHours', daysKey = 'common:usage.resetsInDays' } = options; + + try { + const date = new Date(timestamp); + + // Handle invalid dates (isNaN check before using getTime()) + if (isNaN(date.getTime())) return undefined; + + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + + // Handle past dates + if (diffMs < 0) { + // Return undefined for past dates - caller can provide fallback + return undefined; + } + + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (diffHours < 24) { + return t(hoursKey, { hours: diffHours, minutes: diffMins }); + } + + const diffDays = Math.floor(diffHours / 24); + const remainingHours = diffHours % 24; + return t(daysKey, { days: diffDays, hours: remainingHours }); + } catch (_error) { + return undefined; + } +} + +/** + * Simple time formatting for main process (no i18n) + * + * Used in usage-monitor.ts for backend time formatting. + * Returns simple "2h 30m" or "3d 5h" format. + * + * NOTE: This function returns hardcoded English strings ('Unknown', 'Expired') + * because i18n is not available in the main process. These sentinel values + * flow into ClaudeUsageSnapshot and should be replaced with localized text + * in the renderer process before displaying to users. + * + * FUTURE: Consider returning structured data (e.g., { status: 'unknown' }) + * instead of strings to allow renderer-side localization. + * + * @param timestamp - ISO timestamp string + * @returns Formatted time string, or 'Unknown'/'Expired' for special cases + */ +export function formatTimeRemainingSimple(timestamp: string | undefined): string { + if (!timestamp) return 'Unknown'; + + try { + const date = new Date(timestamp); + + // Handle invalid dates + if (isNaN(date.getTime())) return 'Unknown'; + + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + + // Handle past dates + if (diffMs < 0) return 'Expired'; + + 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 'Unknown'; + } +} diff --git a/apps/frontend/src/shared/utils/provider-detection.test.ts b/apps/frontend/src/shared/utils/provider-detection.test.ts new file mode 100644 index 00000000..b7e443b8 --- /dev/null +++ b/apps/frontend/src/shared/utils/provider-detection.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for provider detection utilities + */ + +import { describe, it, expect } from 'vitest'; +import { detectProvider, getProviderLabel, getProviderBadgeColor } from './provider-detection'; + +describe('provider-detection', () => { + describe('detectProvider', () => { + describe('Anthropic provider', () => { + it('should detect Anthropic from api.anthropic.com', () => { + const result = detectProvider('https://api.anthropic.com'); + expect(result).toBe('anthropic'); + }); + + it('should detect Anthropic with path', () => { + const result = detectProvider('https://api.anthropic.com/v1/messages'); + expect(result).toBe('anthropic'); + }); + + it('should handle subdomain of Anthropic correctly', () => { + const result = detectProvider('https://sub.api.anthropic.com'); + expect(result).toBe('anthropic'); + }); + }); + + describe('z.ai provider', () => { + it('should detect z.ai from api.z.ai', () => { + const result = detectProvider('https://api.z.ai/api/anthropic'); + expect(result).toBe('zai'); + }); + + it('should detect z.ai from z.ai domain', () => { + const result = detectProvider('https://z.ai/api/anthropic'); + expect(result).toBe('zai'); + }); + }); + + describe('ZHIPU provider', () => { + it('should detect ZHIPU from open.bigmodel.cn', () => { + const result = detectProvider('https://open.bigmodel.cn/api/paas/v4'); + expect(result).toBe('zhipu'); + }); + + it('should detect ZHIPU from dev.bigmodel.cn', () => { + const result = detectProvider('https://dev.bigmodel.cn/api/paas/v4'); + expect(result).toBe('zhipu'); + }); + + it('should detect ZHIPU from bigmodel.cn', () => { + const result = detectProvider('https://bigmodel.cn/api/paas/v4'); + expect(result).toBe('zhipu'); + }); + }); + + describe('Unknown provider', () => { + it('should return unknown for unrecognized domain', () => { + const result = detectProvider('https://unknown.com/api'); + expect(result).toBe('unknown'); + }); + + it('should handle invalid URL gracefully', () => { + const result = detectProvider('not-a-url'); + expect(result).toBe('unknown'); + }); + }); + }); + + describe('getProviderLabel', () => { + it('should return correct label for Anthropic', () => { + expect(getProviderLabel('anthropic')).toBe('Anthropic'); + }); + + it('should return correct label for z.ai', () => { + expect(getProviderLabel('zai')).toBe('z.ai'); + }); + + it('should return correct label for ZHIPU', () => { + expect(getProviderLabel('zhipu')).toBe('ZHIPU AI'); + }); + + it('should return Unknown for unknown provider', () => { + expect(getProviderLabel('unknown')).toBe('Unknown'); + }); + }); + + describe('getProviderBadgeColor', () => { + it('should return orange colors for Anthropic', () => { + const color = getProviderBadgeColor('anthropic'); + expect(color).toContain('orange'); + expect(color).toContain('bg-orange-500/10'); + expect(color).toContain('text-orange-500'); + expect(color).toContain('border-orange-500/20'); + }); + + it('should return blue colors for z.ai', () => { + const color = getProviderBadgeColor('zai'); + expect(color).toContain('blue'); + expect(color).toContain('bg-blue-500/10'); + expect(color).toContain('text-blue-500'); + expect(color).toContain('border-blue-500/20'); + }); + + it('should return purple colors for ZHIPU', () => { + const color = getProviderBadgeColor('zhipu'); + expect(color).toContain('purple'); + expect(color).toContain('bg-purple-500/10'); + expect(color).toContain('text-purple-500'); + expect(color).toContain('border-purple-500/20'); + }); + + it('should return gray colors for unknown', () => { + const color = getProviderBadgeColor('unknown'); + expect(color).toContain('gray'); + expect(color).toContain('bg-gray-500/10'); + expect(color).toContain('text-gray-500'); + expect(color).toContain('border-gray-500/20'); + }); + }); +}); diff --git a/apps/frontend/src/shared/utils/provider-detection.ts b/apps/frontend/src/shared/utils/provider-detection.ts new file mode 100644 index 00000000..36737ef7 --- /dev/null +++ b/apps/frontend/src/shared/utils/provider-detection.ts @@ -0,0 +1,112 @@ +/** + * Provider Detection Utilities + * + * Detects API provider type from baseUrl patterns. + * Mirrors the logic from usage-monitor.ts for use in renderer process. + * + * NOTE: Keep this in sync with usage-monitor.ts provider detection logic + */ + +/** + * API Provider type for usage monitoring + * Determines which usage endpoint to query and how to normalize responses + */ +export type ApiProvider = 'anthropic' | 'zai' | 'zhipu' | 'unknown'; + +/** + * Provider detection patterns + * Maps baseUrl patterns to provider types + */ +interface ProviderPattern { + provider: ApiProvider; + domainPatterns: string[]; +} + +const PROVIDER_PATTERNS: readonly ProviderPattern[] = [ + { + provider: 'anthropic', + domainPatterns: ['api.anthropic.com'] + }, + { + provider: 'zai', + domainPatterns: ['api.z.ai', 'z.ai'] + }, + { + provider: 'zhipu', + domainPatterns: ['open.bigmodel.cn', 'dev.bigmodel.cn', 'bigmodel.cn'] + } +] as const; + +/** + * 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 { + try { + // Extract domain from URL + const url = new URL(baseUrl); + const domain = url.hostname; + + // Match against provider patterns + for (const pattern of PROVIDER_PATTERNS) { + for (const patternDomain of pattern.domainPatterns) { + if (domain === patternDomain || domain.endsWith(`.${patternDomain}`)) { + return pattern.provider; + } + } + } + + // No match found + return 'unknown'; + } catch (_error) { + // Invalid URL format + return 'unknown'; + } +} + +/** + * Get human-readable provider label + * + * @param provider - The provider type + * @returns Display label for the provider + */ +export function getProviderLabel(provider: ApiProvider): string { + switch (provider) { + case 'anthropic': + return 'Anthropic'; + case 'zai': + return 'z.ai'; + case 'zhipu': + return 'ZHIPU AI'; + case 'unknown': + return 'Unknown'; + } +} + +/** + * Get provider badge color scheme + * + * @param provider - The provider type + * @returns CSS classes for badge styling + */ +export function getProviderBadgeColor(provider: ApiProvider): string { + switch (provider) { + case 'anthropic': + return 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'; + case 'zai': + return 'bg-blue-500/10 text-blue-500 border-blue-500/20 hover:bg-blue-500/15'; + case 'zhipu': + return 'bg-purple-500/10 text-purple-500 border-purple-500/20 hover:bg-purple-500/15'; + case 'unknown': + return 'bg-gray-500/10 text-gray-500 border-gray-500/20 hover:bg-gray-500/15'; + } +} diff --git a/implementation_plan.json b/implementation_plan.json index ae64097a..1ae75bfe 100644 --- a/implementation_plan.json +++ b/implementation_plan.json @@ -1,30 +1,81 @@ { - "spec_id": "025-improving-task-card-title-readability", + "spec_id": "045-add-api-profile-providers-usage-endpoints-support-", "subtasks": [ { "id": "1", - "title": "Restructure TaskCard header: Remove flex wrapper around title, make title standalone with full width", + "title": "Implement provider detection from baseUrl (Anthropic, z.ai, ZHIPU)", "status": "completed" }, { "id": "2", - "title": "Relocate status badges from header to metadata section", + "title": "Implement usage endpoint routing based on provider type", "status": "completed" }, { "id": "3", - "title": "Add localization for security severity badge label", + "title": "Implement response normalization for z.ai quota/limit endpoint", + "status": "completed" + }, + { + "id": "4", + "title": "Implement response normalization for ZHIPU quota/limit endpoint", + "status": "completed" + }, + { + "id": "5", + "title": "Implement authentication handling for API profiles (apiKey vs OAuth token)", "status": "completed" } ], "qa_signoff": { "status": "fixes_applied", - "timestamp": "2026-01-01T11:58:40Z", - "fix_session": 1, + "timestamp": "2026-01-18T01:30:00Z", + "fix_session": 5, "issues_fixed": [ { - "title": "Missing localization for hardcoded 'severity' string in TaskCard", - "fix_commit": "de0c8e4" + "title": "Usage values not displaying correctly for z.ai and ZHIPU providers", + "fix_commit": "df81cca8", + "description": "Changed from model-usage endpoint to quota/limit endpoint and updated response parsing to extract limits array" + }, + { + "title": "Usage labels and reset times not user-friendly", + "fix_commit": "6331d11c", + "description": "Updated session label to '5 Hours Quota', weekly label to 'Total Monthly Tools Quota', calculated actual reset times for 5-hour window, and formatted monthly reset as '1st of '" + }, + { + "title": "Additional percentage display needs to be removed", + "fix_commit": "94afd21e", + "description": "Removed percentage text from usage warning badge; now only shows AlertTriangle icon with percentage in tooltip" + }, + { + "title": "Countdown timer needs to move to right of usage badge", + "fix_commit": "94afd21e", + "description": "Moved countdown timer from tooltip to visible blue badge positioned to the right of provider badge" + }, + { + "title": "Duplicate 'Resets:' word in tooltip", + "fix_commit": "037fa6a1", + "description": "Removed duplicate 'Resets:' prefix from tooltips in UsageIndicator and AuthStatusIndicator components" + }, + { + "title": "Monthly Tools badge should show 5 hour usage instead", + "fix_commit": "037fa6a1", + "description": "Replaced countdown timer badge with 5 hour usage badge that shows session percentage" + }, + { + "title": "5 hour usage badge should only show when >= 90% and in red", + "fix_commit": "037fa6a1", + "description": "Badge is hidden until session usage reaches 90% threshold, then displays in red with percentage" + }, + { + "title": "Time synchronization issue with reset countdown", + "fix_commit": "037fa6a1", + "description": "Store ISO timestamps and calculate relative time dynamically in UI instead of at fetch time, ensuring countdown stays accurate" + }, + { + "title": "5-hour window reset time showing duration from start instead of time remaining", + "fix_commit": "52b53f83", + "description": "Fixed sessionResetTimestamp calculation to align with 5-hour interval boundaries (0:00, 5:00, 10:00, 15:00, 20:00) instead of just next hour. The tooltip now correctly shows time remaining until the window resets. Verified >=90% badge is based on actual usage percentage from API, not time-based calculation." } ], "ready_for_qa_revalidation": true