diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 7dc83bac..4d9cca42 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -20,8 +20,10 @@ import type { ClaudeProfileSettings, ClaudeUsageData, ClaudeRateLimitEvent, - ClaudeAutoSwitchSettings + ClaudeAutoSwitchSettings, + APIProfile } from '../shared/types'; +import type { UnifiedAccount } from '../shared/types/unified-account'; // Module imports import { encryptToken, decryptToken } from './claude-profile/token-encryption'; @@ -40,9 +42,11 @@ import { import { getBestAvailableProfile, shouldProactivelySwitch as shouldProactivelySwitchImpl, - getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl + getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl, + getBestAvailableUnifiedAccount } from './claude-profile/profile-scorer'; import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils'; +import { loadProfilesFile } from './services/profile/profile-manager'; import { CLAUDE_PROFILES_DIR, generateProfileId as generateProfileIdImpl, @@ -666,6 +670,57 @@ export class ClaudeProfileManager { return getBestAvailableProfile(this.data.profiles, settings, excludeProfileId, priorityOrder); } + /** + * Load API profiles from profiles.json with error handling + * Shared helper to avoid duplication across methods + */ + private async loadProfilesFileSafe(): Promise<{ profiles: APIProfile[]; activeProfileId?: string }> { + try { + const file = await loadProfilesFile(); + return { profiles: file.profiles, activeProfileId: file.activeProfileId ?? undefined }; + } catch (error) { + console.error('[ClaudeProfileManager] Failed to load profiles file:', error); + return { profiles: [] }; + } + } + + /** + * Load API profiles from profiles.json + * Used by the unified account selection to consider API profiles as fallback + */ + async loadAPIProfiles(): Promise { + const { profiles } = await this.loadProfilesFileSafe(); + return profiles; + } + + /** + * Get the best available unified account from both OAuth and API profiles + * This enables cross-type account switching when OAuth profiles are exhausted + * + * @param excludeAccountId - Unified account ID to exclude (e.g., 'oauth-profile1') + * @returns The best available UnifiedAccount, or null if none available + */ + async getBestAvailableUnifiedAccount(excludeAccountId?: string): Promise { + const settings = this.getAutoSwitchSettings(); + const priorityOrder = this.getAccountPriorityOrder(); + const activeOAuthId = this.data.activeProfileId; + + // Load API profiles and active API profile ID from profiles.json + const { profiles: apiProfiles, activeProfileId: activeAPIId } = await this.loadProfilesFileSafe(); + + return getBestAvailableUnifiedAccount( + this.data.profiles, + apiProfiles, + settings, + { + excludeAccountId, + priorityOrder, + activeOAuthId, + activeAPIId + } + ); + } + /** * Determine if we should proactively switch profiles based on current usage */ diff --git a/apps/frontend/src/main/claude-profile/profile-scorer.ts b/apps/frontend/src/main/claude-profile/profile-scorer.ts index 6498a04c..1428df74 100644 --- a/apps/frontend/src/main/claude-profile/profile-scorer.ts +++ b/apps/frontend/src/main/claude-profile/profile-scorer.ts @@ -10,9 +10,20 @@ * - Must be below user's configured thresholds (default: 95% session, 99% weekly) * 3. First profile in priority order that passes all filters is selected * 4. If no profile passes all filters, falls back to "least bad" option + * + * v3 Enhancement: Unified Account Support + * - Supports both OAuth profiles (ClaudeProfile) and API profiles (APIProfile) + * - API profiles are always considered available (hasUnlimitedUsage = true) + * - Unified selection algorithm considers both types in priority order */ -import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types'; +import type { ClaudeProfile, ClaudeAutoSwitchSettings, APIProfile } from '../../shared/types'; +import type { UnifiedAccount } from '../../shared/types/unified-account'; +import { + claudeProfileToUnified, + apiProfileToUnified, + OAUTH_ID_PREFIX +} from '../../shared/utils/unified-account'; import { isProfileRateLimited } from './rate-limit-manager'; import { isProfileAuthenticated } from './profile-utils'; @@ -121,6 +132,236 @@ function calculateFallbackScore( return score; } +// ============================================ +// Unified Account Scoring (v3) +// ============================================ + +interface ScoredUnifiedAccount { + account: UnifiedAccount; + score: number; + priorityIndex: number; + isAvailable: boolean; + unavailableReason?: string; +} + +/** + * Options for unified account selection + */ +export interface UnifiedAccountSelectionOptions { + /** Unified account ID to exclude (usually the current/failing one) */ + excludeAccountId?: string; + /** User's configured priority order (array of unified IDs) */ + priorityOrder?: string[]; + /** Currently active OAuth profile ID (if any) */ + activeOAuthId?: string; + /** Currently active API profile ID (if any) */ + activeAPIId?: string; +} + +/** + * Score a single unified account for availability + * + * @param account - The unified account to score + * @param priorityIndex - Index in the user's priority order (lower = higher priority) + * @param settings - Auto-switch settings containing usage thresholds + */ +function scoreUnifiedAccount( + account: UnifiedAccount, + priorityIndex: number, + settings: ClaudeAutoSwitchSettings +): ScoredUnifiedAccount { + let score = 100; + let unavailableReason: string | undefined; + let isOverThreshold = false; + + // For API profiles: simple availability check + if (account.type === 'api') { + if (!account.isAuthenticated) { + score = -1000; + unavailableReason = 'API key not validated'; + } else if (!account.isAvailable) { + score = -500; + unavailableReason = 'not available'; + } + // API profiles with valid auth get high scores (no usage limits) + + return { + account, + score, + priorityIndex, + isAvailable: score > 0, + unavailableReason + }; + } + + // For OAuth profiles: detailed scoring with threshold enforcement + if (!account.isAuthenticated) { + score = -1000; + unavailableReason = 'not authenticated'; + } else if (account.isRateLimited) { + if (account.rateLimitType === 'weekly') { + score = -500; + } else { + score = -200; + } + unavailableReason = `rate limited (${account.rateLimitType || 'unknown'})`; + } else { + // Check usage thresholds (matching checkProfileAvailability behavior) + if (account.weeklyPercent !== undefined && account.weeklyPercent >= settings.weeklyThreshold) { + isOverThreshold = true; + unavailableReason = `weekly usage ${account.weeklyPercent}% >= threshold ${settings.weeklyThreshold}%`; + } else if (account.sessionPercent !== undefined && account.sessionPercent >= settings.sessionThreshold) { + isOverThreshold = true; + unavailableReason = `session usage ${account.sessionPercent}% >= threshold ${settings.sessionThreshold}%`; + } + + // Apply proportional penalties for high usage (even if not over threshold) + if (account.weeklyPercent !== undefined) { + score -= account.weeklyPercent * 0.3; + } + if (account.sessionPercent !== undefined) { + score -= account.sessionPercent * 0.1; + } + } + + return { + account, + score, + priorityIndex, + isAvailable: score > 0 && account.isAuthenticated === true && !account.isRateLimited && !isOverThreshold, + unavailableReason + }; +} + +/** + * Get the best unified account from both OAuth and API profiles + * + * Selection Logic: + * 1. Convert all profiles to UnifiedAccount format + * 2. Sort by user's priority order + * 3. Filter by availability + * 4. Return first available account in priority order + * 5. If none available, return the "least bad" option + * + * @param oauthProfiles - All OAuth (Claude) profiles + * @param apiProfiles - All API profiles + * @param settings - Auto-switch settings (contains thresholds for OAuth) + * @param options - Optional configuration for selection + */ +export function getBestAvailableUnifiedAccount( + oauthProfiles: ClaudeProfile[], + apiProfiles: APIProfile[], + settings: ClaudeAutoSwitchSettings, + options: UnifiedAccountSelectionOptions = {} +): UnifiedAccount | null { + const { excludeAccountId, priorityOrder = [], activeOAuthId, activeAPIId } = options; + // Convert all profiles to unified format + const unifiedAccounts: UnifiedAccount[] = []; + + // Convert OAuth profiles + for (const profile of oauthProfiles) { + const isActive = profile.id === activeOAuthId; + const rateLimitStatus = isProfileRateLimited(profile); + // Compute authentication status - profile.isAuthenticated may not be set on raw profiles + const isAuthenticated = isProfileAuthenticated(profile); + + unifiedAccounts.push(claudeProfileToUnified(profile, isActive, { + isRateLimited: rateLimitStatus.limited, + rateLimitType: rateLimitStatus.type, + isAuthenticated + })); + } + + // Convert API profiles + for (const profile of apiProfiles) { + const isActive = profile.id === activeAPIId; + // TODO: API profiles are considered authenticated if they have an API key. + // Add validation tracking to distinguish "has key" from "key is confirmed valid". + const isAuthenticated = !!profile.apiKey; + unifiedAccounts.push(apiProfileToUnified(profile, isActive, isAuthenticated)); + } + + // Filter out excluded account + const candidates = unifiedAccounts.filter(a => a.id !== excludeAccountId); + + if (candidates.length === 0) { + return null; + } + + if (isDebug) { + console.warn('[ProfileScorer] Evaluating', candidates.length, 'candidate accounts (excluding:', excludeAccountId, ')'); + console.warn('[ProfileScorer] Priority order:', priorityOrder); + console.warn('[ProfileScorer] OAuth thresholds: session =', settings.sessionThreshold, '%, weekly =', settings.weeklyThreshold, '%'); + } + + // Score and check availability for each account + const scoredAccounts: ScoredUnifiedAccount[] = candidates.map(account => { + const priorityIndex = priorityOrder.indexOf(account.id); + const scored = scoreUnifiedAccount(account, priorityIndex === -1 ? Infinity : priorityIndex, settings); + + if (isDebug) { + console.warn('[ProfileScorer] Scoring account:', account.displayName, '(', account.id, ')'); + console.warn('[ProfileScorer] Type:', account.type); + console.warn('[ProfileScorer] Priority index:', priorityIndex === -1 ? 'not in list (Infinity)' : priorityIndex); + console.warn('[ProfileScorer] Available:', scored.isAvailable, scored.unavailableReason ? `(${scored.unavailableReason})` : ''); + if (account.type === 'oauth') { + console.warn('[ProfileScorer] Usage:', `session=${account.sessionPercent}%, weekly=${account.weeklyPercent}%`); + } + console.warn('[ProfileScorer] Score:', scored.score); + } + + return scored; + }); + + // Sort by: + // 1. Available accounts first + // 2. Within available: by priority index (lower = higher priority) + // 3. Within unavailable: by score (higher = better, for "least bad" selection) + scoredAccounts.sort((a, b) => { + // Available accounts always come first + if (a.isAvailable !== b.isAvailable) { + return a.isAvailable ? -1 : 1; + } + + // For available accounts, sort by priority order + if (a.isAvailable && b.isAvailable) { + if (a.priorityIndex !== b.priorityIndex) { + return a.priorityIndex - b.priorityIndex; + } + // Tiebreaker: prefer higher score + return b.score - a.score; + } + + // For unavailable accounts, sort by score (for "least bad" selection) + return b.score - a.score; + }); + + const best = scoredAccounts[0]; + + if (best.isAvailable) { + if (isDebug) { + console.warn('[ProfileScorer] Best available account:', best.account.displayName, + '(type:', best.account.type, ', priority index:', best.priorityIndex, ')'); + } + return best.account; + } + + // No account meets all criteria - check if we should return the least bad option + if (best.score > 0) { + if (isDebug) { + console.warn('[ProfileScorer] No ideal account available, using least-bad option:', best.account.displayName, + '(type:', best.account.type, ', score:', best.score, ', reason:', best.unavailableReason, ')'); + } + return best.account; + } + + // All accounts are truly unusable + if (isDebug) { + console.warn('[ProfileScorer] No usable account available, all have issues'); + } + return null; +} + /** * Get the best profile to switch to based on priority order and availability * @@ -157,7 +398,7 @@ export function getBestAvailableProfile( // Score and check availability for each profile const scoredProfiles: ScoredProfile[] = candidates.map(profile => { - const unifiedId = `oauth-${profile.id}`; + const unifiedId = `${OAUTH_ID_PREFIX}${profile.id}`; const priorityIndex = priorityOrder.indexOf(unifiedId); const availability = checkProfileAvailability(profile, settings); const fallbackScore = calculateFallbackScore(profile, settings); diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 150d8e56..07003074 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -1961,14 +1961,17 @@ export class UsageMonitor extends EventEmitter { this.clearProfileUsageCache(currentProfileId); // Switch to the new profile + // Note: bestAccount.id is already the raw profile ID (not unified format) + const rawProfileId = bestAccount.id; + if (bestAccount.type === 'oauth') { // Switch OAuth profile via profile manager - profileManager.setActiveProfile(bestAccount.id); + profileManager.setActiveProfile(rawProfileId); } else { // Switch API profile via profile-manager service try { const { setActiveAPIProfile } = await import('../services/profile/profile-manager'); - await setActiveAPIProfile(bestAccount.id); + await setActiveAPIProfile(rawProfileId); } catch (error) { console.error('[UsageMonitor] Failed to set active API profile:', error); return; diff --git a/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.ts b/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.ts index 2aa7e08e..b3905472 100644 --- a/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/queue-routing-handlers.ts @@ -3,12 +3,17 @@ * * Handles IPC communication for the rate limit recovery queue routing system. * Provides profile-aware task distribution to enable overnight autonomous operation. + * + * v3 Enhancement: Unified Account Support + * - Supports both OAuth profiles and API profiles in unified selection + * - New QUEUE_GET_BEST_UNIFIED_ACCOUNT handler for cross-type account switching */ import { ipcMain, BrowserWindow } from 'electron'; import { IPC_CHANNELS } from '../../shared/constants'; import type { AgentManager } from '../agent/agent-manager'; import type { ProfileAssignmentReason, RunningTasksByProfile, ClaudeProfile } from '../../shared/types'; +import type { UnifiedAccount } from '../../shared/types/unified-account'; import type { ClaudeProfileManager } from '../claude-profile-manager'; /** @@ -93,6 +98,61 @@ export function registerQueueRoutingHandlers( } ); + // Get best unified account for a task (OAuth + API profiles) + ipcMain.handle( + IPC_CHANNELS.QUEUE_GET_BEST_UNIFIED_ACCOUNT, + async ( + _event, + options?: { + excludeAccountId?: string; + } + ): Promise<{ success: boolean; data?: UnifiedAccount | null; error?: string }> => { + try { + // If no profile manager is available, return null (no preference) + if (!profileManager) { + console.log('[QueueRouting] Profile manager not available, returning null'); + return { success: true, data: null }; + } + + // Get auto-switch settings to check if enabled + const settings = profileManager.getAutoSwitchSettings(); + + // If auto-switching is disabled, return null (no preference) + if (!settings.enabled) { + console.log('[QueueRouting] Auto-switching disabled, returning null'); + return { success: true, data: null }; + } + + // Use getBestAvailableUnifiedAccount which handles: + // - User's configured priority order + // - OAuth profiles (with usage thresholds) + // - API profiles (always available if authenticated) + const bestAccount = await profileManager.getBestAvailableUnifiedAccount( + options?.excludeAccountId + ); + + if (bestAccount) { + console.log('[QueueRouting] Best unified account selected:', { + accountId: bestAccount.id, + accountName: bestAccount.displayName, + accountType: bestAccount.type, + excludedId: options?.excludeAccountId + }); + } else { + console.log('[QueueRouting] No suitable unified account found for task routing'); + } + + return { success: true, data: bestAccount }; + } catch (error) { + console.error('[QueueRouting] Failed to get best unified account for task:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + // Assign a profile to a task ipcMain.handle( IPC_CHANNELS.QUEUE_ASSIGN_PROFILE_TO_TASK, diff --git a/apps/frontend/src/preload/api/queue-api.ts b/apps/frontend/src/preload/api/queue-api.ts index cf98c3e2..ac9e28e3 100644 --- a/apps/frontend/src/preload/api/queue-api.ts +++ b/apps/frontend/src/preload/api/queue-api.ts @@ -13,6 +13,7 @@ import type { ProfileAssignmentReason, ProfileSwapRecord, } from '../../shared/types'; +import type { UnifiedAccount } from '../../shared/types/unified-account'; /** * Result of best profile selection for a task @@ -37,6 +38,14 @@ export interface GetBestProfileOptions { profileThreshold?: number; } +/** + * Options for getting the best unified account for a task + */ +export interface GetBestUnifiedAccountOptions { + /** Unified account ID to exclude (e.g., 'oauth-profile1' or 'api-profile2') */ + excludeAccountId?: string; +} + /** * Profile swap notification event payload */ @@ -68,6 +77,13 @@ export interface QueueAPI { */ getBestProfileForTask: (options?: GetBestProfileOptions) => Promise>; + /** + * Get the best available unified account for a new task + * Considers both OAuth profiles and API profiles in unified selection + * Used for cross-type account switching when OAuth profiles are exhausted + */ + getBestUnifiedAccount: (options?: GetBestUnifiedAccountOptions) => Promise>; + /** * Assign a profile to a task * Called when a task is started or when profile is swapped @@ -118,6 +134,9 @@ export const createQueueAPI = (): QueueAPI => ({ getBestProfileForTask: (options?: GetBestProfileOptions): Promise> => ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK, options), + getBestUnifiedAccount: (options?: GetBestUnifiedAccountOptions): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_BEST_UNIFIED_ACCOUNT, options), + assignProfileToTask: ( taskId: string, profileId: string, diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index abe46ad0..e933054e 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -248,6 +248,7 @@ const browserMockAPI: ElectronAPI = { queue: { getRunningTasksByProfile: async () => ({ success: true, data: { byProfile: {}, totalRunning: 0 } }), getBestProfileForTask: async () => ({ success: true, data: null }), + getBestUnifiedAccount: async () => ({ success: true, data: null }), assignProfileToTask: async () => ({ success: true }), updateTaskSession: async () => ({ success: true }), getTaskSession: async () => ({ success: true, data: null }), diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 2727a3b7..4c628608 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -577,6 +577,7 @@ export const IPC_CHANNELS = { // Queue routing (rate limit recovery) QUEUE_GET_RUNNING_TASKS_BY_PROFILE: 'queue:getRunningTasksByProfile', QUEUE_GET_BEST_PROFILE_FOR_TASK: 'queue:getBestProfileForTask', + QUEUE_GET_BEST_UNIFIED_ACCOUNT: 'queue:getBestUnifiedAccount', // Unified OAuth + API account selection QUEUE_ASSIGN_PROFILE_TO_TASK: 'queue:assignProfileToTask', QUEUE_UPDATE_TASK_SESSION: 'queue:updateTaskSession', QUEUE_GET_TASK_SESSION: 'queue:getTaskSession', diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts index b42b614f..f40b2748 100644 --- a/apps/frontend/src/shared/types/index.ts +++ b/apps/frontend/src/shared/types/index.ts @@ -12,6 +12,7 @@ export * from './kanban'; export * from './terminal'; export * from './agent'; export * from './profile'; +export * from './unified-account'; export * from './settings'; export * from './changelog'; export * from './insights'; diff --git a/apps/frontend/src/shared/types/unified-account.ts b/apps/frontend/src/shared/types/unified-account.ts new file mode 100644 index 00000000..668f8efd --- /dev/null +++ b/apps/frontend/src/shared/types/unified-account.ts @@ -0,0 +1,85 @@ +/** + * Unified Account Types + * + * Types for representing both OAuth accounts and API profiles in a unified format. + * Used by the priority list to display and manage all accounts in a single interface. + * + * For conversion utilities and helper functions, see shared/utils/unified-account.ts + */ + +/** + * Type discriminator for unified accounts + */ +export type UnifiedAccountType = 'oauth' | 'api'; + +/** + * Type of rate limit that was hit + */ +export type RateLimitType = 'session' | 'weekly'; + +/** + * Unified account representation for the priority list. + * + * This interface provides a common format for both OAuth accounts (Claude subscriptions) + * and API profiles (custom endpoints), enabling unified display and management. + * + * Key concepts: + * - Only ONE account should have `isActive: true` at any time (the currently in-use account) + * - `isNext` indicates the fallback account that will be used next + * - Priority is determined by position in the list (index 0 = highest priority) + */ +export interface UnifiedAccount { + /** Unique identifier for this account */ + id: string; + + /** Internal name/key for the account */ + name: string; + + /** Account type discriminator */ + type: UnifiedAccountType; + + /** Human-friendly display name */ + displayName: string; + + /** email for OAuth accounts, baseUrl for API profiles */ + identifier: string; + + /** TRUE only for the ONE account currently in use */ + isActive: boolean; + + /** TRUE for the account that will be used next (first available after active) */ + isNext: boolean; + + /** Whether this account is available for use (authenticated, not rate limited) */ + isAvailable: boolean; + + /** TRUE for API profiles (pay-per-use without rate limits) */ + hasUnlimitedUsage: boolean; + + /** Session usage percentage (0-100), only for OAuth accounts */ + sessionPercent?: number; + + /** Weekly usage percentage (0-100), only for OAuth accounts */ + weeklyPercent?: number; + + /** Whether this account is currently rate limited */ + isRateLimited?: boolean; + + /** Which type of limit was hit, if rate limited */ + rateLimitType?: RateLimitType; + + /** Whether this OAuth account has valid authentication */ + isAuthenticated?: boolean; + + /** + * Set when this account has identical usage to another OAuth account. + * This may indicate the same underlying Anthropic account registered twice. + */ + isDuplicateUsage?: boolean; + + /** + * Set when this OAuth account has an invalid refresh token and needs re-authentication. + * The user should be prompted to log in again. + */ + needsReauthentication?: boolean; +} diff --git a/apps/frontend/src/shared/utils/unified-account.ts b/apps/frontend/src/shared/utils/unified-account.ts new file mode 100644 index 00000000..f1702d7c --- /dev/null +++ b/apps/frontend/src/shared/utils/unified-account.ts @@ -0,0 +1,166 @@ +/** + * Unified Account Utilities + * + * Conversion utilities and helpers for unified account management. + * These functions convert between OAuth/API profiles and the unified account format. + */ + +import type { ClaudeProfile } from '../types/agent'; +import type { APIProfile } from '../types/profile'; +import type { UnifiedAccount, RateLimitType } from '../types/unified-account'; + +// ============================================ +// Constants +// ============================================ + +/** + * ID prefix for OAuth accounts in unified format + */ +export const OAUTH_ID_PREFIX = 'oauth-'; + +/** + * ID prefix for API accounts in unified format + */ +export const API_ID_PREFIX = 'api-'; + +// ============================================ +// Conversion Functions +// ============================================ + +/** + * Convert a ClaudeProfile (OAuth) to UnifiedAccount format + * + * @param profile - The OAuth profile to convert + * @param isActive - Whether this is the currently active account + * @param options - Additional options for conversion + * @param options.isRateLimited - Whether the profile is currently rate limited + * @param options.rateLimitType - The type of rate limit (session or weekly) + * @param options.isAuthenticated - Whether the profile is authenticated (REQUIRED - must be computed by caller) + */ +export function claudeProfileToUnified( + profile: ClaudeProfile, + isActive: boolean, + options?: { + isRateLimited?: boolean; + rateLimitType?: RateLimitType; + isAuthenticated?: boolean; + } +): UnifiedAccount { + // Check for rate limit from profile's rate limit events + const now = new Date(); + const activeRateLimit = profile.rateLimitEvents?.find(e => e.resetAt > now); + const isRateLimited = options?.isRateLimited ?? !!activeRateLimit; + // Use explicit isAuthenticated from options, falling back to profile property (which may be undefined for raw profiles) + const isAuthenticated = options?.isAuthenticated ?? profile.isAuthenticated ?? false; + + // Derive isAvailable from the computed values + const isAvailable = !!(isAuthenticated && !isRateLimited); + + return { + id: `${OAUTH_ID_PREFIX}${profile.id}`, + name: profile.name, + type: 'oauth', + displayName: profile.name, + identifier: profile.email || profile.id, + isActive, + isNext: false, // Computed later based on priority order + isAvailable, + hasUnlimitedUsage: false, // OAuth accounts have usage limits + sessionPercent: profile.usage?.sessionUsagePercent, + weeklyPercent: profile.usage?.weeklyUsagePercent, + isRateLimited, + rateLimitType: options?.rateLimitType ?? activeRateLimit?.type, + isAuthenticated, + needsReauthentication: false // Set separately if needed + }; +} + +/** + * Convert an APIProfile to UnifiedAccount format + * + * @param profile - The API profile to convert + * @param isActive - Whether this is the currently active account + * @param isAuthenticated - Whether the API key is valid (has been tested). Defaults to false for safety. + */ +export function apiProfileToUnified( + profile: APIProfile, + isActive: boolean, + isAuthenticated: boolean = false +): UnifiedAccount { + // API profiles are available if they have a valid API key + // They have unlimited usage (pay-per-use) + const isAvailable = isAuthenticated && !!profile.apiKey; + + return { + id: `${API_ID_PREFIX}${profile.id}`, + name: profile.name, + type: 'api', + displayName: profile.name, + identifier: profile.baseUrl, + isActive, + isNext: false, // Computed later based on priority order + isAvailable, + hasUnlimitedUsage: true, // API profiles are pay-per-use with no rate limits + sessionPercent: undefined, // Not applicable to API profiles + weeklyPercent: undefined, // Not applicable to API profiles + isRateLimited: false, // API profiles don't have rate limits + rateLimitType: undefined, + isAuthenticated, + needsReauthentication: false + }; +} + +// ============================================ +// ID Helper Functions +// ============================================ + +/** + * Check if a unified account ID is for an OAuth account + */ +export function isOAuthAccountId(id: string): boolean { + return id.startsWith(OAUTH_ID_PREFIX); +} + +/** + * Check if a unified account ID is for an API account + */ +export function isAPIAccountId(id: string): boolean { + return id.startsWith(API_ID_PREFIX); +} + +/** + * Extract the original profile ID from a unified account ID + */ +export function extractProfileId(unifiedId: string): string { + if (unifiedId.startsWith(OAUTH_ID_PREFIX)) { + return unifiedId.slice(OAUTH_ID_PREFIX.length); + } + if (unifiedId.startsWith(API_ID_PREFIX)) { + return unifiedId.slice(API_ID_PREFIX.length); + } + return unifiedId; +} + +/** + * Create a unified account ID from an OAuth profile ID + * Guards against double-prefixing if profileId already has the prefix + */ +export function toOAuthUnifiedId(profileId: string): string { + if (profileId.startsWith(OAUTH_ID_PREFIX)) return profileId; + if (profileId.startsWith(API_ID_PREFIX)) { + throw new Error(`Cannot convert API-prefixed ID "${profileId}" to OAuth unified ID`); + } + return `${OAUTH_ID_PREFIX}${profileId}`; +} + +/** + * Create a unified account ID from an API profile ID + * Guards against double-prefixing if profileId already has the prefix + */ +export function toAPIUnifiedId(profileId: string): string { + if (profileId.startsWith(API_ID_PREFIX)) return profileId; + if (profileId.startsWith(OAUTH_ID_PREFIX)) { + throw new Error(`Cannot convert OAuth-prefixed ID "${profileId}" to API unified ID`); + } + return `${API_ID_PREFIX}${profileId}`; +}