mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 04:18:19 +00:00
Merge pull request #708 from kaitranntt/kai/fix/codex-quota-diagnostics
fix: improve quota diagnostics and oauth refresh handling
This commit is contained in:
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user