Merge pull request #708 from kaitranntt/kai/fix/codex-quota-diagnostics

fix: improve quota diagnostics and oauth refresh handling
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-07 01:25:05 -05:00
commit 85de3ab5e4
15 files changed
+1664 -442

No files matched your search

@@ -6,13 +6,22 @@ import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical, Loader2, Pause, Play, KeyRound } from 'lucide-react';
import {
GripVertical,
Loader2,
Pause,
Play,
KeyRound,
AlertTriangle,
AlertCircle,
} from 'lucide-react';
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -159,6 +168,19 @@ export function AccountCard({
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureTextClass =
failureInfo?.tone === 'warning'
? 'text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'text-destructive'
: 'text-muted-foreground/70';
// Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge =
@@ -325,28 +347,35 @@ export function AccountCard({
<div className="text-[8px] text-muted-foreground/60">
{t('accountCard.quotaUnavailable')}
</div>
) : quota?.needsReauth ? (
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
<KeyRound className="w-2.5 h-2.5" />
<span>{t('accountCard.reauthNeeded')}</span>
<div className={cn('flex items-center gap-1 text-[8px]', failureTextClass)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px]">
<p className="text-xs">
{quota.error?.includes('No refresh token')
? t('accountCard.removeAndReadd')
: quota.error || t('accountCard.autoRefreshFailed')}
</p>
<TooltipContent side="top" className="max-w-[220px]">
<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>
</Tooltip>
</TooltipProvider>
) : quota?.error ? (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
) : null}
</div>
)}
@@ -34,6 +34,7 @@ import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
@@ -170,6 +171,19 @@ export function AccountItem({
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureBadgeClass =
failureInfo?.tone === 'warning'
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'border-destructive/50 text-destructive'
: 'border-muted-foreground/50 text-muted-foreground';
return (
<div
@@ -439,47 +453,37 @@ export function AccountItem({
No limits
</Badge>
</div>
) : quota?.needsReauth ? (
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-amber-500/50 text-amber-600 dark:text-amber-400"
className={cn('text-[10px] h-5 px-2 gap-1', failureBadgeClass)}
>
<KeyRound className="w-3 h-3" />
Reauth
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px]">
<p className="text-xs">
{quota.error?.includes('No refresh token')
? 'No refresh token available. Remove and re-add account to fix.'
: quota.error?.includes('refresh') || quota.error?.includes('Invalid')
? `Auto-refresh failed: ${quota.error}`
: `Token issue: ${quota.error || 'Re-authenticate required'}`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.error || (quota && !quota.success) ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<AlertCircle className="w-3 h-3" />
N/A
</Badge>
<TooltipContent side="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>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">{quota?.error || 'Quota information unavailable'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -9,6 +9,7 @@ import {
formatQuotaPercent,
formatResetTime,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getCodexWindowDisplayLabel,
getModelsWithTiers,
groupModelsByTier,
@@ -67,7 +68,28 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
}
if (!quota.success) {
return <p className="text-xs text-destructive">{quota.error || 'Failed to load quota'}</p>;
const failureInfo = getQuotaFailureInfo(quota);
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>
{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>
);
}
// Antigravity (agy) provider tooltip
+50
View File
@@ -241,10 +241,20 @@ export interface QuotaResult {
models: ModelQuota[];
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
}
@@ -295,12 +305,22 @@ export interface CodexQuotaResult {
planType: 'free' | 'plus' | 'team' | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** Error message if fetch failed */
error?: string;
/** Account ID (email) this quota belongs to */
accountId?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
/** True if account lacks quota access (403) - displayed as 0% instead of error */
@@ -353,9 +373,15 @@ export interface ClaudeQuotaResult {
windows: ClaudeQuotaWindow[];
coreUsage?: ClaudeCoreUsageSummary;
lastUpdated: number;
httpStatus?: number;
errorCode?: string;
errorDetail?: string;
isForbidden?: boolean;
error?: string;
accountId?: string;
actionHint?: string;
needsReauth?: boolean;
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
@@ -388,12 +414,24 @@ export interface GeminiCliQuotaResult {
projectId: string | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Account ID (email) this quota belongs to */
accountId?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
@@ -435,12 +473,24 @@ export interface GhcpQuotaResult {
};
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Account ID this quota belongs to */
accountId?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
+41
View File
@@ -518,6 +518,20 @@ const resources = {
quota: 'Quota',
quotaUnavailable: 'Quota limits unavailable',
reauthNeeded: 'Reauth needed',
failureLabelReauth: 'Reauth',
failureLabelWorkspace: 'Workspace',
failureLabelNoAccess: 'No Access',
failureLabelRetry: 'Retry',
failureLabelReconnect: 'Reconnect',
failureLabelTemporary: 'Temporary',
failureLabelUnavailable: 'Unavailable',
failureHintReauth: 'Refresh this account by running its auth flow again.',
failureHintWorkspace:
'Move this account back to an active workspace, then remove and re-add it.',
failureHintNoAccess: 'This account cannot access the provider quota endpoint.',
failureHintRetry: 'Wait a bit, then retry the quota request.',
failureHintReconnect: 'Remove the stale account and authenticate it again.',
failureHintTemporary: 'Retry later. This looks temporary.',
removeAndReadd: 'Remove and re-add account',
autoRefreshFailed: 'Auto-refresh failed',
},
@@ -1645,6 +1659,19 @@ const resources = {
quota: '配额',
quotaUnavailable: '配额限制不可用',
reauthNeeded: '需要重新认证',
failureLabelReauth: '重新认证',
failureLabelWorkspace: '工作区',
failureLabelNoAccess: '无权限',
failureLabelRetry: '重试',
failureLabelReconnect: '重新连接',
failureLabelTemporary: '临时问题',
failureLabelUnavailable: '不可用',
failureHintReauth: '请重新运行该账号的认证流程以刷新访问权限。',
failureHintWorkspace: '请将该账号移回可用的工作区,然后移除并重新添加。',
failureHintNoAccess: '该账号无法访问提供商的配额接口。',
failureHintRetry: '稍后再试一次。',
failureHintReconnect: '请移除这个失效账号并重新认证。',
failureHintTemporary: '这看起来是临时问题,请稍后重试。',
removeAndReadd: '移除并重新添加账号',
autoRefreshFailed: '自动刷新失败',
},
@@ -2789,6 +2816,20 @@ const resources = {
quota: 'hạn ngạch',
quotaUnavailable: 'Thông tin quota chưa khả dụng',
reauthNeeded: 'Cần xác thực lại',
failureLabelReauth: 'Xác thực lại',
failureLabelWorkspace: 'Workspace',
failureLabelNoAccess: 'Không có quyền',
failureLabelRetry: 'Thử lại',
failureLabelReconnect: 'Kết nối lại',
failureLabelTemporary: 'Tạm thời',
failureLabelUnavailable: 'Chưa khả dụng',
failureHintReauth: 'Chạy lại luồng xác thực của tài khoản này để làm mới quyền truy cập.',
failureHintWorkspace:
'Chuyển tài khoản này về workspace còn hoạt động rồi xóa và thêm lại.',
failureHintNoAccess: 'Tài khoản này không thể truy cập endpoint quota của nhà cung cấp.',
failureHintRetry: 'Đợi một chút rồi thử lại yêu cầu quota.',
failureHintReconnect: 'Xóa tài khoản cũ rồi xác thực lại.',
failureHintTemporary: 'Có vẻ đây là lỗi tạm thời. Hãy thử lại sau.',
removeAndReadd: 'Xóa và thêm lại tài khoản',
autoRefreshFailed: 'Tự động làm mới không thành công',
},
+166
View File
@@ -9,6 +9,7 @@ import type {
GhcpQuotaResult,
QuotaResult,
} from './api-client';
import i18n from './i18n';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -718,6 +719,171 @@ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuota
// ==================== Unified Quota Helpers ====================
export interface QuotaFailureInfo {
label: string;
summary: string;
actionHint: string | null;
technicalDetail: string | null;
rawDetail: string | null;
tone: 'warning' | 'muted' | 'destructive';
}
function buildQuotaTechnicalDetail(quota: UnifiedQuotaResult): string | null {
const details: string[] = [];
if (typeof quota.httpStatus === 'number') {
details.push(`HTTP ${quota.httpStatus}`);
}
if (typeof quota.errorCode === 'string' && quota.errorCode.trim()) {
details.push(quota.errorCode.trim());
}
return details.length > 0 ? details.join(' | ') : null;
}
function buildQuotaRawDetail(
quota: UnifiedQuotaResult,
summary: string,
technicalDetail: string | null
): string | null {
const rawDetail = quota.errorDetail?.trim() || null;
if (!rawDetail) return null;
const normalizedRawDetail = rawDetail.toLowerCase();
if (normalizedRawDetail === summary.toLowerCase()) {
return null;
}
if (technicalDetail && normalizedRawDetail === technicalDetail.toLowerCase()) {
return null;
}
return rawDetail;
}
export function getQuotaFailureInfo(
quota: UnifiedQuotaResult | null | undefined
): QuotaFailureInfo | null {
if (!quota || quota.success) {
return null;
}
const summary = quota.error?.trim() || 'Quota information unavailable';
const actionHint = quota.actionHint?.trim() || null;
const errorCode = quota.errorCode?.trim().toLowerCase() || '';
const technicalDetail = buildQuotaTechnicalDetail(quota);
const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail);
const lowerSummary = summary.toLowerCase();
if (
quota.needsReauth ||
errorCode === 'token_expired' ||
errorCode === 'reauth_required' ||
lowerSummary.includes('token expired') ||
lowerSummary.includes('re-authenticate') ||
lowerSummary.includes('reauth') ||
lowerSummary.includes('expired or invalid')
) {
return {
label: i18n.t('accountCard.failureLabelReauth'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintReauth'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
if (
errorCode === 'deactivated_workspace' ||
quota.httpStatus === 402 ||
lowerSummary.includes('workspace deactivated') ||
lowerSummary.includes('payment or workspace access required')
) {
return {
label: i18n.t('accountCard.failureLabelWorkspace'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintWorkspace'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
if (
quota.isForbidden ||
quota.httpStatus === 403 ||
errorCode === 'quota_api_forbidden' ||
lowerSummary.includes('forbidden')
) {
return {
label: i18n.t('accountCard.failureLabelNoAccess'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintNoAccess'),
technicalDetail,
rawDetail,
tone: 'muted',
};
}
if (
quota.httpStatus === 429 ||
errorCode === 'rate_limited' ||
lowerSummary.includes('rate limited')
) {
return {
label: i18n.t('accountCard.failureLabelRetry'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintRetry'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
if (
errorCode === 'auth_file_missing' ||
errorCode === 'missing_account_id' ||
lowerSummary.includes('auth file not found') ||
lowerSummary.includes('missing chatgpt-account-id')
) {
return {
label: i18n.t('accountCard.failureLabelReconnect'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintReconnect'),
technicalDetail,
rawDetail,
tone: 'muted',
};
}
if (
quota.retryable ||
errorCode === 'network_timeout' ||
errorCode === 'network_error' ||
errorCode === 'provider_unavailable' ||
lowerSummary.includes('timeout') ||
lowerSummary.includes('network') ||
lowerSummary.includes('fetch failed') ||
lowerSummary.includes('service unavailable')
) {
return {
label: i18n.t('accountCard.failureLabelTemporary'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintTemporary'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
return {
label: i18n.t('accountCard.failureLabelUnavailable'),
summary,
actionHint,
technicalDetail,
rawDetail,
tone: 'muted',
};
}
/**
* Get minimum quota percentage for any provider
* Centralizes provider-specific logic to eliminate duplication
+100
View File
@@ -12,6 +12,7 @@ import {
getCodexWindowDisplayLabel,
getMinGeminiQuota,
getGeminiResetTime,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isAgyQuotaResult,
@@ -1724,3 +1725,102 @@ describe('getProviderResetTime', () => {
});
});
});
describe('getQuotaFailureInfo', () => {
it('maps reauth failures to a clear reauth badge and technical detail', () => {
const quota: CodexQuotaResult = {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Codex token expired or invalid',
needsReauth: true,
httpStatus: 401,
errorCode: 'reauth_required',
errorDetail: '{"detail":"session expired"}',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Reauth',
summary: 'Codex token expired or invalid',
actionHint: 'Refresh this account by running its auth flow again.',
technicalDetail: 'HTTP 401 | reauth_required',
rawDetail: '{"detail":"session expired"}',
tone: 'warning',
});
});
it('maps 402 workspace failures to a workspace badge and actionable hint', () => {
const quota: CodexQuotaResult = {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Workspace deactivated (HTTP 402)',
httpStatus: 402,
errorCode: 'deactivated_workspace',
errorDetail: '{"detail":{"code":"deactivated_workspace"}}',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Workspace',
summary: 'Workspace deactivated (HTTP 402)',
actionHint: 'Move this account back to an active workspace, then remove and re-add it.',
technicalDetail: 'HTTP 402 | deactivated_workspace',
rawDetail: '{"detail":{"code":"deactivated_workspace"}}',
tone: 'warning',
});
});
it('maps retryable network failures to a temporary badge', () => {
const quota: GeminiCliQuotaResult = {
success: false,
buckets: [],
projectId: null,
lastUpdated: Date.now(),
error: 'Network timeout while fetching quota',
errorCode: 'network_timeout',
retryable: true,
errorDetail: 'ETIMEDOUT',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Temporary',
summary: 'Network timeout while fetching quota',
actionHint: 'Retry later. This looks temporary.',
technicalDetail: 'network_timeout',
rawDetail: 'ETIMEDOUT',
tone: 'warning',
});
});
it('returns null when quota fetch succeeded', () => {
const quota: QuotaResult = {
success: true,
models: [],
lastUpdated: Date.now(),
};
expect(getQuotaFailureInfo(quota)).toBeNull();
});
it('suppresses raw detail when it only duplicates the summary', () => {
const quota: CodexQuotaResult = {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Quota fetch failed',
errorDetail: 'Quota fetch failed',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Temporary',
summary: 'Quota fetch failed',
actionHint: 'Retry later. This looks temporary.',
technicalDetail: null,
rawDetail: null,
tone: 'warning',
});
});
});