From bb331ff5d8a9ff8bc387599a491335ffe89e7a1c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:17:50 -0400 Subject: [PATCH 1/7] feat(cliproxy): add entitlement evidence for gemini and agy --- src/cliproxy/gemini-cli-quota-normalizer.ts | 2 +- src/cliproxy/provider-entitlement-evidence.ts | 69 +++++++++++++++++ src/cliproxy/provider-entitlement-types.ts | 33 +++++++++ src/cliproxy/quota-fetcher-gemini-cli.ts | 74 ++++++++++++++++--- src/cliproxy/quota-fetcher.ts | 49 +++++++----- src/cliproxy/quota-types.ts | 4 + src/commands/cliproxy/quota-subcommand.ts | 13 +++- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 54 ++++++++++++++ .../cliproxy-quota-subcommand.test.ts | 8 ++ .../account/shared/account-surface-card.tsx | 4 +- .../shared/quota-tooltip-content.tsx | 36 ++++++++- ui/src/lib/api-client.ts | 26 +++++++ ui/src/lib/utils.ts | 20 +++++ .../shared/quota-tooltip-content.test.tsx | 13 ++++ 14 files changed, 371 insertions(+), 34 deletions(-) create mode 100644 src/cliproxy/provider-entitlement-evidence.ts create mode 100644 src/cliproxy/provider-entitlement-types.ts diff --git a/src/cliproxy/gemini-cli-quota-normalizer.ts b/src/cliproxy/gemini-cli-quota-normalizer.ts index 1d5425b9..3fad0de9 100644 --- a/src/cliproxy/gemini-cli-quota-normalizer.ts +++ b/src/cliproxy/gemini-cli-quota-normalizer.ts @@ -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', diff --git a/src/cliproxy/provider-entitlement-evidence.ts b/src/cliproxy/provider-entitlement-evidence.ts new file mode 100644 index 00000000..9b36576e --- /dev/null +++ b/src/cliproxy/provider-entitlement-evidence.ts @@ -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 = { + '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') + ); +} diff --git a/src/cliproxy/provider-entitlement-types.ts b/src/cliproxy/provider-entitlement-types.ts new file mode 100644 index 00000000..ef07b8db --- /dev/null +++ b/src/cliproxy/provider-entitlement-types.ts @@ -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; +} diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 1dc8fe7a..27d80d6a 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -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 = { - '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, }; diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 1365ae42..06765c58 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -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 { ]); }); + 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', () => { diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index 0b49cfef..f6bd033d 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -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'); + }); }); diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 64a1795c..3d91d71f 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -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'; diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 5dcda088..bd1e14dd 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -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) => ( +
+ {row.label} + {row.value} +
+ )); +} + /** * Renders provider-specific quota tooltip content * Uses type guards for proper TypeScript narrowing @@ -132,6 +162,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (
+ {renderEntitlementRows(quota.entitlement)}

Model Quotas:

{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 (
- {quota.tierLabel && ( + {renderEntitlementRows(quota.entitlement)} + {!hasEntitlementTier && quota.tierLabel && (
Tier {quota.tierLabel} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 74a848ee..6a0e1bc4 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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 */ diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 9db71545..0105d1e1 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -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' || diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index bada72b1..790ec8a1 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -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(); From f9c61e0faceae2532798bcfe7ff87d458024c6a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:18:18 -0400 Subject: [PATCH 2/7] docs(cliproxy): clarify gemini and agy tier semantics --- docs/project-overview-pdr.md | 7 ++++--- docs/project-roadmap.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index d11bec1d..14c87e4e 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -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 diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 6ae4fc8e..82c63df6 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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. From 7330fdbf2830911bcac09826cabf5929cc741be8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:24:00 -0400 Subject: [PATCH 3/7] fix(cliproxy): prefer live entitlement tier in account badges --- .../account/shared/account-surface-card.tsx | 28 ++++++++--- .../shared/account-surface-card.test.tsx | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 3d91d71f..783f3054 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -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,13 +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' || normalizedProvider === 'gemini') && - tier && - tier !== 'unknown' && - tier !== 'free'; + effectiveTier && + effectiveTier !== 'unknown' && + effectiveTier !== 'free'; const isCompact = mode === 'compact'; const defaultCompactMetaBadges = ( <> @@ -99,10 +113,10 @@ export function AccountSurfaceCard({ - {tier} + {effectiveTier} )} {identity.audienceLabel && ( @@ -145,12 +159,12 @@ export function AccountSurfaceCard({ - {tier === 'ultra' ? 'U' : 'P'} + {effectiveTier === 'ultra' ? 'U' : 'P'} )}
diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx new file mode 100644 index 00000000..6a0a823e --- /dev/null +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -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 { + 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( + + ); + + expect(screen.getByText('pro')).toBeInTheDocument(); + }); +}); From 7c4545eb1b35914a20762e14fdbd7832828b1324 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:42:51 -0400 Subject: [PATCH 4/7] fix(cliproxy): attach entitlement evidence to agy failures --- src/cliproxy/quota-fetcher.ts | 71 ++++++++++++++++++- .../quota-fetcher-antigravity-failure.test.ts | 31 ++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 06765c58..551c0889 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -185,6 +185,7 @@ interface ProjectLookupResult { tier?: AccountTier; rawTierId?: string | null; rawTierLabel?: string | null; + entitlement?: ProviderEntitlementEvidence; error?: string; errorCode?: string; errorDetail?: string; @@ -219,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 || ''); @@ -232,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', + }), }; } @@ -243,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', + }), }; } @@ -254,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', + }), }; } @@ -265,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', + }), }; } @@ -276,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', + }), }; } @@ -285,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', + }), }; } @@ -292,6 +342,13 @@ 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', + }), }; } @@ -581,6 +638,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise { + 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', + }); + }); +}); From 506b61eca61381b8717d16b97be94b193e9fcbbf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 14:19:02 -0400 Subject: [PATCH 5/7] fix(cliproxy): propagate agy entitlement failure metadata --- src/cliproxy/quota-fetcher.ts | 9 ++++ .../quota-fetcher-antigravity-failure.test.ts | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 551c0889..06e7c392 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -698,6 +698,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.', + }), }; } @@ -814,6 +822,7 @@ export async function fetchAccountQuota( httpStatus: lastProjectResult.httpStatus, needsReauth: lastProjectResult.needsReauth, isUnprovisioned: lastProjectResult.isUnprovisioned, + entitlement: lastProjectResult.entitlement, isExpired: authData.isExpired, expiresAt: authData.expiresAt || undefined, }; diff --git a/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts b/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts index 6f79048a..817e7f3a 100644 --- a/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts @@ -28,4 +28,58 @@ describe('Antigravity quota failure metadata', () => { 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 }); + } + }); }); From b56b58c0679d888baa7daa7526f8c06f2a49e16c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 14:28:32 -0400 Subject: [PATCH 6/7] fix(cliproxy): preserve entitlement evidence on agy failures --- src/cliproxy/quota-fetcher.ts | 36 +++++ .../quota-fetcher-antigravity-failure.test.ts | 130 ++++++++++++++++++ ui/src/lib/api-client.ts | 24 +--- 3 files changed, 168 insertions(+), 22 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 06e7c392..dbe30a75 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -352,6 +352,28 @@ function buildAntigravityFailure( }; } +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 @@ -620,6 +642,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise { 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 }); + } + }); }); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 6a0e1bc4..a4fe2ed8 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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; @@ -566,28 +568,6 @@ export interface QuotaResult { 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 */ export interface CodexQuotaWindow { /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */ From 92a24786d674ca647caa44db9ee2ce638b843b55 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 14:32:45 -0400 Subject: [PATCH 7/7] fix(cliproxy): use type-only account provider import --- src/cliproxy/accounts/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts index d93b20c4..3d098f54 100644 --- a/src/cliproxy/accounts/types.ts +++ b/src/cliproxy/accounts/types.ts @@ -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';