feat(cliproxy): add granular account tier prioritization (ultra/pro/free)

- change AccountTier from binary (free/paid) to granular (ultra/pro/free/unknown)

- update mapTierString() to parse API tier strings correctly

- remove faulty inferTierFromModels() fallback - tier comes only from API

- update default tier_priority config to ['ultra', 'pro', 'free']

- update model catalog tier types and display logic

Closes #387
This commit is contained in:
kaitranntt
2026-01-28 15:11:00 -05:00
parent e01a622c97
commit aeb9abc998
8 changed files with 44 additions and 55 deletions
+3 -3
View File
@@ -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;
+3 -3
View File
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
{
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 },
},
+13 -3
View File
@@ -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}`) : '';
+13 -39
View File
@@ -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) {
+2 -2
View File
@@ -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,
};
})
+6 -1
View File
@@ -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}`;
}
+1 -1
View File
@@ -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,
};
+3 -3
View File
@@ -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}`);
}
}
}