mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 14:15:56 +00:00
Merge pull request #390 from kaitranntt/feat/account-tier-prioritization
feat(cliproxy): account tier prioritization (ultra/pro/free)
This commit is contained in:
14 files changed
+128
-102
No files matched your search
@@ -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,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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user