fix(cliproxy): refresh non-default gemini quota tokens

This commit is contained in:
Tam Nhu Tran
2026-04-08 23:55:55 -04:00
parent 04878f0864
commit b6fb443f57
11 changed files with 207 additions and 71 deletions
+28 -5
View File
@@ -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;
}
+17 -10
View File
@@ -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, unknown>): string | null {
* Extract expiry from Gemini auth file data
* Handles both flat (expired) and nested (token.expiry) structures
*/
function extractExpiry(data: Record<string, unknown>): string | null {
function extractExpiry(data: Record<string, unknown>): 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<string, unknown>;
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) {
+19 -8
View File
@@ -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();
});
});
});
@@ -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, unknown>): string {
function writeGeminiToken(token: Record<string, unknown>, 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', () => {
@@ -249,7 +249,7 @@ export function AccountCard({
)}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<TooltipContent side="top" className="max-w-sm">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
@@ -250,7 +250,7 @@ export function AccountQuotaPanel({
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs">
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
@@ -306,23 +306,8 @@ export function AccountQuotaPanel({
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -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 (
<div className="text-xs space-y-1">
<p className="font-medium text-destructive">
{failureInfo?.label || quota.error || 'Failed to load quota'}
</p>
<p className="text-destructive/90">{failureInfo?.summary || quota.error}</p>
<div className="min-w-[16rem] space-y-2 text-xs">
<div className="space-y-1">
<p className={cn('font-semibold tracking-tight', failureToneClass)}>
{failureInfo?.label || quota.error || 'Failed to load quota'}
</p>
<p className="leading-relaxed text-foreground/90">
{failureInfo?.summary || quota.error}
</p>
</div>
{failureInfo?.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
<div className="rounded-md border border-border/70 bg-muted/35 px-2.5 py-2 text-foreground/80">
{failureInfo.actionHint}
</div>
)}
{failureInfo?.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
<div className="rounded-md border border-border/60 bg-muted/25 px-2 py-1.5 font-mono text-[11px] text-foreground/75">
{failureInfo.technicalDetail}
</p>
</div>
)}
{failureInfo?.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/70 bg-muted/55 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-foreground/85">
{failureInfo.rawDetail}
</pre>
)}
@@ -116,7 +129,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return (
<div className="text-xs space-y-1">
<div className="text-xs space-y-1.5">
<p className="font-medium">Model Quotas:</p>
{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 (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{!isFirst && <div className="my-1 border-t border-border/40" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
@@ -159,7 +172,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
);
return (
<div className="text-xs space-y-1">
<div className="text-xs space-y-1.5">
<p className="font-medium">Rate Limits:</p>
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
{orderedWindows.map((w, index) => (
@@ -228,7 +241,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
null;
return (
<div className="text-xs space-y-1">
<div className="text-xs space-y-1.5">
<p className="font-medium">Rate Limits:</p>
{orderedWindows.map((window, index) => (
<div
@@ -255,7 +268,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
return (
<div className="text-xs space-y-1">
<div className="text-xs space-y-1.5">
{quota.tierLabel && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">Tier</span>
@@ -306,7 +319,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
const planLabel = formatPlanLabel(quota.planType);
return (
<div className="text-xs space-y-1">
<div className="text-xs space-y-1.5">
<p className="font-medium">Quota Snapshots:</p>
{planLabel && <p className="text-muted-foreground">Plan: {planLabel}</p>}
{snapshotRows.map(({ label, snapshot }) => {
@@ -344,9 +357,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) {
if (!resetTime) return null;
return (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">Resets {formatResetTime(resetTime)}</span>
<div className="flex items-center gap-1.5 border-t border-border/60 pt-1">
<Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
<span className="font-medium text-sky-600 dark:text-sky-300">
Resets {formatResetTime(resetTime)}
</span>
</div>
);
}
@@ -364,19 +379,19 @@ function CodexResetIndicators({
if (!hasSpecificReset && !fallbackResetTime) return null;
return (
<div className="pt-1 border-t border-border/50 space-y-1">
<div className="space-y-1 border-t border-border/60 pt-1">
{fiveHourResetTime && (
<div className="flex items-center gap-1.5">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
<Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
<span className="font-medium text-sky-600 dark:text-sky-300">
5h resets {formatResetTime(fiveHourResetTime)}
</span>
</div>
)}
{weeklyResetTime && (
<div className="flex items-center gap-1.5">
<Clock className="w-3 h-3 text-indigo-400" />
<span className="text-indigo-400 font-medium">
<Clock className="h-3 w-3 text-indigo-600 dark:text-indigo-300" />
<span className="font-medium text-indigo-600 dark:text-indigo-300">
Weekly resets {formatResetTime(weeklyResetTime)}
</span>
</div>
+1 -1
View File
@@ -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}
+3 -3
View File
@@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
function TooltipContent({
className,
sideOffset = 0,
sideOffset = 6,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
@@ -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}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 fill-popover stroke-border stroke-[0.75px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
@@ -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);
@@ -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(<QuotaTooltipContent quota={quota} resetTime={null} />);
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();
});
});