feat(profiles): implement unified profile swapping across OAuth and API accounts (#1794)
* auto-claude: subtask-1-1 - Create UnifiedAccount type in shared/types - Add unified-account.ts with UnifiedAccount interface - Extract type from AccountPriorityList.tsx for reusability - Add JSDoc documentation for all fields - Export new types from index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(profiles): implement unified profile swapping across OAuth and API accounts Implements cross-type account switching between OAuth profiles (Claude Code subscription) and API profiles (pay-per-use endpoints) when reaching usage limits. Changes: - Add conversion utilities (claudeProfileToUnified, apiProfileToUnified) to unified-account.ts for converting profile types to unified format - Add checkAPIProfileAvailability function for API profiles (no usage limits) - Add getBestAvailableUnifiedAccount function for unified OAuth + API selection - Add loadAPIProfiles method to ClaudeProfileManager - Add getBestAvailableUnifiedAccount async method to ClaudeProfileManager - Add QUEUE_GET_BEST_UNIFIED_ACCOUNT IPC channel and handler - Add getBestUnifiedAccount method to queue preload API All 3055 frontend tests pass. Backward compatibility maintained - existing getBestAvailableProfile continues to work for OAuth-only scenarios. Task: 070-unified-profile-swapping-across-oauth-and-api-acco * fix(profiles): address code review feedback on unified profile swapping - Fix critical bug: activeAPIId now correctly read from profiles.json's activeProfileId instead of incorrectly comparing OAuth ID against API IDs - Fix high severity: scoreUnifiedAccount now enforces usage thresholds (sessionThreshold, weeklyThreshold) matching OAuth-only behavior - Fix medium: Remove redundant rate limit check in claudeProfileToUnified - Fix medium: Change apiProfileToUnified isAuthenticated default to false for safer default behavior - Fix minor: Add guard against double-prefixing in toOAuthUnifiedId and toAPIUnifiedId helper functions - Remove unused checkAPIProfileAvailability function All 3055 frontend tests pass. * refactor(profiles): move runtime functions from types to utils Follow project convention by keeping shared/types/ for type definitions only. Move conversion utilities and helper functions to shared/utils/: - Create shared/utils/unified-account.ts for runtime functions - Keep only types/interfaces in shared/types/unified-account.ts - Update import in profile-scorer.ts to use new utils location Functions moved: - claudeProfileToUnified() - apiProfileToUnified() - isOAuthAccountId() - isAPIAccountId() - extractProfileId() - toOAuthUnifiedId() - toAPIUnifiedId() - OAUTH_ID_PREFIX / API_ID_PREFIX constants All 3055 frontend tests pass. * fix(profiles): fix unified account authentication and ID handling Critical fixes: - Fix proactive switching: extractProfileId() now strips prefix before calling setActiveProfile/setActiveAPIProfile (fixes HIGH severity bug where prefixed IDs like 'oauth-primary' were passed to functions expecting raw IDs like 'primary') - Fix OAuth profile authentication: claudeProfileToUnified now accepts explicit isAuthenticated option, and profile-scorer computes it using isProfileAuthenticated() before conversion (fixes critical bug where OAuth profiles scored -1000 due to undefined isAuthenticated) Changes: - Add isAuthenticated option to claudeProfileToUnified in unified-account.ts - Compute isProfileAuthenticated() in profile-scorer.ts OAuth conversion loop - Use extractProfileId() in usage-monitor.ts proactive switching - Add TODO for API key validation tracking All 3055 frontend tests pass. * refactor(profiles): improve unified account selection API and logging - Add UnifiedAccountSelectionOptions interface for cleaner API - Gate debug logs behind isDebug flag to prevent PII leakage in production - Fix new Date() allocation in rate limit check (compute once) - Add needsReauthentication field to apiProfileToUnified for consistency Addresses CodeRabbit feedback on PR #1794. * refactor(profiles): address CodeRabbit feedback on unified account handling - Use OAUTH_ID_PREFIX constant instead of hardcoded string - Extract duplicated loadProfilesFile logic into shared helper - Add cross-type prefix collision guards in toOAuthUnifiedId/toAPIUnifiedId - Remove unnecessary extractProfileId call in usage-monitor (id is already raw) - Remove unused import of extractProfileId Addresses CodeRabbit feedback on PR #1794. --------- Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<APIProfile[]> {
|
||||
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<UnifiedAccount | null> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IPCResult<BestProfileResult | null>>;
|
||||
|
||||
/**
|
||||
* 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<IPCResult<UnifiedAccount | null>>;
|
||||
|
||||
/**
|
||||
* 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<IPCResult<BestProfileResult | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_BEST_PROFILE_FOR_TASK, options),
|
||||
|
||||
getBestUnifiedAccount: (options?: GetBestUnifiedAccountOptions): Promise<IPCResult<UnifiedAccount | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.QUEUE_GET_BEST_UNIFIED_ACCOUNT, options),
|
||||
|
||||
assignProfileToTask: (
|
||||
taskId: string,
|
||||
profileId: string,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user