feat(billing): introduce token price multiplier and update cost calculations; enhance billing UI with new pricing buttons

This commit is contained in:
sharath 2025-06-26 17:33:52 +00:00
parent 79b71db250
commit e7f02f31bc
No known key found for this signature in database
7 changed files with 194 additions and 227 deletions

View File

@ -20,6 +20,9 @@ import time
# Initialize Stripe # Initialize Stripe
stripe.api_key = config.STRIPE_SECRET_KEY stripe.api_key = config.STRIPE_SECRET_KEY
# Token price multiplier
TOKEN_PRICE_MULTIPLIER = 2
# Initialize router # Initialize router
router = APIRouter(prefix="/billing", tags=["billing"]) router = APIRouter(prefix="/billing", tags=["billing"])
@ -278,8 +281,8 @@ async def calculate_monthly_usage(client, user_id: str) -> float:
total_cost += message_cost total_cost += message_cost
# Return total cost * 2 (as per original logic) # Return total cost * TOKEN_PRICE_MULTIPLIER (as per original logic)
total_cost = total_cost * 2 total_cost = total_cost * TOKEN_PRICE_MULTIPLIER
logger.info(f"Total cost for user {user_id}: {total_cost}") logger.info(f"Total cost for user {user_id}: {total_cost}")
return total_cost return total_cost
@ -1038,8 +1041,8 @@ async def get_available_models(
if hardcoded_pricing: if hardcoded_pricing:
input_cost_per_million, output_cost_per_million = hardcoded_pricing input_cost_per_million, output_cost_per_million = hardcoded_pricing
pricing_info = { pricing_info = {
"input_cost_per_million_tokens": input_cost_per_million, "input_cost_per_million_tokens": input_cost_per_million * TOKEN_PRICE_MULTIPLIER,
"output_cost_per_million_tokens": output_cost_per_million, "output_cost_per_million_tokens": output_cost_per_million * TOKEN_PRICE_MULTIPLIER,
"max_tokens": None "max_tokens": None
} }
else: else:
@ -1090,8 +1093,8 @@ async def get_available_models(
if input_cost_per_token is not None and output_cost_per_token is not None: if input_cost_per_token is not None and output_cost_per_token is not None:
pricing_info = { pricing_info = {
"input_cost_per_million_tokens": round(input_cost_per_token * 2, 2), "input_cost_per_million_tokens": input_cost_per_token * TOKEN_PRICE_MULTIPLIER,
"output_cost_per_million_tokens": round(output_cost_per_token * 2, 2), "output_cost_per_million_tokens": output_cost_per_token * TOKEN_PRICE_MULTIPLIER,
"max_tokens": None # cost_per_token doesn't provide max_tokens info "max_tokens": None # cost_per_token doesn't provide max_tokens info
} }
else: else:

View File

@ -1,226 +1,183 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { SectionHeader } from '@/components/home/section-header'; import { SectionHeader } from '@/components/home/section-header';
import { getAvailableModels, Model } from '@/lib/api'; import { AlertCircle } from 'lucide-react';
import { Loader2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-billing';
import type { Model } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
interface PricingTableRowProps { interface ModelCardProps {
model: Model; model: Model;
index: number; index: number;
} }
function PricingTableRow({ model, index }: PricingTableRowProps) { function ModelCard({ model, index }: ModelCardProps) {
return ( return (
<tr <div
className="border-b border-border/50 transition-all duration-200 hover:bg-muted/30" className="group relative bg-gradient-to-br from-background to-background/80 border border-border/50 rounded-2xl p-6 hover:border-border transition-all duration-300 hover:shadow-xl hover:shadow-black/5 hover:-translate-y-1"
style={{ animationDelay: `${index * 100}ms` }} style={{ animationDelay: `${index * 100}ms` }}
> >
<td className="py-6 px-6"> <div className="space-y-4">
<div> {/* Model Header */}
<div className="font-semibold text-foreground capitalize"> <div className="space-y-2">
<h3 className="text-xl font-bold text-foreground group-hover:text-blue-600 transition-colors duration-300">
{model.display_name} {model.display_name}
</h3>
</div>
{/* Pricing Grid */}
<div className="grid grid-cols-2 gap-4">
{/* Input Cost */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground">
Input
</span>
</div>
{model.input_cost_per_million_tokens !== null &&
model.input_cost_per_million_tokens !== undefined ? (
<>
<div className="text-2xl font-bold text-foreground">
${model.input_cost_per_million_tokens.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
per 1M tokens
</div>
</>
) : (
<div className="text-2xl font-bold text-muted-foreground"></div>
)}
</div> </div>
<div className="text-sm text-muted-foreground font-mono truncate max-w-[200px]">
{model.id} {/* Output Cost */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground">
Output
</span>
</div>
{model.output_cost_per_million_tokens !== null &&
model.output_cost_per_million_tokens !== undefined ? (
<>
<div className="text-2xl font-bold text-foreground">
${model.output_cost_per_million_tokens.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
per 1M tokens
</div>
</>
) : (
<div className="text-2xl font-bold text-muted-foreground"></div>
)}
</div> </div>
</div> </div>
</td>
<td className="py-6 px-6 text-right">
{model.input_cost_per_million_tokens !== null &&
model.input_cost_per_million_tokens !== undefined ? (
<>
<div className="font-semibold text-foreground">
${model.input_cost_per_million_tokens.toFixed(2)}
</div>
<div className="text-sm text-muted-foreground">per 1M tokens</div>
</>
) : (
<div className="text-muted-foreground"></div>
)}
</td>
<td className="py-6 px-6 text-right">
{model.output_cost_per_million_tokens !== null &&
model.output_cost_per_million_tokens !== undefined ? (
<>
<div className="font-semibold text-foreground">
${model.output_cost_per_million_tokens.toFixed(2)}
</div>
<div className="text-sm text-muted-foreground">per 1M tokens</div>
</>
) : (
<div className="text-muted-foreground"></div>
)}
</td>
</tr>
);
}
export default function PricingPage() { {/* Model Stats */}
const [models, setModels] = useState<Model[]>([]); {model.max_tokens && (
const [loading, setLoading] = useState(true); <div className="pt-4 border-t border-border/50">
const [error, setError] = useState<string | null>(null); <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Max Tokens</span>
useEffect(() => { <span className="font-medium text-foreground">
const fetchPricing = async () => { {model.max_tokens.toLocaleString()}
try { </span>
setLoading(true); </div>
setError(null); </div>
const response = await getAvailableModels(); )}
// Filter to only show models that have pricing information available </div>
const filteredModels = response.models.filter((model) => { </div>
return ( );
model.input_cost_per_million_tokens !== null && }
model.input_cost_per_million_tokens !== undefined &&
model.output_cost_per_million_tokens !== null && export default function PricingPage() {
model.output_cost_per_million_tokens !== undefined const {
); data: modelsResponse,
}); isLoading: loading,
setModels(filteredModels); error,
} catch (err) { refetch,
console.error('Error fetching model pricing:', err); } = useAvailableModels();
// If there's an authentication error, show fallback data for the specific models requested // Filter to only show models that have pricing information available
if ( const models =
err instanceof Error && modelsResponse?.models?.filter((model: Model) => {
(err.message.includes('token') || err.message.includes('auth')) return (
) { model.input_cost_per_million_tokens !== null &&
const fallbackModels: Model[] = [ model.input_cost_per_million_tokens !== undefined &&
{ model.output_cost_per_million_tokens !== null &&
id: 'anthropic/claude-sonnet-4-20250514', model.output_cost_per_million_tokens !== undefined
display_name: 'Claude Sonnet 4', );
requires_subscription: true, }) || [];
is_available: false,
input_cost_per_million_tokens: 3.0, return (
output_cost_per_million_tokens: 15.0, <div className="relative min-h-screen w-full bg-gradient-to-br from-background via-background to-background/90">
max_tokens: 200000, {/* Animated Background */}
}, <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-50/20 via-transparent to-purple-50/20 dark:from-blue-950/20 dark:to-purple-950/20"></div>
{
id: 'openrouter/qwen/qwen3-235b-a22b', {/* Header */}
display_name: 'Qwen3 A22B', <SectionHeader>
requires_subscription: false, <div className="text-center space-y-6">
is_available: false, <div className="space-y-4">
input_cost_per_million_tokens: 0.4, <h1 className="text-5xl md:text-6xl font-bold bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent">
output_cost_per_million_tokens: 0.4, Compute Pricing
max_tokens: 32768, </h1>
}, </div>
{ </div>
id: 'openrouter/google/gemini-2.5-flash-preview-05-20', </SectionHeader>
display_name: 'Gemini Flash 2.5',
requires_subscription: false, {/* Content */}
is_available: false, <div className="relative z-10 max-w-7xl mx-auto px-6 pb-20 pt-4">
input_cost_per_million_tokens: 0.075, {loading ? (
output_cost_per_million_tokens: 0.3, <div className="flex items-center justify-center py-32">
max_tokens: 1000000, <div className="flex flex-col items-center gap-6">
}, <div className="relative">
{ <div className="w-16 h-16 border-4 border-blue-200 dark:border-blue-800 rounded-full animate-spin border-t-blue-500"></div>
id: 'openrouter/deepseek/deepseek-chat', <div className="absolute inset-0 w-16 h-16 border-4 border-transparent rounded-full animate-ping border-t-blue-500/20"></div>
display_name: 'DeepSeek Chat', </div>
requires_subscription: false, <div className="text-center">
is_available: false, <p className="text-lg font-medium text-foreground">
input_cost_per_million_tokens: 0.14, Loading compute pricing
output_cost_per_million_tokens: 0.28, </p>
max_tokens: 65536, <p className="text-sm text-muted-foreground">
}, Fetching the latest pricing data...
]; </p>
setModels(fallbackModels); </div>
} else { </div>
setError( </div>
err instanceof Error ) : error ? (
? err.message <div className="flex items-center justify-center py-32">
: 'Failed to fetch model pricing', <div className="max-w-md text-center space-y-6">
); <div className="w-20 h-20 mx-auto rounded-full bg-gradient-to-br from-red-50 to-red-100 dark:from-red-950/50 dark:to-red-900/50 border border-red-200 dark:border-red-800 flex items-center justify-center">
} <AlertCircle className="w-10 h-10 text-red-500" />
} finally { </div>
setLoading(false); <div className="space-y-3">
} <h3 className="text-2xl font-bold text-foreground">
}; Pricing Unavailable
</h3>
fetchPricing(); <p className="text-muted-foreground leading-relaxed">
}, []); {error instanceof Error
? error.message
return ( : 'Failed to fetch model pricing'}
<div className="relative min-h-screen w-full"> </p>
{/* Header */} </div>
<SectionHeader> <Button
<div className="text-center space-y-4"> onClick={() => refetch()}
<div className="inline-flex items-center px-3 py-1.5 rounded-full border border-border bg-background/50 backdrop-blur-sm"> className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-300"
<span className="text-sm font-medium text-muted-foreground"> >
💰 MODEL PRICING <Loader2 className="w-4 h-4 mr-2" />
</span> Try Again
</div> </Button>
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent"> </div>
AI Model Pricing </div>
</h1> ) : (
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed"> <div className="space-y-12">
Compare pricing across our supported AI models. All prices are shown {/* Model Cards Grid */}
per million tokens. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
</p> {models.map((model, index) => (
</div> <ModelCard key={model.id} model={model} index={index} />
</SectionHeader> ))}
{/* Content */}
<div className="relative z-10 max-w-7xl mx-auto px-6 py-12">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<p className="text-muted-foreground">Loading model pricing...</p>
</div>
</div>
) : error ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<div>
<h3 className="text-xl font-semibold text-foreground mb-2">
Failed to load pricing
</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="hover:bg-muted/50"
>
Try Again
</Button>
</div>
</div>
</div>
) : (
<div className="space-y-8">
{/* Pricing Table */}
<div className="border border-border rounded-xl bg-background/50 backdrop-blur-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left py-4 px-6 font-semibold text-foreground">
Model
</th>
<th className="text-right py-4 px-6 font-semibold text-foreground">
Input Cost
</th>
<th className="text-right py-4 px-6 font-semibold text-foreground">
Output Cost
</th>
</tr>
</thead>
<tbody>
{models.map((model, index) => (
<PricingTableRow
key={model.id}
model={model}
index={index}
/>
))}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -122,6 +122,13 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
<div className="mt-20"></div> <div className="mt-20"></div>
{/* Manage Subscription Button */} {/* Manage Subscription Button */}
<Button
onClick={() => window.open('/model-pricing', '_blank')}
variant="outline"
className="w-full border-border hover:bg-muted/50 shadow-sm hover:shadow-md transition-all mb-3"
>
View Compute Pricing
</Button>
<Button <Button
onClick={handleManageSubscription} onClick={handleManageSubscription}
disabled={isManaging} disabled={isManaging}
@ -161,11 +168,11 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
{/* Action Buttons */} {/* Action Buttons */}
<div className="space-y-3"> <div className="space-y-3">
<Button <Button
onClick={() => window.open('/models', '_blank')} onClick={() => window.open('/model-pricing', '_blank')}
variant="outline" variant="outline"
className="w-full border-border hover:bg-muted/50 shadow-sm hover:shadow-md transition-all" className="w-full border-border hover:bg-muted/50 shadow-sm hover:shadow-md transition-all"
> >
View Model Pricing View Compute Pricing
</Button> </Button>
<Button <Button
onClick={handleManageSubscription} onClick={handleManageSubscription}

View File

@ -88,7 +88,7 @@ export function BillingModal({ open, onOpenChange, returnUrl = window?.location?
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-5xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Upgrade Your Plan</DialogTitle> <DialogTitle>Upgrade Your Plan</DialogTitle>
</DialogHeader> </DialogHeader>
@ -127,7 +127,7 @@ export function BillingModal({ open, onOpenChange, returnUrl = window?.location?
<Button <Button
onClick={handleManageSubscription} onClick={handleManageSubscription}
disabled={isManaging} disabled={isManaging}
className="w-full bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all mt-4" className="max-w-xs mx-auto w-full bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all mt-4"
> >
{isManaging ? 'Loading...' : 'Manage Subscription'} {isManaging ? 'Loading...' : 'Manage Subscription'}
</Button> </Button>

View File

@ -491,7 +491,7 @@ export default function UsageLogs({ accountId }: Props) {
<TableHead>Time</TableHead> <TableHead>Time</TableHead>
<TableHead>Model</TableHead> <TableHead>Model</TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
Tokens Compute
</TableHead> </TableHead>
<TableHead className="text-right">Cost</TableHead> <TableHead className="text-right">Cost</TableHead>
<TableHead className="text-center"> <TableHead className="text-center">

View File

@ -278,7 +278,7 @@ function PricingTier({
isScheduled && currentSubscription?.scheduled_price_id === tierPriceId; isScheduled && currentSubscription?.scheduled_price_id === tierPriceId;
const isPlanLoading = isLoading[tierPriceId]; const isPlanLoading = isLoading[tierPriceId];
let buttonText = isAuthenticated ? 'Select Plan' : 'Try Free'; let buttonText = isAuthenticated ? 'Select Plan' : 'Start Free';
let buttonDisabled = isPlanLoading; let buttonDisabled = isPlanLoading;
let buttonVariant: ButtonVariant = null; let buttonVariant: ButtonVariant = null;
let ringClass = ''; let ringClass = '';
@ -480,7 +480,7 @@ function PricingTier({
{billingPeriod === 'yearly' && tier.yearlyPrice && tier.discountPercentage ? ( {billingPeriod === 'yearly' && tier.yearlyPrice && tier.discountPercentage ? (
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-green-50 border-green-200 text-green-700 w-fit"> <div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-green-50 border-green-200 text-green-700 w-fit">
Save ${Math.round((parseFloat(tier.originalYearlyPrice?.slice(1) || '0') - parseFloat(tier.yearlyPrice.slice(1))) / 12)} per month Save ${Math.round(parseFloat(tier.originalYearlyPrice?.slice(1) || '0') - parseFloat(tier.yearlyPrice.slice(1)))} per year
</div> </div>
) : ( ) : (
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-primary/10 border-primary/20 text-primary w-fit"> <div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-primary/10 border-primary/20 text-primary w-fit">
@ -631,7 +631,7 @@ export function PricingSection({
return ( return (
<section <section
id="pricing" id="pricing"
className={cn("flex flex-col items-center justify-center gap-10 w-full relative", { "pb-20": !insideDialog })} className={cn("flex flex-col items-center justify-center gap-10 w-full relative")}
> >
{showTitleAndTabs && ( {showTitleAndTabs && (
<> <>

View File

@ -116,7 +116,7 @@ export const siteConfig = {
name: 'Free', name: 'Free',
price: '$0', price: '$0',
description: 'Get started with', description: 'Get started with',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
/** @deprecated */ /** @deprecated */
@ -136,7 +136,7 @@ export const siteConfig = {
originalYearlyPrice: '$240', originalYearlyPrice: '$240',
discountPercentage: 15, discountPercentage: 15,
description: 'Everything in Free, plus:', description: 'Everything in Free, plus:',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-primary text-white dark:text-black', buttonColor: 'bg-primary text-white dark:text-black',
isPopular: true, isPopular: true,
/** @deprecated */ /** @deprecated */
@ -157,7 +157,7 @@ export const siteConfig = {
originalYearlyPrice: '$600', originalYearlyPrice: '$600',
discountPercentage: 15, discountPercentage: 15,
description: 'Everything in Free, plus:', description: 'Everything in Free, plus:',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
/** @deprecated */ /** @deprecated */
@ -178,7 +178,7 @@ export const siteConfig = {
originalYearlyPrice: '$1200', originalYearlyPrice: '$1200',
discountPercentage: 15, discountPercentage: 15,
description: 'Everything in Pro, plus:', description: 'Everything in Pro, plus:',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '12 hours', hours: '12 hours',
@ -200,7 +200,7 @@ export const siteConfig = {
originalYearlyPrice: '$2400', originalYearlyPrice: '$2400',
discountPercentage: 15, discountPercentage: 15,
description: 'Everything in Free, plus:', description: 'Everything in Free, plus:',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-primary text-white dark:text-black', buttonColor: 'bg-primary text-white dark:text-black',
isPopular: false, isPopular: false,
hours: '25 hours', hours: '25 hours',
@ -220,7 +220,7 @@ export const siteConfig = {
originalYearlyPrice: '$4800', originalYearlyPrice: '$4800',
discountPercentage: 15, discountPercentage: 15,
description: 'Everything in Ultra, plus:', description: 'Everything in Ultra, plus:',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '50 hours', hours: '50 hours',
@ -243,7 +243,7 @@ export const siteConfig = {
originalYearlyPrice: '$9600', originalYearlyPrice: '$9600',
discountPercentage: 15, discountPercentage: 15,
description: 'Everything in Enterprise, plus:', description: 'Everything in Enterprise, plus:',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '125 hours', hours: '125 hours',
@ -267,7 +267,7 @@ export const siteConfig = {
originalYearlyPrice: '$12000', originalYearlyPrice: '$12000',
discountPercentage: 15, discountPercentage: 15,
description: 'Everything in Scale, plus:', description: 'Everything in Scale, plus:',
buttonText: 'Try Free', buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white', buttonColor: 'bg-secondary text-white',
isPopular: false, isPopular: false,
hours: '200 hours', hours: '200 hours',