mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +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',
|
||||
label: 'Gemini Flash Lite Series',
|
||||
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',
|
||||
|
||||
@@ -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 path from 'node:path';
|
||||
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 { refreshGeminiToken } from './auth/gemini-token-refresh';
|
||||
import {
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
type GeminiCliParsedBucket,
|
||||
} from './gemini-cli-quota-normalizer';
|
||||
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 */
|
||||
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_TRUNCATION_SUFFIX = '...[truncated]';
|
||||
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 */
|
||||
interface GeminiCliAuthData {
|
||||
@@ -90,6 +90,7 @@ interface GeminiCliSupplementaryInfo {
|
||||
tierLabel: string | null;
|
||||
tierId: string | 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 {
|
||||
const tierId = resolveGeminiCliTierId(payload);
|
||||
if (!tierId) return null;
|
||||
return GEMINI_CLI_TIER_LABELS[tierId] ?? tierId;
|
||||
return getProviderTierLabel(tierId);
|
||||
}
|
||||
|
||||
function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | null): number | null {
|
||||
@@ -340,7 +340,7 @@ async function fetchGeminiCliSupplementary(
|
||||
if (verbose) {
|
||||
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;
|
||||
@@ -348,6 +348,7 @@ async function fetchGeminiCliSupplementary(
|
||||
tierLabel: resolveGeminiCliTierLabel(payload),
|
||||
tierId: resolveGeminiCliTierId(payload),
|
||||
creditBalance: resolveGeminiCliCreditBalance(payload),
|
||||
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -355,7 +356,7 @@ async function fetchGeminiCliSupplementary(
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
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;
|
||||
needsReauth?: boolean;
|
||||
isForbidden?: boolean;
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
): GeminiCliQuotaResult {
|
||||
return {
|
||||
@@ -390,6 +392,7 @@ function buildGeminiCliFailureResult(
|
||||
retryable: options.retryable,
|
||||
needsReauth: options.needsReauth,
|
||||
isForbidden: options.isForbidden,
|
||||
entitlement: options.entitlement,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -549,10 +552,37 @@ function buildGeminiCliHttpFailureResult(
|
||||
actionHint: buildGeminiCliForbiddenActionHint(parsed),
|
||||
isForbidden: true,
|
||||
retryable: false,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
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, {
|
||||
error: parsed.message || 'Rate limited - try again later',
|
||||
httpStatus: 429,
|
||||
@@ -560,6 +590,13 @@ function buildGeminiCliHttpFailureResult(
|
||||
errorDetail: parsed.errorDetail,
|
||||
actionHint: 'Retry after a short delay.',
|
||||
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 (supplementary.normalizedTier !== 'unknown') {
|
||||
setAccountTier('gemini', accountId, supplementary.normalizedTier);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
buckets,
|
||||
@@ -690,6 +731,15 @@ async function fetchWithAuthData(
|
||||
tierLabel: supplementary.tierLabel,
|
||||
tierId: supplementary.tierId,
|
||||
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(),
|
||||
accountId,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
type AccountTier,
|
||||
} from './account-manager';
|
||||
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';
|
||||
|
||||
/** Individual model quota info */
|
||||
@@ -67,6 +73,8 @@ export interface QuotaResult {
|
||||
projectId?: string;
|
||||
/** Detected account tier based on model access */
|
||||
tier?: AccountTier;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
|
||||
/** Google Cloud Code API endpoints */
|
||||
@@ -175,6 +183,8 @@ interface ManagedResponse {
|
||||
interface ProjectLookupResult {
|
||||
projectId: string | null;
|
||||
tier?: AccountTier;
|
||||
rawTierId?: string | null;
|
||||
rawTierLabel?: string | null;
|
||||
error?: string;
|
||||
errorCode?: 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.
|
||||
* 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
|
||||
* 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
|
||||
const tierStr = data.paidTier?.id || data.currentTier?.id;
|
||||
const tier = mapTierString(tierStr);
|
||||
const rawTierId = (data.paidTier?.id || data.currentTier?.id || '').trim() || null;
|
||||
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
|
||||
let projectId = authData.projectId;
|
||||
let apiTier: AccountTier = 'unknown';
|
||||
let rawTierId: string | null = null;
|
||||
let rawTierLabel: string | null = null;
|
||||
|
||||
// Always call loadCodeAssist to get accurate tier from API.
|
||||
// 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
|
||||
projectId = lastProjectResult.projectId || projectId;
|
||||
apiTier = lastProjectResult.tier || 'unknown';
|
||||
rawTierId = lastProjectResult.rawTierId || null;
|
||||
rawTierLabel = lastProjectResult.rawTierLabel || null;
|
||||
|
||||
if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`);
|
||||
|
||||
@@ -769,6 +773,15 @@ export async function fetchAccountQuota(
|
||||
if (result.success) {
|
||||
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
|
||||
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') {
|
||||
setAccountTier(provider, accountId, finalTier);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Supports Antigravity, Codex, Claude, Gemini CLI, and GitHub Copilot OAuth providers.
|
||||
*/
|
||||
|
||||
import type { ProviderEntitlementEvidence } from './provider-entitlement-types';
|
||||
|
||||
/** Supported quota providers */
|
||||
export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp';
|
||||
|
||||
@@ -200,6 +202,8 @@ export interface GeminiCliQuotaResult extends QuotaErrorMetadata {
|
||||
tierId?: string | null;
|
||||
/** Available Google One AI credits when reported by the API */
|
||||
creditBalance?: number | null;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Error message if fetch failed */
|
||||
|
||||
@@ -103,6 +103,13 @@ function formatCliAccountLabel(account: { id: string; email?: string; nickname?:
|
||||
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 {
|
||||
tone: 'error' | 'info' | 'dim';
|
||||
text: string;
|
||||
@@ -363,7 +370,7 @@ function displayAntigravityQuotaSection(
|
||||
if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning'));
|
||||
|
||||
const defaultMark = account.isDefault ? color('*', 'success') : ' ';
|
||||
const tier = account.tier || 'unknown';
|
||||
const tier = resolveDisplayedTier(account.tier, quota?.entitlement?.normalizedTier);
|
||||
const status = statusParts.join(', ');
|
||||
|
||||
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
|
||||
@@ -641,6 +648,9 @@ function displayGeminiCliQuotaSection(
|
||||
if (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) {
|
||||
console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`);
|
||||
}
|
||||
@@ -783,6 +793,7 @@ const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntim
|
||||
|
||||
export const __testExports = {
|
||||
getQuotaFailureDisplayEntries,
|
||||
resolveDisplayedTier,
|
||||
};
|
||||
|
||||
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', () => {
|
||||
const rawBuckets = [
|
||||
{ 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.tierId).toBe('g1-pro-tier');
|
||||
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([
|
||||
'Gemini Flash Lite Series',
|
||||
'Gemini Flash Series',
|
||||
@@ -822,6 +839,43 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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 normalizedProvider = provider.toLowerCase();
|
||||
const showTierBadge =
|
||||
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
|
||||
(normalizedProvider === 'agy' ||
|
||||
normalizedProvider === 'antigravity' ||
|
||||
normalizedProvider === 'gemini') &&
|
||||
tier &&
|
||||
tier !== 'unknown' &&
|
||||
tier !== 'free';
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type ModelTier,
|
||||
type UnifiedQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import type { ProviderEntitlementEvidence } from '@/lib/api-client';
|
||||
|
||||
interface QuotaTooltipContentProps {
|
||||
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
|
||||
* Uses type guards for proper TypeScript narrowing
|
||||
@@ -132,6 +162,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1.5">
|
||||
{renderEntitlementRows(quota.entitlement)}
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
@@ -268,10 +299,13 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
// Gemini provider tooltip
|
||||
if (isGeminiQuotaResult(quota)) {
|
||||
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||
const hasEntitlementTier =
|
||||
!!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown';
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1.5">
|
||||
{quota.tierLabel && (
|
||||
{renderEntitlementRows(quota.entitlement)}
|
||||
{!hasEntitlementTier && quota.tierLabel && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Tier</span>
|
||||
<span className="font-mono">{quota.tierLabel}</span>
|
||||
|
||||
@@ -562,6 +562,30 @@ export interface QuotaResult {
|
||||
retryable?: boolean;
|
||||
/** True if token is expired and needs re-authentication */
|
||||
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 */
|
||||
@@ -725,6 +749,8 @@ export interface GeminiCliQuotaResult {
|
||||
tierId?: string | null;
|
||||
/** Available Google One AI credits when reported by the API */
|
||||
creditBalance?: number | null;
|
||||
/** Richer provider entitlement evidence derived from live/runtime signals */
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Upstream HTTP status when available */
|
||||
|
||||
@@ -771,6 +771,25 @@ export function getQuotaFailureInfo(
|
||||
const technicalDetail = buildQuotaTechnicalDetail(quota);
|
||||
const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail);
|
||||
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 (
|
||||
quota.needsReauth ||
|
||||
@@ -808,6 +827,7 @@ export function getQuotaFailureInfo(
|
||||
}
|
||||
|
||||
if (
|
||||
entitlement?.accessState === 'not_entitled' ||
|
||||
quota.isForbidden ||
|
||||
quota.httpStatus === 403 ||
|
||||
errorCode === 'quota_api_forbidden' ||
|
||||
|
||||
@@ -34,6 +34,17 @@ function createGeminiQuotaResult(
|
||||
tierLabel: 'Pro',
|
||||
tierId: 'g1-pro-tier',
|
||||
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(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -54,6 +65,8 @@ describe('QuotaTooltipContent', () => {
|
||||
|
||||
expect(screen.getByText('Tier')).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('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user