diff --git a/src/cliproxy/auth-utils.ts b/src/cliproxy/auth-utils.ts index 77e371b1..79f8628f 100644 --- a/src/cliproxy/auth-utils.ts +++ b/src/cliproxy/auth-utils.ts @@ -16,12 +16,35 @@ export function sanitizeEmail(email: string): string { * Check if token is expired based on the expired timestamp. * Returns false if timestamp is missing or invalid (fail-open for quota display). */ -export function isTokenExpired(expiredStr?: string): boolean { - if (!expiredStr) return false; +export function getTokenExpiryTimestamp(expiredValue?: string | number | null): number | null { + if (expiredValue === undefined || expiredValue === null || expiredValue === '') { + return null; + } + try { - const expiredDate = new Date(expiredStr); - return expiredDate.getTime() < Date.now(); + if (typeof expiredValue === 'number') { + return Number.isFinite(expiredValue) ? expiredValue : null; + } + + const trimmed = expiredValue.trim(); + if (!trimmed) { + return null; + } + + if (/^\d+$/.test(trimmed)) { + const numericTimestamp = Number(trimmed); + return Number.isFinite(numericTimestamp) ? numericTimestamp : null; + } + + const expiredDate = new Date(trimmed); + const expiredAt = expiredDate.getTime(); + return Number.isNaN(expiredAt) ? null : expiredAt; } catch { - return false; + return null; } } + +export function isTokenExpired(expiredValue?: string | number | null): boolean { + const expiredAt = getTokenExpiryTimestamp(expiredValue); + return expiredAt !== null ? expiredAt < Date.now() : false; +} diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 0ec6afef..64882d4c 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -9,7 +9,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 { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils'; import { refreshGeminiToken } from './auth/gemini-token-refresh'; import { buildGeminiCliBucketsFromParsedBuckets, @@ -38,7 +38,7 @@ interface GeminiCliAuthData { accessToken: string; projectId: string | null; isExpired: boolean; - expiresAt: string | null; + expiresAt: string | number | null; } /** Raw bucket from API response */ @@ -130,17 +130,23 @@ function extractAccessToken(data: Record): string | null { * Extract expiry from Gemini auth file data * Handles both flat (expired) and nested (token.expiry) structures */ -function extractExpiry(data: Record): string | null { +function extractExpiry(data: Record): string | number | null { // Flat structure: { expired: "..." } if (typeof data.expired === 'string') { return data.expired; } + if (typeof data.expired === 'number') { + return data.expired; + } // Nested structure: { token: { expiry: "..." } } if (data.token && typeof data.token === 'object') { const token = data.token as Record; if (typeof token.expiry === 'string') { return token.expiry; } + if (typeof token.expiry === 'number') { + return token.expiry; + } } return null; } @@ -736,19 +742,20 @@ export async function fetchGeminiCliQuota( // Proactive refresh: refresh if expired OR expiring within 5 minutes const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const shouldRefresh = - authData.isExpired || - !authData.expiresAt || - new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; + authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; + let attemptedRefresh = false; if (shouldRefresh) { + attemptedRefresh = true; if (verbose) console.error( authData.isExpired ? '[i] Token expired, refreshing...' : '[i] Token expiring soon, proactive refresh...' ); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { if (verbose) console.error('[i] Token refreshed successfully'); @@ -776,10 +783,10 @@ export async function fetchGeminiCliQuota( // First attempt with current token const result = await fetchWithAuthData(authData, accountId, verbose); - // If 401 error and we haven't refreshed yet, try refresh and retry - if (result.needsReauth && result.error?.includes('expired')) { + // Retry once with an account-scoped refresh when the quota endpoint rejects auth. + if (result.needsReauth && !attemptedRefresh) { if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { const refreshedAuthData = readGeminiCliAuthData(accountId); if (refreshedAuthData) { diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts index a6003b80..b809031a 100644 --- a/tests/unit/cliproxy/auth-utils.test.ts +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'bun:test'; -import { sanitizeEmail, isTokenExpired } from '../../../src/cliproxy/auth-utils'; +import { + getTokenExpiryTimestamp, + isTokenExpired, + sanitizeEmail, +} from '../../../src/cliproxy/auth-utils'; describe('Auth Utilities', () => { describe('sanitizeEmail', () => { @@ -75,14 +79,21 @@ describe('Auth Utilities', () => { expect(isTokenExpired(futureISO)).toBe(false); }); - it('should handle Unix timestamp strings', () => { - // JavaScript Date can parse numeric strings as timestamps + it('should handle Unix timestamp strings deterministically', () => { const pastTimestamp = String(Date.now() - 86400000); // Yesterday - // Note: Date parsing of pure numbers as strings is inconsistent - // This test documents the actual behavior - const result = isTokenExpired(pastTimestamp); - // The behavior depends on how Date parses the string - expect(typeof result).toBe('boolean'); + expect(isTokenExpired(pastTimestamp)).toBe(true); + }); + + it('should handle Unix timestamps provided as numbers', () => { + const futureTimestamp = Date.now() + 86400000; + expect(isTokenExpired(futureTimestamp)).toBe(false); + }); + + it('should expose normalized expiry timestamps for string and numeric inputs', () => { + const futureTimestamp = Date.now() + 60000; + expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp('not-a-date')).toBeNull(); }); }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 8c2d1b18..6df70140 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -27,10 +27,10 @@ describe('Gemini CLI Quota Fetcher', () => { let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; - function writeGeminiToken(token: Record): string { + function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { const authDir = getProviderAuthDir('gemini'); fs.mkdirSync(authDir, { recursive: true }); - const tokenPath = path.join(authDir, 'gemini-test.json'); + const tokenPath = path.join(authDir, filename); fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2)); return tokenPath; } @@ -649,6 +649,74 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); expect(result.errorDetail).toBe('[HTML error response omitted]'); }); + + it('refreshes the requested Gemini account instead of the default account', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'default@example.com', + project_id: 'default-project', + token: { + access_token: 'default-access-token', + refresh_token: 'default-refresh-token', + expiry: Date.now() + 60 * 60 * 1000, + client_id: 'default-client-id', + client_secret: 'default-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-default.json' + ); + + writeGeminiToken( + { + type: 'gemini', + email: 'target@example.com', + project_id: 'target-project', + token: { + access_token: 'target-stale-token', + refresh_token: 'target-refresh-token', + expiry: Date.now() - 1000, + client_id: 'target-client-id', + client_secret: 'target-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-target.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'target-fresh-token', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const result = await fetchGeminiCliQuota('target@example.com'); + + expect(result.success).toBe(true); + + const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); + expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); + expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); + expect(refreshRequest.body).not.toContain('default-refresh-token'); + expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index b4f65156..e66e4195 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -249,7 +249,7 @@ export function AccountCard({ )} - + diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index 95c4fb57..88ffa4f9 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -250,7 +250,7 @@ export function AccountQuotaPanel({ )} - + @@ -306,23 +306,8 @@ export function AccountQuotaPanel({ )} - -
-

{failureInfo.summary}

- {failureInfo.actionHint && ( -

{failureInfo.actionHint}

- )} - {failureInfo.technicalDetail && ( -

- {failureInfo.technicalDetail} -

- )} - {failureInfo.rawDetail && ( -
-                {failureInfo.rawDetail}
-              
- )} -
+ + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 25edae1b..305cd98f 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -86,22 +86,35 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (!quota.success) { const failureInfo = getQuotaFailureInfo(quota); + const failureToneClass = + failureInfo?.tone === 'destructive' + ? 'text-destructive' + : failureInfo?.tone === 'warning' + ? 'text-amber-700 dark:text-amber-300' + : 'text-foreground'; + return ( -
-

- {failureInfo?.label || quota.error || 'Failed to load quota'} -

-

{failureInfo?.summary || quota.error}

+
+
+

+ {failureInfo?.label || quota.error || 'Failed to load quota'} +

+

+ {failureInfo?.summary || quota.error} +

+
{failureInfo?.actionHint && ( -

{failureInfo.actionHint}

+
+ {failureInfo.actionHint} +
)} {failureInfo?.technicalDetail && ( -

+

{failureInfo.technicalDetail} -

+
)} {failureInfo?.rawDetail && ( -
+          
             {failureInfo.rawDetail}
           
)} @@ -116,7 +129,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; return ( -
+

Model Quotas:

{tierOrder.map((tier, idx) => { const models = groups.get(tier); @@ -124,7 +137,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); return (
- {!isFirst &&
} + {!isFirst &&
} {models.map((m) => (
@@ -159,7 +172,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro ); return ( -
+

Rate Limits:

{quota.planType &&

Plan: {quota.planType}

} {orderedWindows.map((w, index) => ( @@ -228,7 +241,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro null; return ( -
+

Rate Limits:

{orderedWindows.map((window, index) => (
!!bucket.resetTime); return ( -
+
{quota.tierLabel && (
Tier @@ -306,7 +319,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const planLabel = formatPlanLabel(quota.planType); return ( -
+

Quota Snapshots:

{planLabel &&

Plan: {planLabel}

} {snapshotRows.map(({ label, snapshot }) => { @@ -344,9 +357,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { if (!resetTime) return null; return ( -
- - Resets {formatResetTime(resetTime)} +
+ + + Resets {formatResetTime(resetTime)} +
); } @@ -364,19 +379,19 @@ function CodexResetIndicators({ if (!hasSpecificReset && !fallbackResetTime) return null; return ( -
+
{fiveHourResetTime && (
- - + + 5h resets {formatResetTime(fiveHourResetTime)}
)} {weeklyResetTime && (
- - + + Weekly resets {formatResetTime(weeklyResetTime)}
diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx index 07b74e1a..b78a6417 100644 --- a/ui/src/components/ui/popover.tsx +++ b/ui/src/components/ui/popover.tsx @@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 w-80 max-w-[calc(100vw-2rem)] rounded-lg border border-border/70 bg-popover p-4 text-popover-foreground shadow-xl shadow-black/10 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index d4011c57..2d5995df 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps) { @@ -42,13 +42,13 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance', + 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', className )} {...props} > {children} - + ); diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 8086b0a9..65401d33 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -133,6 +133,9 @@ describe('AccountCard grouped quota tooltip', () => { await userEvent.hover(screen.getByText('Business')); expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0); expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0); + const tooltipContent = document.querySelector('[data-slot="tooltip-content"]'); + expect(tooltipContent?.className).toContain('bg-popover'); + expect(tooltipContent?.className).toContain('text-popover-foreground'); await userEvent.hover(screen.getByText('Personal')); expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0); 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 73ab0923..d47bca8b 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 @@ -90,4 +90,28 @@ describe('QuotaTooltipContent', () => { vi.useRealTimers(); }); + + it('renders failure summaries, action hints, and raw details with readable structure', () => { + const quota = createGeminiQuotaResult({ + success: false, + buckets: [], + error: 'Request had invalid authentication credentials.', + httpStatus: 401, + errorCode: 'UNAUTHENTICATED', + errorDetail: + '{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + }); + + render(); + + expect(screen.getByText('Reauth')).toBeInTheDocument(); + expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument(); + expect( + screen.getByText('Run ccs gemini --auth to reconnect this account.') + ).toBeInTheDocument(); + expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument(); + expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument(); + }); });