Merge pull request #390 from kaitranntt/feat/account-tier-prioritization

feat(cliproxy): account tier prioritization (ultra/pro/free)
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-01-28 16:53:56 -05:00
commit c70014a5ba
14 files changed
+128 -102

No files matched your search

+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}`) : '';
+23 -53
View File
@@ -303,48 +303,27 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
/**
* Map tier ID string to AccountTier type
* Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free
* API returns: "g1-ultra-tier", "g1-pro-tier", "standard-tier", etc.
* 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';
// Match "g1-ultra-tier" or "ultra" anywhere in string
if (normalized.includes('ultra')) return 'ultra';
// Match "g1-pro-tier" or "pro" anywhere in string
if (normalized.includes('pro')) return 'pro';
// Match free/legacy tiers
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
// "standard-tier" and other unknown values = unknown
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)
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier)
*/
async function getProjectId(accessToken: string): Promise<{
projectId: string | null;
@@ -406,17 +385,10 @@ async function getProjectId(accessToken: string): Promise<{
};
}
// 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;
}
// Extract tier - paidTier reflects actual subscription status, takes priority
// API returns: paidTier.id = "g1-ultra-tier" or "g1-pro-tier"
// allowedTiers/currentTier often return "standard-tier" which is not useful
const tierStr = data.paidTier?.id || data.currentTier?.id;
const tier = mapTierString(tierStr);
return { projectId: projectId.trim(), tier };
@@ -650,15 +622,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 +638,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 || 'unknown',
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}`);
}
}
}
@@ -99,6 +99,10 @@ export function AccountCard({
// Show minimum quota of Claude models (primary), fallback to min of all models
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
// Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge =
isAgy && account.tier && account.tier !== 'unknown' && account.tier !== 'free';
return (
<div
data-account-index={originalIndex}
@@ -124,16 +128,33 @@ export function AccountCard({
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
<div className="flex items-center gap-1.5 mb-1 mr-4">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
{/* Header row: Email + Tier | Pause button | Drag handle */}
<div className="flex items-center gap-1.5 mb-1">
{/* Email with tier badge inline */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
{showTierBadge && (
<span
className={cn(
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
account.tier === 'ultra'
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
)}
>
{account.tier}
</span>
)}
>
{cleanEmail(account.email)}
</span>
</div>
{/* Pause/Resume button */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
@@ -167,7 +188,11 @@ export function AccountCard({
</Tooltip>
</TooltipProvider>
)}
{/* Drag handle */}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</div>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
@@ -2,6 +2,9 @@
* Type definitions for Account Flow Visualization
*/
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
/** Position offset for draggable cards */
export interface DragOffset {
x: number;
@@ -17,6 +20,8 @@ export interface AccountData {
lastUsedAt?: string;
color: string;
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
}
export interface ProviderData {
@@ -128,14 +128,14 @@ export function AccountItem({
return (
<div
className={cn(
'flex flex-col gap-2 p-3 rounded-lg border transition-colors',
'flex flex-col gap-2 p-3 rounded-lg border transition-colors overflow-hidden',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
account.paused && 'opacity-75',
selected && 'ring-2 ring-primary/50 bg-primary/5'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Selection checkbox for bulk actions */}
{selectable && (
<button
@@ -179,13 +179,30 @@ export function AccountItem({
</Tooltip>
</TooltipProvider>
)}
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
{/* Avatar with tier badge overlay */}
<div className="relative shrink-0">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
{/* Tier badge - fixed position on avatar */}
{account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded',
'ring-1 ring-background',
account.tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{account.tier === 'ultra' ? 'U' : 'P'}
</span>
)}
>
<User className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -200,18 +217,6 @@ export function AccountItem({
Default
</Badge>
)}
{account.tier && account.tier !== 'unknown' && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 uppercase',
account.tier === 'paid' && 'border-blue-500 text-blue-600',
account.tier === 'free' && 'border-gray-400 text-gray-500'
)}
>
{account.tier}
</Badge>
)}
{account.paused && (
<Badge
variant="outline"
@@ -281,7 +286,7 @@ export function AccountItem({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
@@ -101,6 +101,7 @@ export function useAuthMonitorData(): AuthMonitorData {
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
projectId: account.projectId,
paused: account.paused,
tier: account.tier,
};
accountsList.push(row);
providerData.accounts.push(row);
@@ -2,6 +2,9 @@
* Type definitions for Auth Monitor components
*/
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
export interface AccountRow {
id: string;
email: string;
@@ -16,6 +19,8 @@ export interface AccountRow {
projectId?: string;
/** Whether account is paused (skipped in quota rotation) */
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
}
export interface ProviderStats {
+2 -2
View File
@@ -83,8 +83,8 @@ export interface OAuthAccount {
paused?: boolean;
/** ISO timestamp when account was paused */
pausedAt?: string;
/** Account tier: free or paid (Pro/Ultra combined) */
tier?: 'free' | 'paid' | 'unknown';
/** Account tier: free, pro, ultra, or unknown */
tier?: 'free' | 'pro' | 'ultra' | 'unknown';
/** GCP Project ID (Antigravity only) - read-only */
projectId?: string;
}