diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 9919ace1..fc450c7b 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -14,8 +14,8 @@ import { CLIProxyProvider } from './types'; import { getCliproxyDir, getAuthDir } from './config-generator'; import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; -/** Account tier for quota management */ -export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; +/** Account tier for quota management (free vs paid - no Pro/Ultra distinction needed) */ +export type AccountTier = 'free' | 'paid' | 'unknown'; /** * Providers that typically have empty email in OAuth token files. @@ -45,7 +45,7 @@ export interface AccountInfo { paused?: boolean; /** ISO timestamp when paused */ pausedAt?: string; - /** Account tier: free, pro, ultra */ + /** Account tier: free or paid (Pro/Ultra combined) */ tier?: AccountTier; } diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index eb1429e8..4ba769a2 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -114,6 +114,7 @@ interface TokenRefreshResponse { /** Tier info from loadCodeAssist */ interface TierInfo { id?: string; + isDefault?: boolean; } /** loadCodeAssist response */ @@ -123,6 +124,8 @@ interface LoadCodeAssistResponse { currentTier?: TierInfo; /** Paid tier (reflects actual subscription - takes priority) */ paidTier?: TierInfo; + /** Array of allowed tiers - use isDefault=true to find active tier (CLIProxyAPIPlus approach) */ + allowedTiers?: TierInfo[]; } /** fetchAvailableModels response model */ @@ -284,22 +287,49 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | } /** - * Map API tier string to AccountTier type - * API returns tier IDs like: "standard-tier" (free), "pro-tier" (pro), "ultra-tier" (ultra) - * Also handles legacy formats: "FREE", "PRO", "ULTRA" + * Map tier ID string to AccountTier type + * Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free */ function mapTierString(tierStr: string | undefined): AccountTier { if (!tierStr) return 'unknown'; const normalized = tierStr.toLowerCase(); - if (normalized.includes('ultra')) return 'ultra'; - if (normalized.includes('pro')) return 'pro'; - // "standard-tier" or "free" both map to free - if (normalized.includes('standard') || normalized.includes('free')) return 'free'; + if (normalized.includes('ultra') || normalized.includes('pro')) return 'paid'; + if (normalized.includes('free') || normalized.includes('legacy')) { + return 'free'; + } + // "standard-tier" and other unknown values should NOT map to 'free' + // Let inferTierFromModels handle the detection return 'unknown'; } /** - * Get project ID and subscription tier via loadCodeAssist endpoint + * Infer tier from model access patterns. + * - Paid: Has access to Claude models OR premium Gemini models + * - Free: Only basic models + * + * Claude models are Ultra-exclusive, premium Gemini indicates Pro/Ultra. + * Both are "paid" tier for our purposes. + */ +function inferTierFromModels(models: ModelQuota[]): AccountTier { + if (models.length === 0) return 'unknown'; + + // Check for Claude models (Ultra-exclusive) or premium Gemini (Pro/Ultra) + const hasPaidAccess = models.some((m) => { + const name = m.name.toLowerCase(); + return ( + name.includes('claude') || + name.includes('gemini-3-pro') || + name.includes('gemini-2.5-pro') || + name.includes('gemini-pro-high') + ); + }); + + return hasPaidAccess ? 'paid' : 'unknown'; +} + +/** + * Get project ID and tier via loadCodeAssist endpoint + * Uses allowedTiers array with isDefault=true (CLIProxyAPIPlus approach) */ async function getProjectId(accessToken: string): Promise<{ projectId: string | null; @@ -361,8 +391,17 @@ async function getProjectId(accessToken: string): Promise<{ }; } - // Extract tier: priority paidTier > currentTier (paid reflects actual subscription) - const tierStr = data.paidTier?.id || data.currentTier?.id; + // Extract tier using CLIProxyAPIPlus approach: + // 1. Find tier with isDefault=true in allowedTiers array + // 2. Fallback to paidTier > currentTier + let tierStr: string | undefined; + if (data.allowedTiers && Array.isArray(data.allowedTiers)) { + const defaultTier = data.allowedTiers.find((t) => t.isDefault); + tierStr = defaultTier?.id; + } + if (!tierStr) { + tierStr = data.paidTier?.id || data.currentTier?.id; + } const tier = mapTierString(tierStr); return { projectId: projectId.trim(), tier }; @@ -569,21 +608,29 @@ export async function fetchAccountQuota( refreshResult.accessToken, projectId as string ); - // Use API tier (from loadCodeAssist) instead of model-based detection + // Determine tier: model access (Claude = Ultra) > API tier > fallback if (retryResult.success) { - retryResult.tier = apiTier; + let finalTier = inferTierFromModels(retryResult.models); + if (finalTier === 'unknown') { + finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; + } + retryResult.tier = finalTier; retryResult.accountId = accountId; - setAccountTier(provider, accountId, apiTier); + setAccountTier(provider, accountId, finalTier); } return retryResult; } } - // Use API tier (from loadCodeAssist) instead of model-based detection + // Determine tier: model access > API tier > fallback to paid if (result.success) { - result.tier = apiTier; + let finalTier = inferTierFromModels(result.models); + if (finalTier === 'unknown') { + finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; + } + result.tier = finalTier; result.accountId = accountId; - setAccountTier(provider, accountId, apiTier); + setAccountTier(provider, accountId, finalTier); } return result; diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index abc09d80..0b1d8dc7 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -199,7 +199,7 @@ export async function findHealthyAccount( exclude: string[] ): Promise<{ id: string; tier: string; lastQuota: number } | null> { const config = loadOrCreateUnifiedConfig(); - const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro']; + const tierPriority = config.quota_management?.auto?.tier_priority ?? ['paid']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; const accounts = getProviderAccounts(provider); @@ -224,7 +224,7 @@ export async function findHealthyAccount( return { id: account.id, - tier: account.tier || 'pro', + tier: account.tier || 'paid', lastQuota: avgQuota, }; }) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 3a23ba1e..9f3935b2 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -17,6 +17,7 @@ import { DEFAULT_COPILOT_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, + DEFAULT_QUOTA_MANAGEMENT_CONFIG, GlobalEnvConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -212,6 +213,35 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start, }, }, + // Quota management config - hybrid auto+manual account selection + quota_management: { + mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode, + auto: { + preflight_check: + partial.quota_management?.auto?.preflight_check ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check, + exhaustion_threshold: + partial.quota_management?.auto?.exhaustion_threshold ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold, + tier_priority: + partial.quota_management?.auto?.tier_priority ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority, + cooldown_minutes: + partial.quota_management?.auto?.cooldown_minutes ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes, + }, + manual: { + paused_accounts: + partial.quota_management?.manual?.paused_accounts ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts, + forced_default: + partial.quota_management?.manual?.forced_default ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default, + tier_lock: + partial.quota_management?.manual?.tier_lock ?? + DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock, + }, + }, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index ac17e17d..e5eec79b 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -356,7 +356,7 @@ export interface AutoQuotaConfig { preflight_check: boolean; /** Quota percentage below which account is "exhausted" (default: 5) */ exhaustion_threshold: number; - /** Tier priority for failover, highest to lowest (default: ['ultra', 'pro']) */ + /** Tier priority for failover, highest to lowest (default: ['paid']) */ tier_priority: string[]; /** Minutes to skip exhausted account before retry (default: 5) */ cooldown_minutes: number; @@ -402,7 +402,7 @@ export interface QuotaManagementConfig { export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = { preflight_check: true, exhaustion_threshold: 5, - tier_priority: ['ultra', 'pro'], + tier_priority: ['paid'], cooldown_minutes: 5, }; diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 072094ef..8c6ac2f0 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -148,8 +148,7 @@ export function AccountItem({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 uppercase', - account.tier === 'ultra' && 'border-purple-500 text-purple-600', - account.tier === 'pro' && 'border-blue-500 text-blue-600', + account.tier === 'paid' && 'border-blue-500 text-blue-600', account.tier === 'free' && 'border-gray-400 text-gray-500' )} > diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index f8ef47e1..22d67bb0 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -81,8 +81,8 @@ export interface OAuthAccount { paused?: boolean; /** ISO timestamp when account was paused */ pausedAt?: string; - /** Account tier: free, pro, ultra */ - tier?: 'free' | 'pro' | 'ultra' | 'unknown'; + /** Account tier: free or paid (Pro/Ultra combined) */ + tier?: 'free' | 'paid' | 'unknown'; } export interface AuthStatus {