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