From 81052d06b430d7b9ca98ed9e5aab8ae14ec06280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Thu, 5 Mar 2026 11:10:54 +0530 Subject: [PATCH 1/5] Fix preflight model mapping when skipping invalid providers --- backend/api/subscription/routes/preflight.py | 21 +++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/api/subscription/routes/preflight.py b/backend/api/subscription/routes/preflight.py index 2a55eedd..e7ebd1a0 100644 --- a/backend/api/subscription/routes/preflight.py +++ b/backend/api/subscription/routes/preflight.py @@ -75,7 +75,10 @@ async def preflight_check( 'provider': provider_enum, 'tokens_requested': op.tokens_requested or 0, 'actual_provider_name': op.actual_provider_name or op.provider, - 'operation_type': op.operation_type + 'operation_type': op.operation_type, + # Keep the originating request fields together so model lookup + # cannot drift when invalid providers are skipped. + 'model': op.model }) except Exception as e: logger.warning(f"Error processing operation {op.operation_type}: {e}") @@ -94,7 +97,7 @@ async def preflight_check( operation_results = [] total_cost = 0.0 - for i, op in enumerate(operations_to_validate): + for op in operations_to_validate: op_result = { 'provider': op['actual_provider_name'], 'operation_type': op['operation_type'], @@ -105,7 +108,7 @@ async def preflight_check( } # Get pricing for this operation - model_name = request.operations[i].model + model_name = op.get('model') if model_name: pricing_info = pricing_service.get_pricing_for_provider_model( op['provider'], @@ -124,11 +127,15 @@ async def preflight_check( chars = max(0, int(op.get('tokens_requested') or 0)) cost = max(0.005, 0.005 * (chars / 100.0)) else: - # Audio pricing is per character (every character is 1 token) - cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * (op['tokens_requested'] / 1000.0) + # Audio pricing uses per-token/per-character unit pricing from DB. + # Do not divide by 1000 here: pricing values are already normalized + # as per-unit costs in APIProviderPricing. + cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * op['tokens_requested'] elif op['tokens_requested'] > 0: - # Token-based cost estimation (rough estimate) - cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * (op['tokens_requested'] / 1000) + # Token-based cost estimation (rough estimate). + # IMPORTANT: cost_per_input_token is stored as cost-per-token. + # Multiplying by tokens_requested gives the correct estimate. + cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * op['tokens_requested'] else: cost = pricing_info.get('cost_per_request', 0.0) or 0.0 From 45dbf095f6470958db14ca9b9c71b959089e9534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Thu, 5 Mar 2026 11:17:21 +0530 Subject: [PATCH 2/5] Add Stripe webhook idempotency persistence guard --- backend/models/subscription_models.py | 14 +++ .../services/subscription/stripe_service.py | 105 +++++++++++++++--- 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/backend/models/subscription_models.py b/backend/models/subscription_models.py index 25f9b788..996bc870 100644 --- a/backend/models/subscription_models.py +++ b/backend/models/subscription_models.py @@ -408,3 +408,17 @@ class FraudWarning(Base): reason_notes = Column(Text, nullable=True) meta_info = Column(JSON, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) + + +class StripeWebhookEvent(Base): + """Processed Stripe webhook events for idempotency and replay protection.""" + + __tablename__ = "stripe_webhook_events" + + event_id = Column(String(100), primary_key=True) + event_type = Column(String(100), nullable=False) + status = Column(String(20), nullable=False, default="processing") # processing, processed, failed + error_message = Column(Text, nullable=True) + processed_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + diff --git a/backend/services/subscription/stripe_service.py b/backend/services/subscription/stripe_service.py index afd0e133..75d370ed 100644 --- a/backend/services/subscription/stripe_service.py +++ b/backend/services/subscription/stripe_service.py @@ -4,7 +4,8 @@ from typing import Optional, Dict, Any from loguru import logger from fastapi import HTTPException from sqlalchemy.orm import Session -from models.subscription_models import UserSubscription, SubscriptionPlan, SubscriptionTier, BillingCycle, UsageStatus, FraudWarning +from sqlalchemy.exc import IntegrityError +from models.subscription_models import UserSubscription, SubscriptionPlan, SubscriptionTier, BillingCycle, UsageStatus, FraudWarning, StripeWebhookEvent from services.subscription.pricing_service import PricingService from datetime import datetime @@ -216,6 +217,35 @@ class StripeService: logger.error(f"Error creating portal session: {e}") raise HTTPException(status_code=500, detail=str(e)) + def _ensure_webhook_event_table(self) -> None: + """Ensure webhook idempotency table exists before processing events.""" + try: + bind = self.db.get_bind() + if bind is not None: + StripeWebhookEvent.__table__.create(bind=bind, checkfirst=True) + except Exception as e: + logger.warning(f"Failed to ensure stripe_webhook_events table exists: {e}") + + def _mark_webhook_event_status( + self, + event_id: str, + status: str, + error_message: Optional[str] = None, + ) -> None: + """Update persisted webhook event processing status.""" + event_row = self.db.query(StripeWebhookEvent).filter( + StripeWebhookEvent.event_id == event_id + ).first() + if not event_row: + return + + event_row.status = status + event_row.error_message = (error_message or "")[:1000] if error_message else None + if status == "processed": + event_row.processed_at = datetime.utcnow() + + self.db.commit() + async def handle_webhook(self, payload: bytes, sig_header: str): """ Handle Stripe webhooks. @@ -235,25 +265,64 @@ class StripeService: logger.error(f"Invalid signature: {e}") raise HTTPException(status_code=400, detail="Invalid signature") - event_type = event["type"] - data = event["data"]["object"] + event_id = event.get("id") + event_type = event.get("type") + data = event.get("data", {}).get("object", {}) - logger.info(f"Received Stripe webhook: {event_type}") - - if event_type == "checkout.session.completed": - await self._handle_checkout_completed(data) - elif event_type == "invoice.payment_succeeded": - await self._handle_invoice_payment_succeeded(data) - elif event_type == "invoice.payment_failed": - await self._handle_invoice_payment_failed(data) - elif event_type == "customer.subscription.updated": - await self._handle_subscription_updated(data) - elif event_type == "customer.subscription.deleted": - await self._handle_subscription_deleted(data) - elif event_type.startswith("radar.early_fraud_warning."): - await self._handle_early_fraud_warning(data) + if not event_id or not event_type: + logger.error("Stripe webhook missing event id/type") + raise HTTPException(status_code=400, detail="Invalid Stripe event payload") - return {"status": "success"} + # Idempotency guard: persist event id before mutating subscription state. + self._ensure_webhook_event_table() + existing_event = self.db.query(StripeWebhookEvent).filter( + StripeWebhookEvent.event_id == event_id + ).first() + if existing_event: + logger.info(f"Skipping already processed Stripe event: {event_id} ({existing_event.status})") + return {"status": "success", "idempotent": True} + + try: + event_row = StripeWebhookEvent( + event_id=event_id, + event_type=event_type, + status="processing", + ) + self.db.add(event_row) + self.db.commit() + except IntegrityError: + self.db.rollback() + logger.info(f"Skipping duplicate Stripe event insert: {event_id}") + return {"status": "success", "idempotent": True} + + logger.info(f"Received Stripe webhook: {event_type} ({event_id})") + + try: + if event_type == "checkout.session.completed": + await self._handle_checkout_completed(data) + elif event_type == "invoice.payment_succeeded": + await self._handle_invoice_payment_succeeded(data) + elif event_type == "invoice.payment_failed": + await self._handle_invoice_payment_failed(data) + elif event_type == "customer.subscription.updated": + await self._handle_subscription_updated(data) + elif event_type == "customer.subscription.deleted": + await self._handle_subscription_deleted(data) + elif event_type.startswith("radar.early_fraud_warning."): + await self._handle_early_fraud_warning(data) + + self._mark_webhook_event_status(event_id=event_id, status="processed") + return {"status": "success"} + + except Exception as e: + self.db.rollback() + self._mark_webhook_event_status( + event_id=event_id, + status="failed", + error_message=str(e), + ) + logger.error(f"Failed Stripe webhook handling for {event_id}: {e}") + raise async def _handle_checkout_completed(self, session: Dict[str, Any]): """ From 81f49f4ebd204bef3b6f349b4e853bd218e315d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Thu, 5 Mar 2026 11:17:48 +0530 Subject: [PATCH 3/5] Add explicit usage summary uniqueness and billing indexes --- backend/models/subscription_models.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/models/subscription_models.py b/backend/models/subscription_models.py index 996bc870..72dd66a2 100644 --- a/backend/models/subscription_models.py +++ b/backend/models/subscription_models.py @@ -6,7 +6,7 @@ Comprehensive models for usage-based subscription system with API cost tracking. # Ensure Optional is available in global scope for dynamic imports from typing import Optional -from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, JSON, Text, ForeignKey, Enum +from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, JSON, Text, ForeignKey, Enum, Index, UniqueConstraint from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship from datetime import datetime, timedelta @@ -174,6 +174,9 @@ class APIUsageLog(Base): # Indexes for performance __table_args__ = ( + Index('ix_api_usage_logs_user_period_ts', 'user_id', 'billing_period', 'timestamp'), + Index('ix_api_usage_logs_user_provider_ts', 'user_id', 'provider', 'timestamp'), + Index('ix_api_usage_logs_user_status_ts', 'user_id', 'status_code', 'timestamp'), {'mysql_engine': 'InnoDB'}, ) @@ -241,6 +244,8 @@ class UsageSummary(Base): # Unique constraint on user_id and billing_period __table_args__ = ( + UniqueConstraint('user_id', 'billing_period', name='uq_usage_summaries_user_period'), + Index('ix_usage_summaries_user_period', 'user_id', 'billing_period'), {'mysql_engine': 'InnoDB'}, ) @@ -276,6 +281,7 @@ class APIProviderPricing(Base): # Unique constraint on provider and model __table_args__ = ( + UniqueConstraint('provider', 'model_name', name='uq_api_provider_pricing_provider_model'), {'mysql_engine': 'InnoDB'}, ) From 7d530b3220711282444b0ddb188a92aac3091197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Thu, 5 Mar 2026 11:31:49 +0530 Subject: [PATCH 4/5] Preserve full provider breakdown in billing UI coercion --- frontend/src/services/billingService.ts | 181 ++++++++++++------------ frontend/src/types/billing.ts | 6 +- 2 files changed, 89 insertions(+), 98 deletions(-) diff --git a/frontend/src/services/billingService.ts b/frontend/src/services/billingService.ts index 8cb10384..6c2b77cd 100644 --- a/frontend/src/services/billingService.ts +++ b/frontend/src/services/billingService.ts @@ -20,7 +20,6 @@ import { ProviderBreakdown, UsagePercentages, ProviderUsage, - ProviderBreakdownSchema, SubscriptionRenewal, RenewalHistoryResponse, RenewalHistoryAPIResponse, @@ -122,11 +121,6 @@ billingAPI.interceptors.response.use( const defaultProviderUsage = { calls: 0, tokens: 0, cost: 0 }; -const defaultProviderBreakdown = { - gemini: { ...defaultProviderUsage }, - huggingface: { ...defaultProviderUsage }, -}; - const defaultLimits = { plan_name: 'Unknown Plan', tier: 'free' as const, @@ -196,79 +190,65 @@ function coerceUsageStats(raw: any): UsageStats { features: raw?.limits?.features ?? [], }; - // Extract provider breakdown - only include gemini and huggingface - // Backend sends mistral data for HuggingFace, so we map it to huggingface - // Explicitly extract and type the provider usage data - const geminiData = providerBreakdown.gemini; - const mistralData = providerBreakdown.mistral; // Backend sends 'mistral' for HuggingFace - const huggingfaceData = providerBreakdown.huggingface; - const wavespeedData = providerBreakdown.wavespeed; - - // Create properly typed ProviderUsage objects - const geminiUsage: ProviderUsage = geminiData && typeof geminiData === 'object' && 'calls' in geminiData - ? { calls: Number(geminiData.calls) || 0, tokens: Number(geminiData.tokens) || 0, cost: Number(geminiData.cost) || 0 } - : { calls: 0, tokens: 0, cost: 0 }; - - // Map mistral data to huggingface (HuggingFace is stored as MISTRAL in DB) - const huggingfaceUsage: ProviderUsage = (huggingfaceData && typeof huggingfaceData === 'object' && 'calls' in huggingfaceData) - ? { calls: Number(huggingfaceData.calls) || 0, tokens: Number(huggingfaceData.tokens) || 0, cost: Number(huggingfaceData.cost) || 0 } - : (mistralData && typeof mistralData === 'object' && 'calls' in mistralData) - ? { calls: Number(mistralData.calls) || 0, tokens: Number(mistralData.tokens) || 0, cost: Number(mistralData.cost) || 0 } - : { calls: 0, tokens: 0, cost: 0 }; + // Preserve full provider breakdown from backend (dynamic keys). + // Also normalize mistral -> huggingface for display consistency while + // retaining the original mistral key for transparency. + const providerBreakdownCoerced: ProviderBreakdown = {}; + const normalizeProviderUsage = (value: any): ProviderUsage => ({ + calls: Number(value?.calls) || 0, + tokens: Number(value?.tokens) || 0, + cost: Number(value?.cost) || 0, + }); - const wavespeedUsage: ProviderUsage = wavespeedData && typeof wavespeedData === 'object' && 'calls' in wavespeedData - ? { calls: Number(wavespeedData.calls) || 0, tokens: Number(wavespeedData.tokens) || 0, cost: Number(wavespeedData.cost) || 0 } - : { calls: 0, tokens: 0, cost: 0 }; - - // Create ProviderBreakdown with only gemini and huggingface - const providerBreakdownCoerced: ProviderBreakdown = { - gemini: geminiUsage, - huggingface: huggingfaceUsage, - wavespeed: wavespeedUsage, - }; + Object.entries(providerBreakdown).forEach(([provider, usage]) => { + providerBreakdownCoerced[provider] = normalizeProviderUsage(usage); + }); - // Extract usage percentages - only include gemini and huggingface - // Backend sends mistral_calls for HuggingFace, map it to huggingface_calls - const usagePercentagesCoerced: UsagePercentages = { - gemini_calls: typeof raw?.usage_percentages?.gemini_calls === 'number' ? raw.usage_percentages.gemini_calls : 0, - huggingface_calls: typeof raw?.usage_percentages?.mistral_calls === 'number' - ? raw.usage_percentages.mistral_calls - : (typeof raw?.usage_percentages?.huggingface_calls === 'number' ? raw.usage_percentages.huggingface_calls : 0), - cost: typeof raw?.usage_percentages?.cost === 'number' ? raw.usage_percentages.cost : 0, - }; - - // Calculate total_cost from provider breakdown - // Always calculate from provider breakdown to ensure accuracy, but prefer backend total if it's more accurate - const backendTotalCost = typeof raw?.total_cost === 'number' ? raw.total_cost : 0; - const calculatedTotalCost = geminiUsage.cost + huggingfaceUsage.cost + wavespeedUsage.cost; - - // Use the maximum of backend cost and calculated cost to ensure we show the actual cost - // If backend cost is 0 but we have provider costs, use calculated cost - // If both are 0, the cost is genuinely 0 (no API calls with costs yet) - const totalCost = Math.max(backendTotalCost, calculatedTotalCost); - - // Debug logging for cost calculation - if (calculatedTotalCost > 0 || backendTotalCost > 0) { - console.log('💰 [BILLING DEBUG] Cost calculation in coerceUsageStats:', { - backendTotalCost, - calculatedTotalCost, - finalTotalCost: totalCost, - geminiCost: geminiUsage.cost, - huggingfaceCost: huggingfaceUsage.cost, - wavespeedCost: wavespeedUsage.cost, - geminiCalls: geminiUsage.calls, - huggingfaceCalls: huggingfaceUsage.calls, - wavespeedCalls: wavespeedUsage.calls, - }); + if (!providerBreakdownCoerced.gemini) { + providerBreakdownCoerced.gemini = { ...defaultProviderUsage }; + } + if (!providerBreakdownCoerced.huggingface) { + if (providerBreakdownCoerced.mistral) { + providerBreakdownCoerced.huggingface = { ...providerBreakdownCoerced.mistral }; + } else { + providerBreakdownCoerced.huggingface = { ...defaultProviderUsage }; + } + } + if (!providerBreakdownCoerced.wavespeed) { + providerBreakdownCoerced.wavespeed = { ...defaultProviderUsage }; } - // Calculate total_calls and total_tokens from provider breakdown if needed + // Extract usage percentages (fallback to provider breakdown if backend omits fields) + const usagePercentagesCoerced: UsagePercentages = { + gemini_calls: + typeof raw?.usage_percentages?.gemini_calls === 'number' + ? raw.usage_percentages.gemini_calls + : providerBreakdownCoerced.gemini?.calls ?? 0, + huggingface_calls: + typeof raw?.usage_percentages?.mistral_calls === 'number' + ? raw.usage_percentages.mistral_calls + : typeof raw?.usage_percentages?.huggingface_calls === 'number' + ? raw.usage_percentages.huggingface_calls + : providerBreakdownCoerced.huggingface?.calls ?? 0, + cost: + typeof raw?.usage_percentages?.cost === 'number' + ? raw.usage_percentages.cost + : 0, + }; + + // Calculate totals from the full provider breakdown for complete analytics visibility + const providerUsageValues = Object.values(providerBreakdownCoerced); + const calculatedTotalCost = providerUsageValues.reduce((sum, usage) => sum + (usage?.cost ?? 0), 0); + const calculatedTotalCalls = providerUsageValues.reduce((sum, usage) => sum + (usage?.calls ?? 0), 0); + const calculatedTotalTokens = providerUsageValues.reduce((sum, usage) => sum + (usage?.tokens ?? 0), 0); + + const backendTotalCost = typeof raw?.total_cost === 'number' ? raw.total_cost : 0; + const totalCost = Math.max(backendTotalCost, calculatedTotalCost); + const backendTotalCalls = typeof raw?.total_calls === 'number' ? raw.total_calls : 0; - const calculatedTotalCalls = geminiUsage.calls + huggingfaceUsage.calls + wavespeedUsage.calls; const totalCalls = backendTotalCalls > 0 ? backendTotalCalls : calculatedTotalCalls; const backendTotalTokens = typeof raw?.total_tokens === 'number' ? raw.total_tokens : 0; - const calculatedTotalTokens = geminiUsage.tokens + huggingfaceUsage.tokens + wavespeedUsage.tokens; const totalTokens = backendTotalTokens > 0 ? backendTotalTokens : calculatedTotalTokens; const coerced: UsageStats = { @@ -340,9 +320,13 @@ export const billingService = { // Debug: Log cost calculation details console.log('💰 [BILLING DEBUG] Cost calculation:', { backendTotalCost: coerced.current_usage.total_cost, - geminiCost: coerced.current_usage.provider_breakdown.gemini?.cost ?? 0, - huggingfaceCost: coerced.current_usage.provider_breakdown.huggingface?.cost ?? 0, - calculatedTotal: (coerced.current_usage.provider_breakdown.gemini?.cost ?? 0) + (coerced.current_usage.provider_breakdown.huggingface?.cost ?? 0), + providerCosts: Object.entries(coerced.current_usage.provider_breakdown || {}).map(([provider, usage]) => ({ + provider, + cost: usage?.cost ?? 0, + calls: usage?.calls ?? 0, + tokens: usage?.tokens ?? 0, + })), + calculatedTotal: Object.values(coerced.current_usage.provider_breakdown || {}).reduce((sum, usage) => sum + (usage?.cost ?? 0), 0), providerBreakdown: coerced.current_usage.provider_breakdown, }); @@ -354,23 +338,6 @@ export const billingService = { emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' }); return validatedData; } catch (validationError: any) { - // Check if error is due to old schema expecting other providers - const isOldSchemaError = validationError.errors?.some((err: any) => - err.path?.includes('provider_breakdown') && - err.path[err.path.length - 1] !== 'gemini' && - err.path[err.path.length - 1] !== 'huggingface' - ); - - if (isOldSchemaError) { - console.error('❌ [BILLING DEBUG] Validation failed due to cached old schema. Browser cache needs to be cleared.'); - console.error('❌ [BILLING DEBUG] Error details:', validationError.errors); - // Still return the coerced data - it's correct, just schema validation is cached - // The data structure is correct with only gemini and huggingface - emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' }); - return coerced; - } - - // For other validation errors, throw them console.error('❌ [BILLING DEBUG] Validation error:', validationError); throw validationError; } @@ -758,7 +725,21 @@ export const calculateUsagePercentage = (current: number, limit: number): number export const getProviderIcon = (provider: string): string => { const icons: { [key: string]: string } = { gemini: '🤖', - huggingface: '🤗', // HuggingFace icon + huggingface: '🤗', + mistral: '🤗', + wavespeed: '🌊', + openai: '🧠', + anthropic: '📝', + tavily: '🔎', + serper: '🌐', + metaphor: '📚', + firecrawl: '🕷️', + stability: '🖼️', + video: '🎬', + image: '🖼️', + image_edit: '✂️', + audio: '🔊', + exa: '🧭', }; return icons[provider.toLowerCase()] || '🔧'; }; @@ -766,7 +747,21 @@ export const getProviderIcon = (provider: string): string => { export const getProviderColor = (provider: string): string => { const colors: { [key: string]: string } = { gemini: '#4285f4', - huggingface: '#ffd21e', // HuggingFace yellow color + huggingface: '#ffd21e', + mistral: '#ffd21e', + wavespeed: '#06b6d4', + openai: '#10a37f', + anthropic: '#d97706', + tavily: '#22c55e', + serper: '#3b82f6', + metaphor: '#8b5cf6', + firecrawl: '#f97316', + stability: '#ec4899', + video: '#ef4444', + image: '#a855f7', + image_edit: '#f43f5e', + audio: '#14b8a6', + exa: '#6366f1', }; return colors[provider.toLowerCase()] || '#6b7280'; }; diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts index aa22599a..b45860b7 100644 --- a/frontend/src/types/billing.ts +++ b/frontend/src/types/billing.ts @@ -201,11 +201,7 @@ export const ProviderUsageSchema = z.object({ cost: z.number(), }); -export const ProviderBreakdownSchema = z.object({ - gemini: ProviderUsageSchema, - huggingface: ProviderUsageSchema, - wavespeed: ProviderUsageSchema.optional(), -}); +export const ProviderBreakdownSchema = z.record(ProviderUsageSchema); export const SubscriptionLimitsSchema = z.object({ plan_name: z.string(), From 01bf56837fbacb37a2f85881a67e585a4daa81ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=8A?= Date: Thu, 5 Mar 2026 11:36:04 +0530 Subject: [PATCH 5/5] Fix unlimited video limit display in usage rings --- .../components/UsageLimitRings.tsx | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx b/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx index f7853cb1..46ab52d0 100644 --- a/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx +++ b/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx @@ -69,21 +69,25 @@ export const UsageLimitRings: React.FC = ({ label: 'AI Calls', used: currentUsage.total_calls, limit: limits.limits.gemini_calls || limits.limits.openai_calls || 50, - color: '#3b82f6' + color: '#3b82f6', + unlimited: false, }, { label: 'Images', used: imageCalls, limit: limits.limits.stability_calls || 50, - color: '#a855f7' + color: '#a855f7', + unlimited: false, }, { label: 'Videos', used: videoCalls, - limit: limits.limits.video_calls || 30, - color: '#ec4899' + // IMPORTANT: 0 means unlimited (do not coerce to fallback finite value) + limit: limits.limits.video_calls, + color: '#ec4899', + unlimited: limits.limits.video_calls === 0, } - ].filter(item => item.limit > 0); + ].filter(item => item.unlimited || item.limit > 0); if (keyLimits.length === 0) return null; @@ -104,15 +108,34 @@ export const UsageLimitRings: React.FC = ({ animate={{ opacity: 1, scale: 1 }} transition={{ delay: index * 0.1, duration: 0.4 }} > - + {item.unlimited ? ( + + + {item.label} + + ) : ( + + )} ))}