Merge PR #379: fix preflight pricing/model drift and usage UI
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -69,21 +69,24 @@ export const UsageLimitRings: React.FC<UsageLimitRingsProps> = ({
|
||||
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<UsageLimitRingsProps> = ({
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.4 }}
|
||||
>
|
||||
<UsageLimitRing
|
||||
used={item.used}
|
||||
limit={item.limit}
|
||||
label={item.label}
|
||||
color={item.color}
|
||||
size={100}
|
||||
terminalTheme={terminalTheme}
|
||||
terminalColors={terminalColors}
|
||||
/>
|
||||
{item.unlimited ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: '50%',
|
||||
border: `2px dashed ${item.color}`,
|
||||
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>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user