mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 22:16:38 +00:00
feat(cliproxy): add entitlement evidence for gemini and agy
This commit is contained in:
@@ -20,7 +20,7 @@ const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
|||||||
id: 'gemini-flash-lite-series',
|
id: 'gemini-flash-lite-series',
|
||||||
label: 'Gemini Flash Lite Series',
|
label: 'Gemini Flash Lite Series',
|
||||||
preferredModelId: 'gemini-2.5-flash-lite',
|
preferredModelId: 'gemini-2.5-flash-lite',
|
||||||
modelIds: ['gemini-2.5-flash-lite'],
|
modelIds: ['gemini-2.5-flash-lite', 'gemini-3.1-flash-lite-preview'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-flash-series',
|
id: 'gemini-flash-series',
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { AccountTier } from './accounts/types';
|
||||||
|
import type {
|
||||||
|
ProviderAccessState,
|
||||||
|
ProviderCapacityState,
|
||||||
|
ProviderEntitlementEvidence,
|
||||||
|
ProviderEntitlementSource,
|
||||||
|
} from './provider-entitlement-types';
|
||||||
|
|
||||||
|
const RAW_TIER_LABELS: Record<string, string> = {
|
||||||
|
'free-tier': 'Free',
|
||||||
|
'legacy-tier': 'Legacy',
|
||||||
|
'standard-tier': 'Standard',
|
||||||
|
'g1-pro-tier': 'Pro',
|
||||||
|
'g1-ultra-tier': 'Ultra',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeProviderTierId(rawTierId: string | null | undefined): AccountTier {
|
||||||
|
if (!rawTierId) return 'unknown';
|
||||||
|
const normalized = rawTierId.trim().toLowerCase();
|
||||||
|
if (!normalized) return 'unknown';
|
||||||
|
if (normalized.includes('ultra')) return 'ultra';
|
||||||
|
if (normalized.includes('pro')) return 'pro';
|
||||||
|
if (normalized.includes('free') || normalized.includes('legacy')) return 'free';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderTierLabel(rawTierId: string | null | undefined): string | null {
|
||||||
|
if (!rawTierId) return null;
|
||||||
|
const normalized = rawTierId.trim().toLowerCase();
|
||||||
|
return normalized ? (RAW_TIER_LABELS[normalized] ?? rawTierId.trim()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProviderEntitlementEvidence(input: {
|
||||||
|
normalizedTier: AccountTier;
|
||||||
|
rawTierId?: string | null;
|
||||||
|
rawTierLabel?: string | null;
|
||||||
|
source: ProviderEntitlementSource;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
accessState: ProviderAccessState;
|
||||||
|
capacityState: ProviderCapacityState;
|
||||||
|
notes?: string | null;
|
||||||
|
lastVerifiedAt?: number;
|
||||||
|
}): ProviderEntitlementEvidence {
|
||||||
|
const rawTierId = input.rawTierId?.trim() || null;
|
||||||
|
return {
|
||||||
|
normalizedTier: input.normalizedTier,
|
||||||
|
rawTierId,
|
||||||
|
rawTierLabel: input.rawTierLabel ?? getProviderTierLabel(rawTierId),
|
||||||
|
source: input.source,
|
||||||
|
confidence: input.confidence,
|
||||||
|
accessState: input.accessState,
|
||||||
|
capacityState: input.capacityState,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
lastVerifiedAt: input.lastVerifiedAt ?? Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelCapacityExhausted(
|
||||||
|
message: string | null | undefined,
|
||||||
|
detail: string | null | undefined,
|
||||||
|
errorCode: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
const haystack = `${message || ''} ${detail || ''} ${errorCode || ''}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
haystack.includes('model_capacity_exhausted') ||
|
||||||
|
haystack.includes('no capacity available') ||
|
||||||
|
haystack.includes('capacity exhausted')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { AccountTier } from './accounts/types';
|
||||||
|
|
||||||
|
export type ProviderEntitlementSource =
|
||||||
|
| 'runtime_api'
|
||||||
|
| 'runtime_inference'
|
||||||
|
| 'registry_cache'
|
||||||
|
| 'official_docs';
|
||||||
|
|
||||||
|
export type ProviderAccessState =
|
||||||
|
| 'entitled'
|
||||||
|
| 'not_entitled'
|
||||||
|
| 'capacity_exhausted'
|
||||||
|
| 'temporarily_unavailable'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export type ProviderCapacityState =
|
||||||
|
| 'available'
|
||||||
|
| 'capacity_exhausted'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'temporarily_unavailable'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export interface ProviderEntitlementEvidence {
|
||||||
|
normalizedTier: AccountTier;
|
||||||
|
rawTierId: string | null;
|
||||||
|
rawTierLabel: string | null;
|
||||||
|
source: ProviderEntitlementSource;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
accessState: ProviderAccessState;
|
||||||
|
capacityState: ProviderCapacityState;
|
||||||
|
lastVerifiedAt: number;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { getAuthDir } from './config-generator';
|
import { getAuthDir } from './config-generator';
|
||||||
import { getProviderAccounts, getPausedDir } from './account-manager';
|
import { getProviderAccounts, getPausedDir, setAccountTier } from './account-manager';
|
||||||
import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils';
|
import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils';
|
||||||
import { refreshGeminiToken } from './auth/gemini-token-refresh';
|
import { refreshGeminiToken } from './auth/gemini-token-refresh';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,13 @@ import {
|
|||||||
type GeminiCliParsedBucket,
|
type GeminiCliParsedBucket,
|
||||||
} from './gemini-cli-quota-normalizer';
|
} from './gemini-cli-quota-normalizer';
|
||||||
import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types';
|
import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types';
|
||||||
|
import {
|
||||||
|
buildProviderEntitlementEvidence,
|
||||||
|
getProviderTierLabel,
|
||||||
|
isModelCapacityExhausted,
|
||||||
|
normalizeProviderTierId,
|
||||||
|
} from './provider-entitlement-evidence';
|
||||||
|
import type { ProviderEntitlementEvidence } from './provider-entitlement-types';
|
||||||
|
|
||||||
/** Google Cloud Code API endpoints */
|
/** Google Cloud Code API endpoints */
|
||||||
const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||||
@@ -25,13 +32,6 @@ const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERS
|
|||||||
const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
|
const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
|
||||||
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
|
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
|
||||||
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
|
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
|
||||||
const GEMINI_CLI_TIER_LABELS: Record<string, string> = {
|
|
||||||
'free-tier': 'Free',
|
|
||||||
'legacy-tier': 'Legacy',
|
|
||||||
'standard-tier': 'Standard',
|
|
||||||
'g1-pro-tier': 'Pro',
|
|
||||||
'g1-ultra-tier': 'Ultra',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Auth data extracted from Gemini CLI auth file */
|
/** Auth data extracted from Gemini CLI auth file */
|
||||||
interface GeminiCliAuthData {
|
interface GeminiCliAuthData {
|
||||||
@@ -90,6 +90,7 @@ interface GeminiCliSupplementaryInfo {
|
|||||||
tierLabel: string | null;
|
tierLabel: string | null;
|
||||||
tierId: string | null;
|
tierId: string | null;
|
||||||
creditBalance: number | null;
|
creditBalance: number | null;
|
||||||
|
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,8 +279,7 @@ function resolveGeminiCliTierId(payload: GeminiCliCodeAssistResponse | null): st
|
|||||||
|
|
||||||
function resolveGeminiCliTierLabel(payload: GeminiCliCodeAssistResponse | null): string | null {
|
function resolveGeminiCliTierLabel(payload: GeminiCliCodeAssistResponse | null): string | null {
|
||||||
const tierId = resolveGeminiCliTierId(payload);
|
const tierId = resolveGeminiCliTierId(payload);
|
||||||
if (!tierId) return null;
|
return getProviderTierLabel(tierId);
|
||||||
return GEMINI_CLI_TIER_LABELS[tierId] ?? tierId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | null): number | null {
|
function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | null): number | null {
|
||||||
@@ -340,7 +340,7 @@ async function fetchGeminiCliSupplementary(
|
|||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`);
|
console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as GeminiCliCodeAssistResponse;
|
const payload = (await response.json()) as GeminiCliCodeAssistResponse;
|
||||||
@@ -348,6 +348,7 @@ async function fetchGeminiCliSupplementary(
|
|||||||
tierLabel: resolveGeminiCliTierLabel(payload),
|
tierLabel: resolveGeminiCliTierLabel(payload),
|
||||||
tierId: resolveGeminiCliTierId(payload),
|
tierId: resolveGeminiCliTierId(payload),
|
||||||
creditBalance: resolveGeminiCliCreditBalance(payload),
|
creditBalance: resolveGeminiCliCreditBalance(payload),
|
||||||
|
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -355,7 +356,7 @@ async function fetchGeminiCliSupplementary(
|
|||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`);
|
console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`);
|
||||||
}
|
}
|
||||||
return { tierLabel: null, tierId: null, creditBalance: null };
|
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +372,7 @@ function buildGeminiCliFailureResult(
|
|||||||
retryable?: boolean;
|
retryable?: boolean;
|
||||||
needsReauth?: boolean;
|
needsReauth?: boolean;
|
||||||
isForbidden?: boolean;
|
isForbidden?: boolean;
|
||||||
|
entitlement?: ProviderEntitlementEvidence;
|
||||||
}
|
}
|
||||||
): GeminiCliQuotaResult {
|
): GeminiCliQuotaResult {
|
||||||
return {
|
return {
|
||||||
@@ -390,6 +392,7 @@ function buildGeminiCliFailureResult(
|
|||||||
retryable: options.retryable,
|
retryable: options.retryable,
|
||||||
needsReauth: options.needsReauth,
|
needsReauth: options.needsReauth,
|
||||||
isForbidden: options.isForbidden,
|
isForbidden: options.isForbidden,
|
||||||
|
entitlement: options.entitlement,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,10 +552,37 @@ function buildGeminiCliHttpFailureResult(
|
|||||||
actionHint: buildGeminiCliForbiddenActionHint(parsed),
|
actionHint: buildGeminiCliForbiddenActionHint(parsed),
|
||||||
isForbidden: true,
|
isForbidden: true,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
|
entitlement: buildProviderEntitlementEvidence({
|
||||||
|
normalizedTier: 'unknown',
|
||||||
|
source: 'runtime_inference',
|
||||||
|
confidence: 'medium',
|
||||||
|
accessState: 'not_entitled',
|
||||||
|
capacityState: 'unknown',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
|
if (isModelCapacityExhausted(parsed.message, parsed.errorDetail, parsed.errorCode)) {
|
||||||
|
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||||
|
error: parsed.message || 'Model capacity exhausted for this account right now',
|
||||||
|
httpStatus: 429,
|
||||||
|
errorCode: 'capacity_exhausted',
|
||||||
|
errorDetail: parsed.errorDetail,
|
||||||
|
actionHint:
|
||||||
|
'Retry later or switch to another Gemini model. This indicates temporary model capacity, not an authentication failure.',
|
||||||
|
retryable: true,
|
||||||
|
entitlement: buildProviderEntitlementEvidence({
|
||||||
|
normalizedTier: 'unknown',
|
||||||
|
source: 'runtime_inference',
|
||||||
|
confidence: 'medium',
|
||||||
|
accessState: 'entitled',
|
||||||
|
capacityState: 'capacity_exhausted',
|
||||||
|
notes: 'Upstream returned MODEL_CAPACITY_EXHAUSTED for this model.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return buildGeminiCliFailureResult(accountId, projectId, {
|
return buildGeminiCliFailureResult(accountId, projectId, {
|
||||||
error: parsed.message || 'Rate limited - try again later',
|
error: parsed.message || 'Rate limited - try again later',
|
||||||
httpStatus: 429,
|
httpStatus: 429,
|
||||||
@@ -560,6 +590,13 @@ function buildGeminiCliHttpFailureResult(
|
|||||||
errorDetail: parsed.errorDetail,
|
errorDetail: parsed.errorDetail,
|
||||||
actionHint: 'Retry after a short delay.',
|
actionHint: 'Retry after a short delay.',
|
||||||
retryable: true,
|
retryable: true,
|
||||||
|
entitlement: buildProviderEntitlementEvidence({
|
||||||
|
normalizedTier: 'unknown',
|
||||||
|
source: 'runtime_inference',
|
||||||
|
confidence: 'low',
|
||||||
|
accessState: 'unknown',
|
||||||
|
capacityState: 'rate_limited',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +720,10 @@ async function fetchWithAuthData(
|
|||||||
|
|
||||||
if (verbose) console.error(`[i] Gemini CLI buckets found: ${buckets.length}`);
|
if (verbose) console.error(`[i] Gemini CLI buckets found: ${buckets.length}`);
|
||||||
|
|
||||||
|
if (supplementary.normalizedTier !== 'unknown') {
|
||||||
|
setAccountTier('gemini', accountId, supplementary.normalizedTier);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
buckets,
|
buckets,
|
||||||
@@ -690,6 +731,15 @@ async function fetchWithAuthData(
|
|||||||
tierLabel: supplementary.tierLabel,
|
tierLabel: supplementary.tierLabel,
|
||||||
tierId: supplementary.tierId,
|
tierId: supplementary.tierId,
|
||||||
creditBalance: supplementary.creditBalance,
|
creditBalance: supplementary.creditBalance,
|
||||||
|
entitlement: buildProviderEntitlementEvidence({
|
||||||
|
normalizedTier: supplementary.normalizedTier,
|
||||||
|
rawTierId: supplementary.tierId,
|
||||||
|
rawTierLabel: supplementary.tierLabel,
|
||||||
|
source: supplementary.tierId ? 'runtime_api' : 'runtime_inference',
|
||||||
|
confidence: supplementary.tierId ? 'high' : 'medium',
|
||||||
|
accessState: 'entitled',
|
||||||
|
capacityState: 'available',
|
||||||
|
}),
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
accountId,
|
accountId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
type AccountTier,
|
type AccountTier,
|
||||||
} from './account-manager';
|
} from './account-manager';
|
||||||
import { sanitizeEmail, isTokenExpired } from './auth-utils';
|
import { sanitizeEmail, isTokenExpired } from './auth-utils';
|
||||||
|
import {
|
||||||
|
buildProviderEntitlementEvidence,
|
||||||
|
getProviderTierLabel,
|
||||||
|
normalizeProviderTierId,
|
||||||
|
} from './provider-entitlement-evidence';
|
||||||
|
import type { ProviderEntitlementEvidence } from './provider-entitlement-types';
|
||||||
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
|
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
|
||||||
|
|
||||||
/** Individual model quota info */
|
/** Individual model quota info */
|
||||||
@@ -67,6 +73,8 @@ export interface QuotaResult {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
/** Detected account tier based on model access */
|
/** Detected account tier based on model access */
|
||||||
tier?: AccountTier;
|
tier?: AccountTier;
|
||||||
|
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||||
|
entitlement?: ProviderEntitlementEvidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google Cloud Code API endpoints */
|
/** Google Cloud Code API endpoints */
|
||||||
@@ -175,6 +183,8 @@ interface ManagedResponse {
|
|||||||
interface ProjectLookupResult {
|
interface ProjectLookupResult {
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
tier?: AccountTier;
|
tier?: AccountTier;
|
||||||
|
rawTierId?: string | null;
|
||||||
|
rawTierLabel?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
errorDetail?: string;
|
errorDetail?: string;
|
||||||
@@ -518,21 +528,6 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
|
|||||||
* API returns: "g1-ultra-tier", "g1-pro-tier", "standard-tier", etc.
|
* API returns: "g1-ultra-tier", "g1-pro-tier", "standard-tier", etc.
|
||||||
* Priority: ultra > pro > free
|
* Priority: ultra > pro > free
|
||||||
*/
|
*/
|
||||||
function mapTierString(tierStr: string | undefined): AccountTier {
|
|
||||||
if (!tierStr) return 'unknown';
|
|
||||||
const normalized = tierStr.toLowerCase();
|
|
||||||
// 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 = unknown
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get project ID and tier via loadCodeAssist endpoint
|
* Get project ID and tier via loadCodeAssist endpoint
|
||||||
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier)
|
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier)
|
||||||
@@ -590,10 +585,15 @@ async function getProjectId(accountId: string, accessToken: string): Promise<Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract tier - paidTier reflects actual subscription status, takes priority
|
// Extract tier - paidTier reflects actual subscription status, takes priority
|
||||||
const tierStr = data.paidTier?.id || data.currentTier?.id;
|
const rawTierId = (data.paidTier?.id || data.currentTier?.id || '').trim() || null;
|
||||||
const tier = mapTierString(tierStr);
|
const tier = normalizeProviderTierId(rawTierId);
|
||||||
|
|
||||||
return { projectId: projectId.trim(), tier };
|
return {
|
||||||
|
projectId: projectId.trim(),
|
||||||
|
tier,
|
||||||
|
rawTierId,
|
||||||
|
rawTierLabel: getProviderTierLabel(rawTierId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -727,6 +727,8 @@ export async function fetchAccountQuota(
|
|||||||
// Get project ID and tier - prefer stored project ID, but always call API for tier
|
// Get project ID and tier - prefer stored project ID, but always call API for tier
|
||||||
let projectId = authData.projectId;
|
let projectId = authData.projectId;
|
||||||
let apiTier: AccountTier = 'unknown';
|
let apiTier: AccountTier = 'unknown';
|
||||||
|
let rawTierId: string | null = null;
|
||||||
|
let rawTierLabel: string | null = null;
|
||||||
|
|
||||||
// Always call loadCodeAssist to get accurate tier from API.
|
// Always call loadCodeAssist to get accurate tier from API.
|
||||||
// If the file token is stale, the helper retries through CLIProxy management auth.
|
// If the file token is stale, the helper retries through CLIProxy management auth.
|
||||||
@@ -755,6 +757,8 @@ export async function fetchAccountQuota(
|
|||||||
// Use API project ID if available, else fallback to stored
|
// Use API project ID if available, else fallback to stored
|
||||||
projectId = lastProjectResult.projectId || projectId;
|
projectId = lastProjectResult.projectId || projectId;
|
||||||
apiTier = lastProjectResult.tier || 'unknown';
|
apiTier = lastProjectResult.tier || 'unknown';
|
||||||
|
rawTierId = lastProjectResult.rawTierId || null;
|
||||||
|
rawTierLabel = lastProjectResult.rawTierLabel || null;
|
||||||
|
|
||||||
if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`);
|
if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`);
|
||||||
|
|
||||||
@@ -769,6 +773,15 @@ export async function fetchAccountQuota(
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
|
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
|
||||||
result.tier = finalTier;
|
result.tier = finalTier;
|
||||||
|
result.entitlement = buildProviderEntitlementEvidence({
|
||||||
|
normalizedTier: finalTier,
|
||||||
|
rawTierId,
|
||||||
|
rawTierLabel,
|
||||||
|
source: rawTierId ? 'runtime_api' : 'runtime_inference',
|
||||||
|
confidence: rawTierId ? 'high' : 'medium',
|
||||||
|
accessState: 'entitled',
|
||||||
|
capacityState: 'available',
|
||||||
|
});
|
||||||
if (finalTier !== 'unknown') {
|
if (finalTier !== 'unknown') {
|
||||||
setAccountTier(provider, accountId, finalTier);
|
setAccountTier(provider, accountId, finalTier);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* Supports Antigravity, Codex, Claude, Gemini CLI, and GitHub Copilot OAuth providers.
|
* Supports Antigravity, Codex, Claude, Gemini CLI, and GitHub Copilot OAuth providers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ProviderEntitlementEvidence } from './provider-entitlement-types';
|
||||||
|
|
||||||
/** Supported quota providers */
|
/** Supported quota providers */
|
||||||
export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp';
|
export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp';
|
||||||
|
|
||||||
@@ -200,6 +202,8 @@ export interface GeminiCliQuotaResult extends QuotaErrorMetadata {
|
|||||||
tierId?: string | null;
|
tierId?: string | null;
|
||||||
/** Available Google One AI credits when reported by the API */
|
/** Available Google One AI credits when reported by the API */
|
||||||
creditBalance?: number | null;
|
creditBalance?: number | null;
|
||||||
|
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||||
|
entitlement?: ProviderEntitlementEvidence;
|
||||||
/** Timestamp of fetch */
|
/** Timestamp of fetch */
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
/** Error message if fetch failed */
|
/** Error message if fetch failed */
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ function formatCliAccountLabel(account: { id: string; email?: string; nickname?:
|
|||||||
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
|
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDisplayedTier(
|
||||||
|
accountTier: string | undefined,
|
||||||
|
liveTier: string | undefined
|
||||||
|
): string {
|
||||||
|
return (liveTier && liveTier !== 'unknown' ? liveTier : accountTier) || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
interface QuotaFailureDisplayEntry {
|
interface QuotaFailureDisplayEntry {
|
||||||
tone: 'error' | 'info' | 'dim';
|
tone: 'error' | 'info' | 'dim';
|
||||||
text: string;
|
text: string;
|
||||||
@@ -363,7 +370,7 @@ function displayAntigravityQuotaSection(
|
|||||||
if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning'));
|
if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning'));
|
||||||
|
|
||||||
const defaultMark = account.isDefault ? color('*', 'success') : ' ';
|
const defaultMark = account.isDefault ? color('*', 'success') : ' ';
|
||||||
const tier = account.tier || 'unknown';
|
const tier = resolveDisplayedTier(account.tier, quota?.entitlement?.normalizedTier);
|
||||||
const status = statusParts.join(', ');
|
const status = statusParts.join(', ');
|
||||||
|
|
||||||
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
|
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
|
||||||
@@ -641,6 +648,9 @@ function displayGeminiCliQuotaSection(
|
|||||||
if (quota.tierLabel) {
|
if (quota.tierLabel) {
|
||||||
console.log(` Tier: ${dim(quota.tierLabel)}`);
|
console.log(` Tier: ${dim(quota.tierLabel)}`);
|
||||||
}
|
}
|
||||||
|
if (quota.entitlement?.rawTierId) {
|
||||||
|
console.log(` Tier ID: ${dim(quota.entitlement.rawTierId)}`);
|
||||||
|
}
|
||||||
if (quota.creditBalance !== null && quota.creditBalance !== undefined) {
|
if (quota.creditBalance !== null && quota.creditBalance !== undefined) {
|
||||||
console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`);
|
console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`);
|
||||||
}
|
}
|
||||||
@@ -783,6 +793,7 @@ const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntim
|
|||||||
|
|
||||||
export const __testExports = {
|
export const __testExports = {
|
||||||
getQuotaFailureDisplayEntries,
|
getQuotaFailureDisplayEntries,
|
||||||
|
resolveDisplayedTier,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function handleQuotaStatus(
|
export async function handleQuotaStatus(
|
||||||
|
|||||||
@@ -183,6 +183,16 @@ describe('Gemini CLI Quota Fetcher', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep Gemini 3.1 Flash Lite preview inside the Flash Lite family', () => {
|
||||||
|
const rawBuckets = [{ model_id: 'gemini-3.1-flash-lite-preview', remaining_fraction: 0.65 }];
|
||||||
|
|
||||||
|
const buckets = buildGeminiCliBuckets(rawBuckets);
|
||||||
|
|
||||||
|
expect(buckets).toHaveLength(1);
|
||||||
|
expect(buckets[0].label).toBe('Gemini Flash Lite Series');
|
||||||
|
expect(buckets[0].modelIds).toContain('gemini-3.1-flash-lite-preview');
|
||||||
|
});
|
||||||
|
|
||||||
it('should recognize Gemini 3.1 preview IDs during the rollout', () => {
|
it('should recognize Gemini 3.1 preview IDs during the rollout', () => {
|
||||||
const rawBuckets = [
|
const rawBuckets = [
|
||||||
{ model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 },
|
{ model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 },
|
||||||
@@ -403,6 +413,13 @@ describe('Gemini CLI Quota Fetcher', () => {
|
|||||||
expect(result.tierLabel).toBe('Pro');
|
expect(result.tierLabel).toBe('Pro');
|
||||||
expect(result.tierId).toBe('g1-pro-tier');
|
expect(result.tierId).toBe('g1-pro-tier');
|
||||||
expect(result.creditBalance).toBe(12);
|
expect(result.creditBalance).toBe(12);
|
||||||
|
expect(result.entitlement).toMatchObject({
|
||||||
|
normalizedTier: 'pro',
|
||||||
|
rawTierId: 'g1-pro-tier',
|
||||||
|
rawTierLabel: 'Pro',
|
||||||
|
accessState: 'entitled',
|
||||||
|
capacityState: 'available',
|
||||||
|
});
|
||||||
expect(result.buckets.map((bucket) => bucket.label)).toEqual([
|
expect(result.buckets.map((bucket) => bucket.label)).toEqual([
|
||||||
'Gemini Flash Lite Series',
|
'Gemini Flash Lite Series',
|
||||||
'Gemini Flash Series',
|
'Gemini Flash Series',
|
||||||
@@ -822,6 +839,43 @@ describe('Gemini CLI Quota Fetcher', () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('classifies model capacity exhaustion separately from generic rate limits', async () => {
|
||||||
|
writeActiveGeminiAccount('capacity@example.com');
|
||||||
|
|
||||||
|
mockFetch([
|
||||||
|
{
|
||||||
|
url: GEMINI_QUOTA_URL,
|
||||||
|
method: 'POST',
|
||||||
|
status: 429,
|
||||||
|
response: {
|
||||||
|
error: {
|
||||||
|
code: 429,
|
||||||
|
message: 'No capacity available for model gemini-3.1-pro-preview on the server',
|
||||||
|
status: 'RESOURCE_EXHAUSTED',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'MODEL_CAPACITY_EXHAUSTED',
|
||||||
|
metadata: { model: 'gemini-3.1-pro-preview' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await fetchGeminiCliQuota('capacity@example.com');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.httpStatus).toBe(429);
|
||||||
|
expect(result.errorCode).toBe('capacity_exhausted');
|
||||||
|
expect(result.retryable).toBe(true);
|
||||||
|
expect(result.entitlement).toMatchObject({
|
||||||
|
accessState: 'entitled',
|
||||||
|
capacityState: 'capacity_exhausted',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('direct Gemini error helper coverage', () => {
|
describe('direct Gemini error helper coverage', () => {
|
||||||
|
|||||||
@@ -75,4 +75,12 @@ describe('cliproxy quota subcommand failure formatting', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers live quota tier over stale account tier', async () => {
|
||||||
|
const { resolveDisplayedTier } = await loadQuotaCommandTestExports();
|
||||||
|
|
||||||
|
expect(resolveDisplayedTier('unknown', 'pro')).toBe('pro');
|
||||||
|
expect(resolveDisplayedTier('pro', 'ultra')).toBe('ultra');
|
||||||
|
expect(resolveDisplayedTier('pro', 'unknown')).toBe('pro');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ export function AccountSurfaceCard({
|
|||||||
const title = displayEmail || identity.email || accountId;
|
const title = displayEmail || identity.email || accountId;
|
||||||
const normalizedProvider = provider.toLowerCase();
|
const normalizedProvider = provider.toLowerCase();
|
||||||
const showTierBadge =
|
const showTierBadge =
|
||||||
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
|
(normalizedProvider === 'agy' ||
|
||||||
|
normalizedProvider === 'antigravity' ||
|
||||||
|
normalizedProvider === 'gemini') &&
|
||||||
tier &&
|
tier &&
|
||||||
tier !== 'unknown' &&
|
tier !== 'unknown' &&
|
||||||
tier !== 'free';
|
tier !== 'free';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type ModelTier,
|
type ModelTier,
|
||||||
type UnifiedQuotaResult,
|
type UnifiedQuotaResult,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
import type { ProviderEntitlementEvidence } from '@/lib/api-client';
|
||||||
|
|
||||||
interface QuotaTooltipContentProps {
|
interface QuotaTooltipContentProps {
|
||||||
quota: UnifiedQuotaResult | null | undefined;
|
quota: UnifiedQuotaResult | null | undefined;
|
||||||
@@ -77,6 +78,35 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefined) {
|
||||||
|
if (!entitlement) return null;
|
||||||
|
|
||||||
|
const rows: Array<{ label: string; value: string | null }> = [];
|
||||||
|
if (entitlement.rawTierLabel) {
|
||||||
|
rows.push({ label: 'Tier', value: entitlement.rawTierLabel });
|
||||||
|
} else if (entitlement.normalizedTier !== 'unknown') {
|
||||||
|
rows.push({ label: 'Tier', value: entitlement.normalizedTier });
|
||||||
|
}
|
||||||
|
if (entitlement.rawTierId) {
|
||||||
|
rows.push({ label: 'Tier ID', value: entitlement.rawTierId });
|
||||||
|
}
|
||||||
|
if (entitlement.accessState !== 'entitled' || entitlement.capacityState !== 'available') {
|
||||||
|
rows.push({
|
||||||
|
label: 'State',
|
||||||
|
value: `${entitlement.accessState.replaceAll('_', ' ')} / ${entitlement.capacityState.replaceAll('_', ' ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return rows.map((row) => (
|
||||||
|
<div key={row.label} className="flex justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">{row.label}</span>
|
||||||
|
<span className="font-mono">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders provider-specific quota tooltip content
|
* Renders provider-specific quota tooltip content
|
||||||
* Uses type guards for proper TypeScript narrowing
|
* Uses type guards for proper TypeScript narrowing
|
||||||
@@ -132,6 +162,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-xs space-y-1.5">
|
<div className="text-xs space-y-1.5">
|
||||||
|
{renderEntitlementRows(quota.entitlement)}
|
||||||
<p className="font-medium">Model Quotas:</p>
|
<p className="font-medium">Model Quotas:</p>
|
||||||
{tierOrder.map((tier, idx) => {
|
{tierOrder.map((tier, idx) => {
|
||||||
const models = groups.get(tier);
|
const models = groups.get(tier);
|
||||||
@@ -268,10 +299,13 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
// Gemini provider tooltip
|
// Gemini provider tooltip
|
||||||
if (isGeminiQuotaResult(quota)) {
|
if (isGeminiQuotaResult(quota)) {
|
||||||
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||||
|
const hasEntitlementTier =
|
||||||
|
!!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-xs space-y-1.5">
|
<div className="text-xs space-y-1.5">
|
||||||
{quota.tierLabel && (
|
{renderEntitlementRows(quota.entitlement)}
|
||||||
|
{!hasEntitlementTier && quota.tierLabel && (
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-muted-foreground">Tier</span>
|
<span className="text-muted-foreground">Tier</span>
|
||||||
<span className="font-mono">{quota.tierLabel}</span>
|
<span className="font-mono">{quota.tierLabel}</span>
|
||||||
|
|||||||
@@ -562,6 +562,30 @@ export interface QuotaResult {
|
|||||||
retryable?: boolean;
|
retryable?: boolean;
|
||||||
/** True if token is expired and needs re-authentication */
|
/** True if token is expired and needs re-authentication */
|
||||||
needsReauth?: boolean;
|
needsReauth?: boolean;
|
||||||
|
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||||
|
entitlement?: ProviderEntitlementEvidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderEntitlementEvidence {
|
||||||
|
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
|
||||||
|
rawTierId: string | null;
|
||||||
|
rawTierLabel: string | null;
|
||||||
|
source: 'runtime_api' | 'runtime_inference' | 'registry_cache' | 'official_docs';
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
accessState:
|
||||||
|
| 'entitled'
|
||||||
|
| 'not_entitled'
|
||||||
|
| 'capacity_exhausted'
|
||||||
|
| 'temporarily_unavailable'
|
||||||
|
| 'unknown';
|
||||||
|
capacityState:
|
||||||
|
| 'available'
|
||||||
|
| 'capacity_exhausted'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'temporarily_unavailable'
|
||||||
|
| 'unknown';
|
||||||
|
lastVerifiedAt: number;
|
||||||
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Codex rate limit window */
|
/** Codex rate limit window */
|
||||||
@@ -725,6 +749,8 @@ export interface GeminiCliQuotaResult {
|
|||||||
tierId?: string | null;
|
tierId?: string | null;
|
||||||
/** Available Google One AI credits when reported by the API */
|
/** Available Google One AI credits when reported by the API */
|
||||||
creditBalance?: number | null;
|
creditBalance?: number | null;
|
||||||
|
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||||
|
entitlement?: ProviderEntitlementEvidence;
|
||||||
/** Timestamp of fetch */
|
/** Timestamp of fetch */
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
/** Upstream HTTP status when available */
|
/** Upstream HTTP status when available */
|
||||||
|
|||||||
@@ -771,6 +771,25 @@ export function getQuotaFailureInfo(
|
|||||||
const technicalDetail = buildQuotaTechnicalDetail(quota);
|
const technicalDetail = buildQuotaTechnicalDetail(quota);
|
||||||
const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail);
|
const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail);
|
||||||
const lowerSummary = summary.toLowerCase();
|
const lowerSummary = summary.toLowerCase();
|
||||||
|
const entitlement = 'entitlement' in quota ? quota.entitlement : undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
quota.errorCode === 'capacity_exhausted' ||
|
||||||
|
entitlement?.capacityState === 'capacity_exhausted' ||
|
||||||
|
lowerSummary.includes('no capacity available') ||
|
||||||
|
lowerSummary.includes('capacity exhausted')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
label: 'Capacity',
|
||||||
|
summary,
|
||||||
|
actionHint:
|
||||||
|
actionHint ||
|
||||||
|
'Retry later or switch to another model. This is a temporary provider capacity issue.',
|
||||||
|
technicalDetail,
|
||||||
|
rawDetail,
|
||||||
|
tone: 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
quota.needsReauth ||
|
quota.needsReauth ||
|
||||||
@@ -808,6 +827,7 @@ export function getQuotaFailureInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
entitlement?.accessState === 'not_entitled' ||
|
||||||
quota.isForbidden ||
|
quota.isForbidden ||
|
||||||
quota.httpStatus === 403 ||
|
quota.httpStatus === 403 ||
|
||||||
errorCode === 'quota_api_forbidden' ||
|
errorCode === 'quota_api_forbidden' ||
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ function createGeminiQuotaResult(
|
|||||||
tierLabel: 'Pro',
|
tierLabel: 'Pro',
|
||||||
tierId: 'g1-pro-tier',
|
tierId: 'g1-pro-tier',
|
||||||
creditBalance: 12,
|
creditBalance: 12,
|
||||||
|
entitlement: {
|
||||||
|
normalizedTier: 'pro',
|
||||||
|
rawTierId: 'g1-pro-tier',
|
||||||
|
rawTierLabel: 'Pro',
|
||||||
|
source: 'runtime_api',
|
||||||
|
confidence: 'high',
|
||||||
|
accessState: 'entitled',
|
||||||
|
capacityState: 'available',
|
||||||
|
lastVerifiedAt: Date.now(),
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -54,6 +65,8 @@ describe('QuotaTooltipContent', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Tier')).toBeInTheDocument();
|
expect(screen.getByText('Tier')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tier ID')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('g1-pro-tier')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Credits')).toBeInTheDocument();
|
expect(screen.getByText('Credits')).toBeInTheDocument();
|
||||||
expect(screen.getByText('12')).toBeInTheDocument();
|
expect(screen.getByText('12')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument();
|
expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument();
|
||||||
|
|||||||
Reference in New Issue
Block a user