diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 57495035..b1044cdc 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -16,8 +16,8 @@ import { CLIPROXY_PROFILES } from '../auth/profile-detector'; import { getCliproxyDir, getAuthDir } from './config-generator'; import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; -/** Account tier for quota management (free vs paid - no Pro/Ultra distinction needed) */ -export type AccountTier = 'free' | 'paid' | 'unknown'; +/** Account tier for quota management: ultra > pro > free */ +export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; /** * Providers that typically have empty email in OAuth token files. @@ -47,7 +47,7 @@ export interface AccountInfo { paused?: boolean; /** ISO timestamp when paused */ pausedAt?: string; - /** Account tier: free or paid (Pro/Ultra combined) */ + /** Account tier: ultra, pro, or free */ tier?: AccountTier; /** GCP Project ID (Antigravity only) - read-only, fetched from auth token */ projectId?: string; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 7f035350..80406bb9 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -36,8 +36,8 @@ export interface ModelEntry { id: string; /** Human-readable name for display */ name: string; - /** Access tier indicator - 'paid' means requires paid Google account (not free tier) */ - tier?: 'free' | 'paid'; + /** Access tier indicator - 'ultra' for Claude, 'pro' for premium Gemini, 'free' for basic */ + tier?: 'free' | 'pro' | 'ultra'; /** Optional description for the model */ description?: string; /** Model has known issues - show warning when selected */ @@ -119,7 +119,7 @@ export const MODEL_CATALOG: Partial> = { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', - tier: 'paid', + tier: 'pro', description: 'Latest model, requires paid Google account', thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, }, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index ddec180b..2da1cc63 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -51,8 +51,13 @@ export function getCurrentModel( * Format model entry for display in selection list */ function formatModelOption(model: ModelEntry): string { - // Tier badge: clarify that "paid" means paid Google account (not free tier) - const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; + // Tier badge: ultra/pro indicate paid tiers + const tierBadge = + model.tier === 'ultra' + ? color(' [Ultra]', 'warning') + : model.tier === 'pro' + ? color(' [Pro]', 'warning') + : ''; const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : ''; const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : ''; return `${model.name}${tierBadge}${brokenBadge}${deprecatedBadge}`; @@ -64,7 +69,12 @@ function formatModelOption(model: ModelEntry): string { function formatModelDetailed(model: ModelEntry, isCurrent: boolean): string { const marker = isCurrent ? color('>', 'success') : ' '; const name = isCurrent ? bold(model.name) : model.name; - const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; + const tierBadge = + model.tier === 'ultra' + ? color(' [Ultra]', 'warning') + : model.tier === 'pro' + ? color(' [Pro]', 'warning') + : ''; const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : ''; const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : ''; const desc = model.description ? dim(` - ${model.description}`) : ''; diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 9301d985..f65f9540 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -303,12 +303,13 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | /** * Map tier ID string to AccountTier type - * Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free + * Priority: ultra > pro > free */ function mapTierString(tierStr: string | undefined): AccountTier { if (!tierStr) return 'unknown'; const normalized = tierStr.toLowerCase(); - if (normalized.includes('ultra') || normalized.includes('pro')) return 'paid'; + if (normalized.includes('ultra')) return 'ultra'; + if (normalized.includes('pro')) return 'pro'; if (normalized.includes('free') || normalized.includes('legacy')) { return 'free'; } @@ -317,31 +318,6 @@ function mapTierString(tierStr: string | undefined): AccountTier { return 'unknown'; } -/** - * 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) @@ -650,15 +626,14 @@ export async function fetchAccountQuota( refreshResult.accessToken, projectId as string ); - // Determine tier: model access (Claude = Ultra) > API tier > fallback + // Determine tier from API response only (model inference is unreliable) if (retryResult.success) { - let finalTier = inferTierFromModels(retryResult.models); - if (finalTier === 'unknown') { - finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; - } + const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown'; retryResult.tier = finalTier; retryResult.accountId = accountId; - setAccountTier(provider, accountId, finalTier); + if (finalTier !== 'unknown') { + setAccountTier(provider, accountId, finalTier); + } if (verbose && retryResult.error) { console.log(`[!] Error: ${retryResult.error}`); } @@ -667,15 +642,14 @@ export async function fetchAccountQuota( } } - // Determine tier: model access > API tier > fallback to paid + // Determine tier from API response only if (result.success) { - let finalTier = inferTierFromModels(result.models); - if (finalTier === 'unknown') { - finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; - } + const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown'; result.tier = finalTier; result.accountId = accountId; - setAccountTier(provider, accountId, finalTier); + if (finalTier !== 'unknown') { + setAccountTier(provider, accountId, finalTier); + } } if (verbose && result.error) { diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 48e1bc12..577052fc 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -200,7 +200,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 ?? ['paid']; + const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; const accounts = getProviderAccounts(provider); @@ -225,7 +225,7 @@ export async function findHealthyAccount( return { id: account.id, - tier: account.tier || 'paid', + tier: account.tier || 'pro', lastQuota: avgQuota, }; }) diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 55761a59..3e94af8d 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -152,7 +152,12 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { } function formatModelOption(model: ModelEntry): string { - const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; + const tierBadge = + model.tier === 'ultra' + ? color(' [Ultra]', 'warning') + : model.tier === 'pro' + ? color(' [Pro]', 'warning') + : ''; return `${model.name}${tierBadge}`; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 130d6e0b..cf7e0f1f 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -409,7 +409,7 @@ export interface QuotaManagementConfig { export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = { preflight_check: true, exhaustion_threshold: 5, - tier_priority: ['paid'], + tier_priority: ['ultra', 'pro', 'free'], cooldown_minutes: 5, }; diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 0cd8beed..8b540bca 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -88,12 +88,12 @@ describe('Model Catalog', () => { assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro'); }); - it('includes Gemini 3 Pro with paid tier', () => { + it('includes Gemini 3 Pro with pro tier', () => { const { MODEL_CATALOG } = modelCatalog; const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview'); assert(gem3, 'Should include Gemini 3 Pro'); assert.strictEqual(gem3.name, 'Gemini 3 Pro'); - assert.strictEqual(gem3.tier, 'paid'); + assert.strictEqual(gem3.tier, 'pro'); }); it('includes Gemini 2.5 Pro without tier (free)', () => { @@ -197,7 +197,7 @@ describe('Model Catalog', () => { assert(typeof model.name === 'string', `Model name should be string`); // tier is optional if (model.tier !== undefined) { - assert(['free', 'paid'].includes(model.tier), `Invalid tier: ${model.tier}`); + assert(['free', 'pro', 'ultra'].includes(model.tier), `Invalid tier: ${model.tier}`); } } }