fix: PrimaryButton ref warning + research modal close race condition

This commit is contained in:
ajaysi
2026-04-22 08:48:35 +05:30
parent efff72f4bd
commit 973dd501fe
12 changed files with 367 additions and 50 deletions

View File

@@ -87,17 +87,34 @@ export const QuerySelection: React.FC<QuerySelectionProps> = ({
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const prevIsResearchingRef = useRef(isResearching);
const modalCloseTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Close modal only when research actually completes (transitions from true to false)
// Prevent closing while research is in progress
useEffect(() => {
// Clear any pending close timeout when research starts
if (researchStarted && isResearching) {
if (modalCloseTimeoutRef.current) {
clearTimeout(modalCloseTimeoutRef.current);
modalCloseTimeoutRef.current = null;
}
return;
}
const wasResearching = prevIsResearchingRef.current;
const nowNotResearching = !isResearching;
if (showResearchModal && researchStarted && wasResearching && nowNotResearching) {
setTimeout(() => setShowResearchModal(false), 1000);
modalCloseTimeoutRef.current = setTimeout(() => setShowResearchModal(false), 1000);
}
prevIsResearchingRef.current = isResearching;
return () => {
if (modalCloseTimeoutRef.current) {
clearTimeout(modalCloseTimeoutRef.current);
}
};
}, [isResearching, showResearchModal, researchStarted]);
// Progress message cycling

View File

@@ -14,7 +14,7 @@ interface PrimaryButtonProps {
size?: "small" | "medium" | "large";
}
export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
export const PrimaryButton = React.forwardRef<HTMLButtonElement, PrimaryButtonProps>(({
children,
onClick,
disabled = false,
@@ -25,7 +25,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
ariaLabel,
sx,
size = "medium",
}) => {
}, ref) => {
const sizeStyles = {
small: { px: 1.5, py: 0.5, fontSize: "0.75rem" },
medium: { px: 3, py: 1, fontSize: "0.875rem" },
@@ -34,6 +34,7 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
const button = (
<Button
ref={ref}
variant="contained"
onClick={onClick}
disabled={disabled || loading}
@@ -62,10 +63,12 @@ export const PrimaryButton: React.FC<PrimaryButtonProps> = ({
return tooltip ? (
<Tooltip title={tooltip} arrow>
<span>{button}</span>
{button}
</Tooltip>
) : (
button
);
};
});
PrimaryButton.displayName = "PrimaryButton";

View File

@@ -68,16 +68,16 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
{
label: 'AI Calls',
used: currentUsage.total_calls,
limit: limits.limits.gemini_calls || limits.limits.openai_calls || 50,
limit: limits.limits.ai_text_generation_calls || limits.limits.gemini_calls || limits.limits.openai_calls || 50,
color: '#3b82f6',
unlimited: false,
unlimited: limits.limits.ai_text_generation_calls === 0 && limits.limits.gemini_calls === 0 && limits.limits.openai_calls === 0,
},
{
label: 'Images',
used: imageCalls,
limit: limits.limits.stability_calls || 50,
color: '#a855f7',
unlimited: false,
unlimited: limits.limits.stability_calls === 0,
},
{
label: 'Videos',
@@ -85,6 +85,13 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
limit: limits.limits.video_calls,
color: '#ec4899',
unlimited: limits.limits.video_calls === 0,
},
{
label: 'Audio',
used: currentUsage.provider_breakdown?.audio?.calls ?? 0,
limit: limits.limits.audio_calls,
color: '#22c55e',
unlimited: limits.limits.audio_calls === 0,
}
].filter(item => item.unlimited || item.limit > 0);

View File

@@ -44,6 +44,7 @@ interface UsageStats {
interface UsageLimits {
limits: {
ai_text_generation_calls: number;
gemini_calls: number;
openai_calls: number;
anthropic_calls: number;
@@ -51,8 +52,12 @@ interface UsageLimits {
tavily_calls: number;
serper_calls: number;
metaphor_calls: number;
exa_calls: number;
firecrawl_calls: number;
stability_calls: number;
video_calls: number;
image_edit_calls: number;
audio_calls: number;
monthly_cost: number;
};
}
@@ -169,11 +174,11 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
};
const getUsageColor = (current: number, max: number) => {
if (max === 0) return '#757575';
if (max === 0) return '#9ca3af';
const percentage = (current / max) * 100;
if (percentage >= 100) return '#d32f2f'; // error
if (percentage >= 80) return '#ed6c02'; // warning
return '#2e7d32'; // success
if (percentage >= 100) return '#dc2626';
if (percentage >= 80) return '#ea580c';
return '#16a34a';
};
const getProviderDisplayName = (provider: string) => {
@@ -237,6 +242,35 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
const monthlyLimit = dashboardData?.limits?.limits?.monthly_cost || 0;
const usagePercentage = monthlyLimit > 0 ? (totalCost / monthlyLimit) * 100 : 0;
// Build per-category usage summaries from provider_breakdown and limits
const providerBreakdown = usageData.provider_breakdown || {};
const providerLimits = dashboardData?.limits?.limits || {};
// Aggregate AI text calls (gemini + openai + anthropic + mistral)
const aiCalls = (providerBreakdown.gemini?.calls || 0) + (providerBreakdown.openai?.calls || 0) + (providerBreakdown.anthropic?.calls || 0) + (providerBreakdown.mistral?.calls || 0) + (providerBreakdown.huggingface?.calls || 0) + (providerBreakdown.wavespeed?.calls || 0);
const aiCallLimit = providerLimits.ai_text_generation_calls || providerLimits.gemini_calls || 0;
// Image calls (stability + wavespeed image)
const imageCalls = (providerBreakdown.stability?.calls || 0) + (providerBreakdown.image_edit?.calls || 0);
const imageCallLimit = providerLimits.stability_calls || 0;
// Audio calls
const audioCalls = providerBreakdown.audio?.calls || 0;
const audioCallLimit = providerLimits.audio_calls || 0;
// Video calls
const videoCalls = providerBreakdown.video?.calls || 0;
const videoCallLimit = providerLimits.video_calls || 0;
// Research calls (exa + tavily + serper + firecrawl)
const researchCalls = (providerBreakdown.exa?.calls || 0) + (providerBreakdown.tavily?.calls || 0) + (providerBreakdown.serper?.calls || 0) + (providerBreakdown.firecrawl?.calls || 0);
const researchCallLimit = (providerLimits.exa_calls || 0) + (providerLimits.tavily_calls || 0) + (providerLimits.serper_calls || 0) + (providerLimits.firecrawl_calls || 0);
const formatLimit = (used: number, limit: number) => {
if (limit === 0) return `${used} / ∞`;
return `${used} / ${limit}`;
};
return (
<Box sx={{ width: '100%' }}>
{/* Priority 2 Alert Banner (Usage limits) */}
@@ -261,10 +295,10 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
sx={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'text.secondary',
color: '#374151',
'& .MuiSelect-select': { py: 0.5 }
}}
IconComponent={() => <CalendarMonth sx={{ fontSize: 16, color: 'action.active', ml: 0.5 }} />}
IconComponent={() => <CalendarMonth sx={{ fontSize: 16, color: '#6b7280', ml: 0.5 }} />}
>
{availablePeriods.map((period) => (
<MenuItem key={period} value={period} dense>
@@ -295,8 +329,8 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
size="small"
variant="outlined"
sx={{
bgcolor: `${getUsageColor(totalCost, monthlyLimit)}20`,
borderColor: getUsageColor(totalCost, monthlyLimit),
bgcolor: `${getUsageColor(totalCost, monthlyLimit)}10`,
borderColor: `${getUsageColor(totalCost, monthlyLimit)}60`,
color: getUsageColor(totalCost, monthlyLimit),
fontWeight: 600,
'& .MuiChip-icon': {
@@ -315,14 +349,14 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
width: 40,
height: 6,
borderRadius: 3,
bgcolor: 'rgba(0,0,0,0.1)',
bgcolor: '#e5e7eb',
'& .MuiLinearProgress-bar': {
bgcolor: getUsageColor(totalCost, monthlyLimit),
borderRadius: 3
}
}}
/>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600 }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, color: '#374151' }}>
{usagePercentage.toFixed(0)}%
</Typography>
</Box>
@@ -335,7 +369,8 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
disabled={loading}
sx={{
p: 0.5,
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
color: '#6b7280',
'&:hover': { bgcolor: '#f3f4f6' }
}}
>
<Refresh sx={{ fontSize: 16 }} />
@@ -349,12 +384,93 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
onClick={handleMenuOpen}
sx={{
p: 0.5,
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
color: '#6b7280',
'&:hover': { bgcolor: '#f3f4f6' }
}}
>
<MoreVert sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
{/* Per-Provider Usage Breakdown */}
<Box sx={{ mt: 1.5, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{aiCallLimit > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>AI Calls</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
<LinearProgress
variant="determinate"
value={aiCallLimit > 0 ? Math.min((aiCalls / aiCallLimit) * 100, 100) : 0}
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(aiCalls, aiCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(aiCalls, aiCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(aiCalls, aiCallLimit)}
</Typography>
</Box>
</Box>
)}
{imageCallLimit > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Images</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
<LinearProgress
variant="determinate"
value={imageCallLimit > 0 ? Math.min((imageCalls / imageCallLimit) * 100, 100) : 0}
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(imageCalls, imageCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(imageCalls, imageCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(imageCalls, imageCallLimit)}
</Typography>
</Box>
</Box>
)}
{audioCallLimit > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Audio</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
<LinearProgress
variant="determinate"
value={audioCallLimit > 0 ? Math.min((audioCalls / audioCallLimit) * 100, 100) : 0}
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(audioCalls, audioCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(audioCalls, audioCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(audioCalls, audioCallLimit)}
</Typography>
</Box>
</Box>
)}
{videoCallLimit > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Video</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
<LinearProgress
variant="determinate"
value={videoCallLimit > 0 ? Math.min((videoCalls / videoCallLimit) * 100, 100) : 0}
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(videoCalls, videoCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(videoCalls, videoCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(videoCalls, videoCallLimit)}
</Typography>
</Box>
</Box>
)}
{researchCallLimit > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 500, color: '#6b7280', minWidth: 60 }}>Research</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, ml: 1 }}>
<LinearProgress
variant="determinate"
value={researchCallLimit > 0 ? Math.min((researchCalls / researchCallLimit) * 100, 100) : 0}
sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#e5e7eb', '& .MuiLinearProgress-bar': { bgcolor: getUsageColor(researchCalls, researchCallLimit), borderRadius: 2 } }}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', fontWeight: 600, color: getUsageColor(researchCalls, researchCallLimit), minWidth: 55, textAlign: 'right' }}>
{formatLimit(researchCalls, researchCallLimit)}
</Typography>
</Box>
</Box>
)}
</Box>
<Menu
anchorEl={anchorEl}
@@ -362,24 +478,31 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
onClose={handleMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
bgcolor: '#ffffff',
border: '1px solid rgba(0,0,0,0.08)',
borderRadius: 2,
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
}
}}
>
<MenuItem onClick={handleViewFullDashboard}>
<MenuItem onClick={handleViewFullDashboard} sx={{ color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
<Dashboard sx={{ mr: 1, fontSize: 18 }} />
View Full Dashboard
</MenuItem>
<MenuItem onClick={handleRefresh}>
<MenuItem onClick={handleRefresh} sx={{ color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
<Refresh sx={{ mr: 1, fontSize: 18 }} />
Refresh Data
</MenuItem>
{lastUpdated && (
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" sx={{ color: '#9ca3af' }}>
Last updated: {lastUpdated.toLocaleTimeString()}
</Typography>
</Box>
)}
</Menu>
</Box>
</Box>
);
}

View File

@@ -172,54 +172,60 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: {
minWidth: 320,
maxWidth: 400,
maxHeight: '80vh',
overflow: 'auto'
minWidth: 340,
maxWidth: 420,
maxHeight: '85vh',
overflow: 'auto',
bgcolor: '#ffffff',
border: '1px solid rgba(0,0,0,0.08)',
borderRadius: 3,
boxShadow: '0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08)',
}
}}
>
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{/* User Info Header */}
<Box sx={{ px: 2.5, py: 2, bgcolor: '#f8f9fb', borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1a1a2e', fontSize: '0.9rem' }}>
{user?.fullName || user?.username || 'User'}
</Typography>
<Typography variant="caption" color="text.secondary">
<Typography variant="caption" sx={{ color: '#6b7280', fontSize: '0.75rem' }}>
{user?.primaryEmailAddress?.emailAddress}
</Typography>
</Box>
{/* Subscription Info in Menu */}
<Box sx={{ px: 2, py: 1.5, bgcolor: 'rgba(0,0,0,0.02)' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
{/* Subscription Info */}
<Box sx={{ px: 2.5, py: 1.5, bgcolor: '#f8f9fb' }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Current Plan
</Typography>
<Chip
label={getPlanLabel()}
size="small"
sx={{
bgcolor: `${getPlanColor()}20`,
border: `1px solid ${getPlanColor()}`,
bgcolor: `${getPlanColor()}15`,
border: `1.5px solid ${getPlanColor()}40`,
color: getPlanColor(),
fontWeight: 700,
fontSize: '0.75rem',
height: 26,
}}
/>
</Box>
<Divider sx={{ my: 1 }} />
<Divider sx={{ mx: 2 }} />
{/* System Status Indicator */}
<Box
sx={{
px: 2,
px: 2.5,
py: 1.5,
bgcolor: 'rgba(0,0,0,0.02)',
bgcolor: '#f8f9fb',
maxWidth: '100%',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
System Health
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', '& > *': { transform: 'scale(0.85)' } }}>
@@ -227,33 +233,33 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
</Box>
</Box>
<Divider sx={{ my: 1 }} />
<Divider sx={{ mx: 2 }} />
{/* Usage Dashboard */}
<Box
sx={{
px: 2,
px: 2.5,
py: 1.5,
bgcolor: 'rgba(0,0,0,0.02)',
bgcolor: '#ffffff',
maxWidth: '100%',
overflow: 'auto'
}}
onClick={(e) => e.stopPropagation()}
>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Usage Statistics
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: 0.5 }}>
<UsageDashboard compact={true} />
</Box>
<UsageDashboard compact={true} />
</Box>
<Divider sx={{ my: 1 }} />
<Divider sx={{ mx: 2 }} />
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }}>
<MenuItem onClick={() => { handleClose(); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
Manage Subscription
</MenuItem>
<MenuItem onClick={handleSignOut}>Sign out</MenuItem>
<MenuItem onClick={handleSignOut} sx={{ mx: 1, borderRadius: 1, color: '#6b7280', '&:hover': { bgcolor: '#fef2f2', color: '#ef4444' } }}>
Sign out
</MenuItem>
</Menu>
</Box>
);

View File

@@ -10,6 +10,7 @@ import { saveNavigationState, getCurrentPhaseForTool } from '../utils/navigation
import { showSubscriptionExpiredToast, showUsageLimitToast, showSubscriptionToast } from '../utils/toastNotifications';
export interface SubscriptionLimits {
ai_text_generation_calls: number;
gemini_calls: number;
openai_calls: number;
anthropic_calls: number;
@@ -17,8 +18,12 @@ export interface SubscriptionLimits {
tavily_calls: number;
serper_calls: number;
metaphor_calls: number;
exa_calls: number;
firecrawl_calls: number;
stability_calls: number;
video_calls: number;
image_edit_calls: number;
audio_calls: number;
monthly_cost: number;
}

View File

@@ -80,6 +80,16 @@ export const useSubscriptionGuard = (options: SubscriptionGuardOptions = {}) =>
return subscription.limits.firecrawl_calls;
case 'stability_calls':
return subscription.limits.stability_calls;
case 'video_calls':
return subscription.limits.video_calls || 0;
case 'image_edit_calls':
return subscription.limits.image_edit_calls || 0;
case 'audio_calls':
return subscription.limits.audio_calls || 0;
case 'ai_text_generation_calls':
return subscription.limits.ai_text_generation_calls || 0;
case 'exa_calls':
return subscription.limits.exa_calls || 0;
case 'monthly_cost':
return subscription.limits.monthly_cost;
default:

View File

@@ -147,6 +147,7 @@ const defaultLimits = {
plan_name: 'Unknown Plan',
tier: 'free' as const,
limits: {
ai_text_generation_calls: 0,
gemini_calls: 0,
openai_calls: 0,
anthropic_calls: 0,
@@ -154,8 +155,12 @@ const defaultLimits = {
tavily_calls: 0,
serper_calls: 0,
metaphor_calls: 0,
exa_calls: 0,
firecrawl_calls: 0,
stability_calls: 0,
video_calls: 0,
image_edit_calls: 0,
audio_calls: 0,
gemini_tokens: 0,
openai_tokens: 0,
anthropic_tokens: 0,
@@ -192,6 +197,7 @@ function coerceUsageStats(raw: any): UsageStats {
plan_name: raw?.limits?.plan_name ?? 'free',
tier: raw?.limits?.tier ?? 'free',
limits: {
ai_text_generation_calls: raw?.limits?.limits?.ai_text_generation_calls ?? 0,
gemini_calls: raw?.limits?.limits?.gemini_calls ?? 0,
openai_calls: raw?.limits?.limits?.openai_calls ?? 0,
anthropic_calls: raw?.limits?.limits?.anthropic_calls ?? 0,
@@ -199,10 +205,12 @@ function coerceUsageStats(raw: any): UsageStats {
tavily_calls: raw?.limits?.limits?.tavily_calls ?? 0,
serper_calls: raw?.limits?.limits?.serper_calls ?? 0,
metaphor_calls: raw?.limits?.limits?.metaphor_calls ?? 0,
exa_calls: raw?.limits?.limits?.exa_calls ?? 0,
firecrawl_calls: raw?.limits?.limits?.firecrawl_calls ?? 0,
stability_calls: raw?.limits?.limits?.stability_calls ?? 0,
video_calls: raw?.limits?.limits?.video_calls ?? 0,
image_edit_calls: raw?.limits?.limits?.image_edit_calls ?? 0,
audio_calls: raw?.limits?.limits?.audio_calls ?? 0,
gemini_tokens: raw?.limits?.limits?.gemini_tokens ?? 0,
openai_tokens: raw?.limits?.limits?.openai_tokens ?? 0,
anthropic_tokens: raw?.limits?.limits?.anthropic_tokens ?? 0,

View File

@@ -49,6 +49,7 @@ export interface SubscriptionLimits {
plan_name: string;
tier: 'free' | 'basic' | 'pro' | 'enterprise';
limits: {
ai_text_generation_calls: number;
gemini_calls: number;
openai_calls: number;
anthropic_calls: number;
@@ -56,10 +57,12 @@ export interface SubscriptionLimits {
tavily_calls: number;
serper_calls: number;
metaphor_calls: number;
exa_calls: number;
firecrawl_calls: number;
stability_calls: number;
video_calls: number;
image_edit_calls: number;
audio_calls: number;
gemini_tokens: number;
openai_tokens: number;
anthropic_tokens: number;
@@ -207,6 +210,7 @@ export const SubscriptionLimitsSchema = z.object({
plan_name: z.string(),
tier: z.enum(['free', 'basic', 'pro', 'enterprise']),
limits: z.object({
ai_text_generation_calls: z.number().optional().default(0),
gemini_calls: z.number(),
openai_calls: z.number(),
anthropic_calls: z.number(),
@@ -214,10 +218,12 @@ export const SubscriptionLimitsSchema = z.object({
tavily_calls: z.number(),
serper_calls: z.number(),
metaphor_calls: z.number(),
exa_calls: z.number().optional().default(0),
firecrawl_calls: z.number(),
stability_calls: z.number(),
video_calls: z.number().optional().default(0),
image_edit_calls: z.number().optional().default(0),
audio_calls: z.number().optional().default(0),
gemini_tokens: z.number(),
openai_tokens: z.number(),
anthropic_tokens: z.number(),