Merge branch 'develop' into tests-cli-commands

This commit is contained in:
StillKnotKnown
2026-02-12 13:54:12 +02:00
committed by GitHub
11 changed files with 667 additions and 42 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;
@@ -9,12 +9,7 @@ import { spawn, exec, execFile } from 'child_process';
import type { ChildProcess } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import type { Project } from '../../../../shared/types';
import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/types/terminal';
import { parsePythonCommand } from '../../../python-detector';
@@ -22,6 +17,8 @@ import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-det
import { getClaudeProfileManager } from '../../../claude-profile-manager';
import { getOperationRegistry, type OperationType } from '../../../claude-profile/operation-registry';
import { isWindows, isMacOS } from '../../../platform';
import { getEffectiveSourcePath } from '../../../updater/path-resolver';
import { pythonEnvManager, getConfiguredPythonPath } from '../../../python-env-manager';
import { getTaskkillExePath, getWhereExePath } from '../../../utils/windows-paths';
const execAsync = promisify(exec);
@@ -479,10 +476,20 @@ export function runPythonSubprocess<T = unknown>(
}
/**
* Get the Python path for a project's backend
* Cross-platform: uses Scripts/python.exe on Windows, bin/python on Unix
* Get the Python path for running GitHub runners.
*
* Prefers the managed Python environment (bundled app venv) when ready,
* falls back to project-local .venv for development repos.
*/
export function getPythonPath(backendPath: string): string {
// Use managed env when it's fully set up (has dependencies installed)
if (pythonEnvManager.isEnvReady()) {
const managed = getConfiguredPythonPath();
if (fs.existsSync(managed)) {
return managed;
}
}
// Fallback to venv in backend path (dev mode)
return isWindows()
? path.join(backendPath, '.venv', 'Scripts', 'python.exe')
: path.join(backendPath, '.venv', 'bin', 'python');
@@ -498,44 +505,30 @@ export function getRunnerPath(backendPath: string): string {
/**
* Get the auto-claude backend path for a project
*
* Auto-detects the backend location using multiple strategies:
* 1. Development repo structure (apps/backend)
* 2. Electron app bundle location
* 3. Current working directory
* Uses getEffectiveSourcePath() which handles:
* 1. User settings (autoBuildPath)
* 2. userData override (backend-source) for user-updated backend
* 3. Bundled backend (process.resourcesPath/backend)
* 4. Development paths
* Falls back to project.path/apps/backend for development repos.
*/
export function getBackendPath(project: Project): string | null {
// Import app module for production path detection
let app: any;
try {
app = require('electron').app;
} catch {
// Electron not available in tests
// Use shared path resolver which handles:
// 1. User settings (autoBuildPath)
// 2. userData override (backend-source) for user-updated backend
// 3. Bundled backend (process.resourcesPath/backend)
// 4. Development paths
const effectivePath = getEffectiveSourcePath();
if (fs.existsSync(effectivePath) && fs.existsSync(path.join(effectivePath, 'runners', 'github', 'runner.py'))) {
return effectivePath;
}
// Check if this is a development repo (has apps/backend structure)
// Fallback: check project path for development repo structure
const appsBackendPath = path.join(project.path, 'apps', 'backend');
if (fs.existsSync(path.join(appsBackendPath, 'runners', 'github', 'runner.py'))) {
return appsBackendPath;
}
// Auto-detect from app location (same logic as agent-process.ts)
const possiblePaths = [
// Dev mode: from dist/main -> ../../backend (apps/frontend/out/main -> apps/backend)
path.resolve(__dirname, '..', '..', '..', '..', '..', 'backend'),
// Alternative: from app root -> apps/backend
app ? path.resolve(app.getAppPath(), '..', 'backend') : null,
// If running from repo root with apps structure
path.resolve(process.cwd(), 'apps', 'backend'),
].filter((p): p is string => p !== null);
for (const backendPath of possiblePaths) {
// Check for runner.py as marker
const runnerPath = path.join(backendPath, 'runners', 'github', 'runner.py');
if (fs.existsSync(runnerPath)) {
return backendPath;
}
}
return null;
}
@@ -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}`;
}