mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-10 22:23:35 +00:00
Merge pull request #946 from kaitranntt/kai/feat/945-gemini-antigravity-entitlement-intelligence
feat(cliproxy): add Gemini and AGY entitlement intelligence
This commit is contained in:
@@ -126,9 +126,10 @@ CCS provides:
|
||||
- Keep `round-robin` as the default until the user explicitly changes it
|
||||
- Never infer routing strategy from account count, tier mix, or paused/default account state
|
||||
- Auto-failover when account exhausted
|
||||
- Tier detection: free/paid/unknown
|
||||
- Tier detection: free/pro/ultra/unknown
|
||||
- Distinguish entitlement failures from temporary capacity exhaustion
|
||||
- Pre-flight quota checks before session start
|
||||
- Dashboard UI with pause/resume toggles and tier badges
|
||||
- Dashboard UI with pause/resume toggles, tier badges, and quota-detail guidance
|
||||
|
||||
### FR-010: Docker Deployment
|
||||
- Multi-stage Dockerfile with bun 1.2.21 and node:20-bookworm-slim
|
||||
@@ -279,7 +280,7 @@ CCS provides:
|
||||
### v7.14 Release (Complete)
|
||||
- [x] Hybrid quota management with auto-failover
|
||||
- [x] `ccs cliproxy pause/resume/status` commands
|
||||
- [x] API tier detection (free/paid/unknown)
|
||||
- [x] API tier detection (free/pro/ultra/unknown)
|
||||
- [x] Dashboard pause/resume toggles and tier badges
|
||||
- [x] Pre-flight quota checks before session start
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-04-09
|
||||
Last Updated: 2026-04-10
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity.
|
||||
- **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings.
|
||||
- **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank.
|
||||
- **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared types and constants for account management
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
/** Account tier for quota management: ultra > pro > free */
|
||||
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+146
-19
@@ -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,9 @@ interface ManagedResponse {
|
||||
interface ProjectLookupResult {
|
||||
projectId: string | null;
|
||||
tier?: AccountTier;
|
||||
rawTierId?: string | null;
|
||||
rawTierLabel?: string | null;
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
@@ -209,7 +220,14 @@ function buildAntigravityFailure(
|
||||
bodyText?: string
|
||||
): Pick<
|
||||
QuotaResult,
|
||||
'error' | 'errorCode' | 'errorDetail' | 'actionHint' | 'retryable' | 'httpStatus' | 'needsReauth'
|
||||
| 'error'
|
||||
| 'errorCode'
|
||||
| 'errorDetail'
|
||||
| 'actionHint'
|
||||
| 'retryable'
|
||||
| 'httpStatus'
|
||||
| 'needsReauth'
|
||||
| 'entitlement'
|
||||
> & { isForbidden?: boolean } {
|
||||
const detail = normalizeErrorDetail(bodyText || '');
|
||||
|
||||
@@ -222,6 +240,13 @@ function buildAntigravityFailure(
|
||||
'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.',
|
||||
needsReauth: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,6 +258,13 @@ function buildAntigravityFailure(
|
||||
actionHint: 'This account does not have Gemini Code Assist quota access.',
|
||||
isForbidden: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -244,6 +276,13 @@ function buildAntigravityFailure(
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'rate_limited',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,6 +294,13 @@ function buildAntigravityFailure(
|
||||
actionHint: 'Retry later. This looks temporary.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,6 +312,13 @@ function buildAntigravityFailure(
|
||||
actionHint: 'Retry later. The provider appears unavailable.',
|
||||
retryable: true,
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,6 +328,13 @@ function buildAntigravityFailure(
|
||||
error: `API error: ${status}`,
|
||||
errorCode: 'quota_request_failed',
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,9 +342,38 @@ function buildAntigravityFailure(
|
||||
error: 'Quota request failed',
|
||||
errorCode: 'quota_request_failed',
|
||||
errorDetail: detail,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAntigravityTierEvidence(
|
||||
entitlement: ProviderEntitlementEvidence | undefined,
|
||||
tier: AccountTier,
|
||||
rawTierId: string | null,
|
||||
rawTierLabel: string | null
|
||||
): ProviderEntitlementEvidence | undefined {
|
||||
if (tier === 'unknown' && !entitlement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildProviderEntitlementEvidence({
|
||||
normalizedTier: tier,
|
||||
rawTierId,
|
||||
rawTierLabel,
|
||||
source: rawTierId ? 'runtime_api' : (entitlement?.source ?? 'runtime_inference'),
|
||||
confidence: rawTierId ? 'high' : (entitlement?.confidence ?? 'medium'),
|
||||
accessState: entitlement?.accessState ?? 'unknown',
|
||||
capacityState: entitlement?.capacityState ?? 'unknown',
|
||||
notes: entitlement?.notes ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function readManagedResponse(
|
||||
response: Response,
|
||||
viaManagement: boolean
|
||||
@@ -518,21 +607,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)
|
||||
@@ -568,6 +642,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise<Pro
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid project payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -586,14 +668,27 @@ async function getProjectId(accountId: string, accessToken: string): Promise<Pro
|
||||
errorCode: 'account_unprovisioned',
|
||||
actionHint: 'Complete sign-in in the Antigravity app, then retry quota refresh.',
|
||||
isUnprovisioned: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'medium',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'unknown',
|
||||
notes: 'Project provisioning is incomplete for this account.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -633,6 +728,14 @@ async function fetchAvailableModels(
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid quota payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -727,6 +830,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.
|
||||
@@ -747,6 +852,7 @@ export async function fetchAccountQuota(
|
||||
httpStatus: lastProjectResult.httpStatus,
|
||||
needsReauth: lastProjectResult.needsReauth,
|
||||
isUnprovisioned: lastProjectResult.isUnprovisioned,
|
||||
entitlement: lastProjectResult.entitlement,
|
||||
isExpired: authData.isExpired,
|
||||
expiresAt: authData.expiresAt || undefined,
|
||||
};
|
||||
@@ -755,6 +861,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,12 +877,27 @@ 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);
|
||||
}
|
||||
} else {
|
||||
result.isExpired = authData.isExpired;
|
||||
result.expiresAt = authData.expiresAt || undefined;
|
||||
result.entitlement = mergeAntigravityTierEvidence(
|
||||
result.entitlement,
|
||||
apiTier,
|
||||
rawTierId,
|
||||
rawTierLabel
|
||||
);
|
||||
}
|
||||
|
||||
if (verbose && result.error) {
|
||||
@@ -868,6 +991,10 @@ export async function fetchAllProviderQuotas(
|
||||
return results;
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
buildAntigravityFailure,
|
||||
};
|
||||
|
||||
/**
|
||||
* Find available account with remaining quota
|
||||
* Used by preflight check for auto-switching
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
async function loadAntigravityQuotaTestExports() {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const mod = await import(`../../../src/cliproxy/quota-fetcher?agy-quota-fetcher=${moduleId}`);
|
||||
return mod.__testExports;
|
||||
}
|
||||
|
||||
describe('Antigravity quota failure metadata', () => {
|
||||
it('marks 403 failures as not entitled', async () => {
|
||||
const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports();
|
||||
|
||||
const result = buildAntigravityFailure(403, 'forbidden');
|
||||
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 429 failures as rate limited', async () => {
|
||||
const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports();
|
||||
|
||||
const result = buildAntigravityFailure(429, 'rate limited');
|
||||
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'unknown',
|
||||
capacityState: 'rate_limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves entitlement evidence when project lookup fails before quota fetch', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(`../../../src/cliproxy/quota-fetcher?agy-early=${moduleId}`);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-failure-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
project_id: 'project-x',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: { message: 'forbidden' } }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'not_entitled',
|
||||
capacityState: 'unknown',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches entitlement evidence when project lookup returns an invalid 2xx payload', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(
|
||||
`../../../src/cliproxy/quota-fetcher?agy-invalid-project=${moduleId}`
|
||||
);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-project-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('provider_unavailable');
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves live tier evidence when quota fetch fails after a successful project lookup', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(
|
||||
`../../../src/cliproxy/quota-fetcher?agy-invalid-models=${moduleId}`
|
||||
);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-models-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
cloudaicompanionProject: { id: 'project-x' },
|
||||
paidTier: { id: 'g1-pro-tier' },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entitlement).toMatchObject({
|
||||
normalizedTier: 'pro',
|
||||
rawTierId: 'g1-pro-tier',
|
||||
rawTierLabel: 'Pro',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,19 @@ function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unkno
|
||||
return '?';
|
||||
}
|
||||
|
||||
function resolveEffectiveTier(
|
||||
tier: AccountTier | undefined,
|
||||
quota: UnifiedQuotaResult | undefined
|
||||
): AccountTier | undefined {
|
||||
if (quota && 'entitlement' in quota) {
|
||||
const entitlementTier = quota.entitlement?.normalizedTier;
|
||||
if (entitlementTier && entitlementTier !== 'unknown') {
|
||||
return entitlementTier;
|
||||
}
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
|
||||
export function AccountSurfaceCard({
|
||||
mode,
|
||||
provider,
|
||||
@@ -85,11 +98,14 @@ export function AccountSurfaceCard({
|
||||
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
|
||||
const title = displayEmail || identity.email || accountId;
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
const effectiveTier = resolveEffectiveTier(tier, quota);
|
||||
const showTierBadge =
|
||||
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
|
||||
tier &&
|
||||
tier !== 'unknown' &&
|
||||
tier !== 'free';
|
||||
(normalizedProvider === 'agy' ||
|
||||
normalizedProvider === 'antigravity' ||
|
||||
normalizedProvider === 'gemini') &&
|
||||
effectiveTier &&
|
||||
effectiveTier !== 'unknown' &&
|
||||
effectiveTier !== 'free';
|
||||
const isCompact = mode === 'compact';
|
||||
const defaultCompactMetaBadges = (
|
||||
<>
|
||||
@@ -97,10 +113,10 @@ export function AccountSurfaceCard({
|
||||
<span
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||
getTierBadgeClass(tier)
|
||||
getTierBadgeClass(effectiveTier)
|
||||
)}
|
||||
>
|
||||
{tier}
|
||||
{effectiveTier}
|
||||
</span>
|
||||
)}
|
||||
{identity.audienceLabel && (
|
||||
@@ -143,12 +159,12 @@ export function AccountSurfaceCard({
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded ring-1 ring-background',
|
||||
tier === 'ultra'
|
||||
effectiveTier === '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'
|
||||
)}
|
||||
>
|
||||
{tier === 'ultra' ? 'U' : 'P'}
|
||||
{effectiveTier === 'ultra' ? 'U' : 'P'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,9 +9,11 @@ import type {
|
||||
ListAiProvidersResult,
|
||||
UpsertAiProviderEntryInput,
|
||||
} from '../../../src/cliproxy/ai-providers';
|
||||
import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types';
|
||||
|
||||
export const API_BASE_URL = '/api';
|
||||
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
||||
export type { ProviderEntitlementEvidence };
|
||||
|
||||
export class ApiConflictError extends Error {
|
||||
readonly code = API_CONFLICT_ERROR_CODE;
|
||||
@@ -562,6 +564,8 @@ 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;
|
||||
}
|
||||
|
||||
/** Codex rate limit window */
|
||||
@@ -725,6 +729,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' ||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@tests/setup/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||
import type { GeminiCliQuotaResult } from '@/lib/api-client';
|
||||
|
||||
function createGeminiQuotaResult(
|
||||
overrides: Partial<GeminiCliQuotaResult> = {}
|
||||
): GeminiCliQuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
buckets: [],
|
||||
projectId: 'project-123',
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AccountSurfaceCard', () => {
|
||||
it('prefers live quota entitlement tier over a stale account tier for Gemini badges', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider="gemini"
|
||||
accountId="user@example.com"
|
||||
email="user@example.com"
|
||||
displayEmail="user@example.com"
|
||||
tier="unknown"
|
||||
quota={createGeminiQuotaResult()}
|
||||
showQuota={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('pro')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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