Merge PR #379: fix preflight pricing/model drift and usage UI

This commit is contained in:
ajaysi
2026-03-05 12:22:21 +05:30
5 changed files with 124 additions and 137 deletions

View File

@@ -75,7 +75,8 @@ async def preflight_check(
'provider': provider_enum, 'provider': provider_enum,
'tokens_requested': op.tokens_requested or 0, 'tokens_requested': op.tokens_requested or 0,
'actual_provider_name': op.actual_provider_name or op.provider, '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: except Exception as e:
logger.warning(f"Error processing operation {op.operation_type}: {e}") logger.warning(f"Error processing operation {op.operation_type}: {e}")
@@ -94,7 +95,7 @@ async def preflight_check(
operation_results = [] operation_results = []
total_cost = 0.0 total_cost = 0.0
for i, op in enumerate(operations_to_validate): for op in operations_to_validate:
op_result = { op_result = {
'provider': op['actual_provider_name'], 'provider': op['actual_provider_name'],
'operation_type': op['operation_type'], 'operation_type': op['operation_type'],
@@ -105,7 +106,7 @@ async def preflight_check(
} }
# Get pricing for this operation # Get pricing for this operation
model_name = request.operations[i].model model_name = op.get('model')
if model_name: if model_name:
pricing_info = pricing_service.get_pricing_for_provider_model( pricing_info = pricing_service.get_pricing_for_provider_model(
op['provider'], op['provider'],
@@ -124,11 +125,9 @@ async def preflight_check(
chars = max(0, int(op.get('tokens_requested') or 0)) chars = max(0, int(op.get('tokens_requested') or 0))
cost = max(0.005, 0.005 * (chars / 100.0)) cost = max(0.005, 0.005 * (chars / 100.0))
else: 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']
cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * (op['tokens_requested'] / 1000.0)
elif op['tokens_requested'] > 0: 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']
cost = (pricing_info.get('cost_per_input_token', 0.0) or 0.0) * (op['tokens_requested'] / 1000)
else: else:
cost = pricing_info.get('cost_per_request', 0.0) or 0.0 cost = pricing_info.get('cost_per_request', 0.0) or 0.0

View File

@@ -300,6 +300,7 @@ class APIProviderPricing(Base):
# Unique constraint on provider and model # Unique constraint on provider and model
__table_args__ = ( __table_args__ = (
UniqueConstraint('provider', 'model_name', name='uq_api_provider_pricing_provider_model'),
{'mysql_engine': 'InnoDB'}, {'mysql_engine': 'InnoDB'},
) )
@@ -437,3 +438,4 @@ class FraudWarning(Base):
reason_notes = Column(Text, nullable=True) reason_notes = Column(Text, nullable=True)
meta_info = Column(JSON, nullable=True) meta_info = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -69,21 +69,24 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
label: 'AI Calls', label: 'AI Calls',
used: currentUsage.total_calls, used: currentUsage.total_calls,
limit: limits.limits.gemini_calls || limits.limits.openai_calls || 50, limit: limits.limits.gemini_calls || limits.limits.openai_calls || 50,
color: '#3b82f6' color: '#3b82f6',
unlimited: false,
}, },
{ {
label: 'Images', label: 'Images',
used: imageCalls, used: imageCalls,
limit: limits.limits.stability_calls || 50, limit: limits.limits.stability_calls || 50,
color: '#a855f7' color: '#a855f7',
unlimited: false,
}, },
{ {
label: 'Videos', label: 'Videos',
used: videoCalls, used: videoCalls,
limit: limits.limits.video_calls || 30, limit: limits.limits.video_calls,
color: '#ec4899' 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; if (keyLimits.length === 0) return null;
@@ -104,15 +107,34 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1, duration: 0.4 }} transition={{ delay: index * 0.1, duration: 0.4 }}
> >
<UsageLimitRing {item.unlimited ? (
used={item.used} <Box
limit={item.limit} sx={{
label={item.label} width: 100,
color={item.color} height: 100,
size={100} borderRadius: '50%',
terminalTheme={terminalTheme} border: `2px dashed ${item.color}`,
terminalColors={terminalColors} display: 'flex',
/> flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255,255,255,0.04)',
}}
>
<TypographyComponent sx={{ fontSize: 26, fontWeight: 700, color: item.color, lineHeight: 1 }}></TypographyComponent>
<TypographyComponent sx={{ fontSize: 10, opacity: 0.8, mt: 0.5 }}>{item.label}</TypographyComponent>
</Box>
) : (
<UsageLimitRing
used={item.used}
limit={item.limit}
label={item.label}
color={item.color}
size={100}
terminalTheme={terminalTheme}
terminalColors={terminalColors}
/>
)}
</motion.div> </motion.div>
))} ))}
</Box> </Box>

View File

@@ -20,7 +20,6 @@ import {
ProviderBreakdown, ProviderBreakdown,
UsagePercentages, UsagePercentages,
ProviderUsage, ProviderUsage,
ProviderBreakdownSchema,
SubscriptionRenewal, SubscriptionRenewal,
RenewalHistoryResponse, RenewalHistoryResponse,
RenewalHistoryAPIResponse, RenewalHistoryAPIResponse,
@@ -122,11 +121,6 @@ billingAPI.interceptors.response.use(
const defaultProviderUsage = { calls: 0, tokens: 0, cost: 0 }; const defaultProviderUsage = { calls: 0, tokens: 0, cost: 0 };
const defaultProviderBreakdown = {
gemini: { ...defaultProviderUsage },
huggingface: { ...defaultProviderUsage },
};
const defaultLimits = { const defaultLimits = {
plan_name: 'Unknown Plan', plan_name: 'Unknown Plan',
tier: 'free' as const, tier: 'free' as const,
@@ -196,79 +190,60 @@ function coerceUsageStats(raw: any): UsageStats {
features: raw?.limits?.features ?? [], features: raw?.limits?.features ?? [],
}; };
// Extract provider breakdown - only include gemini and huggingface const providerBreakdownCoerced: ProviderBreakdown = {};
// Backend sends mistral data for HuggingFace, so we map it to huggingface const normalizeProviderUsage = (value: any): ProviderUsage => ({
// Explicitly extract and type the provider usage data calls: Number(value?.calls) || 0,
const geminiData = providerBreakdown.gemini; tokens: Number(value?.tokens) || 0,
const mistralData = providerBreakdown.mistral; // Backend sends 'mistral' for HuggingFace cost: Number(value?.cost) || 0,
const huggingfaceData = providerBreakdown.huggingface; });
const wavespeedData = providerBreakdown.wavespeed;
// Create properly typed ProviderUsage objects Object.entries(providerBreakdown).forEach(([provider, usage]) => {
const geminiUsage: ProviderUsage = geminiData && typeof geminiData === 'object' && 'calls' in geminiData providerBreakdownCoerced[provider] = normalizeProviderUsage(usage);
? { 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) if (!providerBreakdownCoerced.gemini) {
const huggingfaceUsage: ProviderUsage = (huggingfaceData && typeof huggingfaceData === 'object' && 'calls' in huggingfaceData) providerBreakdownCoerced.gemini = { ...defaultProviderUsage };
? { calls: Number(huggingfaceData.calls) || 0, tokens: Number(huggingfaceData.tokens) || 0, cost: Number(huggingfaceData.cost) || 0 } }
: (mistralData && typeof mistralData === 'object' && 'calls' in mistralData) if (!providerBreakdownCoerced.huggingface) {
? { calls: Number(mistralData.calls) || 0, tokens: Number(mistralData.tokens) || 0, cost: Number(mistralData.cost) || 0 } if (providerBreakdownCoerced.mistral) {
: { calls: 0, tokens: 0, cost: 0 }; providerBreakdownCoerced.huggingface = { ...providerBreakdownCoerced.mistral };
} else {
const wavespeedUsage: ProviderUsage = wavespeedData && typeof wavespeedData === 'object' && 'calls' in wavespeedData providerBreakdownCoerced.huggingface = { ...defaultProviderUsage };
? { calls: Number(wavespeedData.calls) || 0, tokens: Number(wavespeedData.tokens) || 0, cost: Number(wavespeedData.cost) || 0 } }
: { calls: 0, tokens: 0, cost: 0 }; }
if (!providerBreakdownCoerced.wavespeed) {
// Create ProviderBreakdown with only gemini and huggingface providerBreakdownCoerced.wavespeed = { ...defaultProviderUsage };
const providerBreakdownCoerced: ProviderBreakdown = {
gemini: geminiUsage,
huggingface: huggingfaceUsage,
wavespeed: wavespeedUsage,
};
// 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,
});
} }
// 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 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 totalCalls = backendTotalCalls > 0 ? backendTotalCalls : calculatedTotalCalls;
const backendTotalTokens = typeof raw?.total_tokens === 'number' ? raw.total_tokens : 0; 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 totalTokens = backendTotalTokens > 0 ? backendTotalTokens : calculatedTotalTokens;
const coerced: UsageStats = { const coerced: UsageStats = {
@@ -336,44 +311,9 @@ export const billingService = {
unread_alerts: 0, unread_alerts: 0,
}, },
}; };
const validatedData = DashboardDataSchema.parse(coerced);
// Debug: Log cost calculation details emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' });
console.log('💰 [BILLING DEBUG] Cost calculation:', { return validatedData;
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;
}
} catch (error) { } catch (error) {
console.error('❌ [BILLING DEBUG] Error fetching dashboard data:', error); console.error('❌ [BILLING DEBUG] Error fetching dashboard data:', error);
throw error; throw error;
@@ -758,7 +698,21 @@ export const calculateUsagePercentage = (current: number, limit: number): number
export const getProviderIcon = (provider: string): string => { export const getProviderIcon = (provider: string): string => {
const icons: { [key: string]: string } = { const icons: { [key: string]: string } = {
gemini: '🤖', gemini: '🤖',
huggingface: '🤗', // HuggingFace icon huggingface: '🤗',
mistral: '🤗',
wavespeed: '🌊',
openai: '🧠',
anthropic: '📝',
tavily: '🔎',
serper: '🌐',
metaphor: '📚',
firecrawl: '🕷️',
stability: '🖼️',
video: '🎬',
image: '🖼️',
image_edit: '✂️',
audio: '🔊',
exa: '🧭',
}; };
return icons[provider.toLowerCase()] || '🔧'; return icons[provider.toLowerCase()] || '🔧';
}; };
@@ -766,7 +720,21 @@ export const getProviderIcon = (provider: string): string => {
export const getProviderColor = (provider: string): string => { export const getProviderColor = (provider: string): string => {
const colors: { [key: string]: string } = { const colors: { [key: string]: string } = {
gemini: '#4285f4', 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'; return colors[provider.toLowerCase()] || '#6b7280';
}; };

View File

@@ -201,11 +201,7 @@ export const ProviderUsageSchema = z.object({
cost: z.number(), cost: z.number(),
}); });
export const ProviderBreakdownSchema = z.object({ export const ProviderBreakdownSchema = z.record(ProviderUsageSchema);
gemini: ProviderUsageSchema,
huggingface: ProviderUsageSchema,
wavespeed: ProviderUsageSchema.optional(),
});
export const SubscriptionLimitsSchema = z.object({ export const SubscriptionLimitsSchema = z.object({
plan_name: z.string(), plan_name: z.string(),