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:
StillKnotKnown
2026-02-12 13:45:06 +02:00
committed by GitHub
parent 4f1b7b2a95
commit 2823873566
10 changed files with 638 additions and 6 deletions
@@ -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',
+1
View File
@@ -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}`;
}