diff --git a/backend/api/subscription/routes/preflight.py b/backend/api/subscription/routes/preflight.py index 2a55eedd..b55120f3 100644 --- a/backend/api/subscription/routes/preflight.py +++ b/backend/api/subscription/routes/preflight.py @@ -75,7 +75,8 @@ 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, + 'model': op.model }) except Exception as e: logger.warning(f"Error processing operation {op.operation_type}: {e}") @@ -94,7 +95,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 +106,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 +125,9 @@ 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) + 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) + 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 diff --git a/backend/models/subscription_models.py b/backend/models/subscription_models.py index caf5f518..a09350b4 100644 --- a/backend/models/subscription_models.py +++ b/backend/models/subscription_models.py @@ -300,6 +300,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'}, ) @@ -437,3 +438,4 @@ class FraudWarning(Base): reason_notes = Column(Text, nullable=True) meta_info = Column(JSON, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) + diff --git a/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx b/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx index f7853cb1..e1bfff41 100644 --- a/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx +++ b/frontend/src/components/billing/CompactBillingDashboard/components/UsageLimitRings.tsx @@ -69,21 +69,24 @@ 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' + 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 +107,34 @@ export const UsageLimitRings: React.FC = ({ animate={{ opacity: 1, scale: 1 }} transition={{ delay: index * 0.1, duration: 0.4 }} > - + {item.unlimited ? ( + + + {item.label} + + ) : ( + + )} ))} diff --git a/frontend/src/services/billingService.ts b/frontend/src/services/billingService.ts index 1aeace8b..a61d0d40 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,60 @@ 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 }; + 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 + 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, + }; + + 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 = { @@ -336,44 +311,9 @@ export const billingService = { unread_alerts: 0, }, }; - - // 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), - providerBreakdown: coerced.current_usage.provider_breakdown, - }); - - // Validate response data after coercion - // Note: If validation fails due to cached schema, we'll handle it gracefully - try { - const validatedData = DashboardDataSchema.parse(coerced); - // Notify app that fresh billing data is available - 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; - } + const validatedData = DashboardDataSchema.parse(coerced); + emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' }); + return validatedData; } catch (error) { console.error('❌ [BILLING DEBUG] Error fetching dashboard data:', error); throw error; @@ -758,7 +698,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 +720,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(),