mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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}`) : '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user