feat(ui): redesign model square pricing page
This commit is contained in:
+377
-65
@@ -13,6 +13,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
@@ -24,6 +31,12 @@ import {
|
||||
replaceModelInPath,
|
||||
isTokenBasedModel,
|
||||
} from '../lib/model-helpers'
|
||||
import {
|
||||
getDynamicPriceEntries,
|
||||
getDynamicPricingSummary,
|
||||
getDynamicPricingTiers,
|
||||
isDynamicPricingModel,
|
||||
} from '../lib/dynamic-price'
|
||||
import { formatGroupPrice, formatFixedPrice } from '../lib/price'
|
||||
import type { PricingModel, TokenUnit, PriceType } from '../types'
|
||||
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
|
||||
@@ -44,6 +57,10 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
: null
|
||||
const description = model.description || model.vendor_description || null
|
||||
const tags = parseTags(model.tags)
|
||||
const isSpecialExpression =
|
||||
model.billing_mode === 'tiered_expr' &&
|
||||
Boolean(model.billing_expr) &&
|
||||
getDynamicPricingTiers(model).length === 0
|
||||
|
||||
return (
|
||||
<header className='pb-5'>
|
||||
@@ -75,7 +92,9 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
{t('Dynamic Pricing')}
|
||||
{isSpecialExpression
|
||||
? t('Special billing expression')
|
||||
: t('Dynamic Pricing')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -107,7 +126,6 @@ function PriceSection(props: {
|
||||
usdExchangeRate: number
|
||||
tokenUnit: TokenUnit
|
||||
showRechargePrice: boolean
|
||||
groupRatio: Record<string, number>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -116,23 +134,33 @@ function PriceSection(props: {
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
groupRatio,
|
||||
} = props
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const defaultGroup = model.enable_groups?.[0] || ''
|
||||
const ratio = defaultGroup ? groupRatio[defaultGroup] || 1 : 1
|
||||
const groupKey = defaultGroup || '_default'
|
||||
const groupRatioMap = { [groupKey]: ratio }
|
||||
const baseGroupKey = '_base'
|
||||
const baseGroupRatioMap = { [baseGroupKey]: 1 }
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
|
||||
const priceTypes: { label: string; type: PriceType; available: boolean }[] = [
|
||||
{ label: t('Input'), type: 'input', available: true },
|
||||
const primaryPriceTypes: { label: string; type: PriceType }[] = [
|
||||
{ label: t('Input'), type: 'input' },
|
||||
{ label: t('Output'), type: 'output' },
|
||||
]
|
||||
const secondaryPriceTypes: {
|
||||
label: string
|
||||
type: PriceType
|
||||
available: boolean
|
||||
}[] = [
|
||||
{
|
||||
label: t('Cached input'),
|
||||
type: 'cache',
|
||||
available: model.cache_ratio != null,
|
||||
},
|
||||
{ label: t('Output'), type: 'output', available: true },
|
||||
{
|
||||
label: t('Cache write'),
|
||||
type: 'create_cache',
|
||||
@@ -156,24 +184,97 @@ function PriceSection(props: {
|
||||
},
|
||||
]
|
||||
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Unable to parse structured pricing')}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
{dynamicSummary.primaryEntries.length > 0 ? (
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{dynamicSummary.primaryEntries.map((entry) => (
|
||||
<div key={entry.key} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(entry.shortLabel)}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
|
||||
{entry.formatted}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Dynamic Pricing')}
|
||||
</p>
|
||||
)}
|
||||
{dynamicSummary.secondaryEntries.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{dynamicSummary.secondaryEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{t(entry.shortLabel)}
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono text-sm tabular-nums'>
|
||||
{entry.formatted}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isTokenBased) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Price')}</SectionTitle>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Per request')}
|
||||
</span>
|
||||
<span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatGroupPrice(
|
||||
{formatFixedPrice(
|
||||
model,
|
||||
groupKey,
|
||||
'input',
|
||||
tokenUnit,
|
||||
baseGroupKey,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMap
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -181,33 +282,57 @@ function PriceSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const items = priceTypes.filter((p) => p.available)
|
||||
const secondaryItems = secondaryPriceTypes.filter((p) => p.available)
|
||||
const renderPrice = (type: PriceType) => (
|
||||
<>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
baseGroupKey,
|
||||
type,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Price')}</SectionTitle>
|
||||
<div className='space-y-1.5'>
|
||||
{items.map((item) => (
|
||||
<div key={item.type} className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>{item.label}</span>
|
||||
<span className='text-foreground font-mono text-sm tabular-nums'>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
groupKey,
|
||||
item.type,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMap
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</span>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{primaryPriceTypes.map((item) => (
|
||||
<div key={item.type} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div className='text-muted-foreground text-xs'>{item.label}</div>
|
||||
<div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{secondaryItems.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{secondaryItems.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono text-sm tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -348,6 +473,128 @@ function GroupPricingSection(props: {
|
||||
const thClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
if (isDynamicPricingModel(model)) {
|
||||
const dynamicTiers = getDynamicPricingTiers(model)
|
||||
|
||||
if (dynamicTiers.length === 0) {
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t(
|
||||
'Group prices cannot be expanded because this expression is not a standard tiered pricing expression.'
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{model.billing_expr}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const priceFields = Array.from(
|
||||
new Map(
|
||||
dynamicTiers
|
||||
.flatMap((tier) =>
|
||||
getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
)
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values()
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<div className='space-y-3'>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = groupRatio[group] || 1
|
||||
return (
|
||||
<div key={group} className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
|
||||
<GroupBadge group={group} size='sm' />
|
||||
<span className='text-muted-foreground font-mono text-xs'>
|
||||
{ratio}x
|
||||
</span>
|
||||
</div>
|
||||
<div className='overflow-x-auto'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Tier')}</TableHead>
|
||||
{priceFields.map((entry) => (
|
||||
<TableHead
|
||||
key={entry.field}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{t(entry.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
entries.map((entry) => [entry.field, entry])
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow key={`${group}-${tier.label || tierIndex}`}>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-xs'>
|
||||
{tier.label || t('Default')}
|
||||
</TableCell>
|
||||
{priceFields.map((fieldEntry) => {
|
||||
const entry = entryMap.get(fieldEntry.field)
|
||||
return (
|
||||
<TableCell
|
||||
key={fieldEntry.field}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{entry?.formatted ?? '-'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
@@ -464,6 +711,92 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export interface ModelDetailsContentProps {
|
||||
model: PricingModel
|
||||
groupRatio: Record<string, number>
|
||||
usableGroup: Record<string, { desc: string; ratio: number }>
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
autoGroups: string[]
|
||||
priceRate: number
|
||||
usdExchangeRate: number
|
||||
tokenUnit: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
}
|
||||
|
||||
export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
const {
|
||||
model,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice = false,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModelHeader model={model} />
|
||||
|
||||
<PriceSection
|
||||
model={model}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
|
||||
<EndpointsSection model={model} endpointMap={endpointMap} />
|
||||
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<div className='border-b'>
|
||||
<DynamicPricingBreakdown billingExpr={model.billing_expr} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GroupPricingSection
|
||||
model={model}
|
||||
groupRatio={groupRatio}
|
||||
usableGroup={usableGroup}
|
||||
autoGroups={autoGroups}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { open, onOpenChange, ...contentProps } = props
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
|
||||
>
|
||||
<SheetHeader className='sr-only'>
|
||||
<SheetTitle>{props.model.model_name}</SheetTitle>
|
||||
<SheetDescription>{t('Model details')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 overflow-y-auto px-5 pt-12 pb-6 sm:px-6'>
|
||||
<ModelDetailsContent {...contentProps} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetails() {
|
||||
const { t } = useTranslation()
|
||||
const { modelId } = useParams({ from: '/pricing/$modelId/' })
|
||||
@@ -547,34 +880,7 @@ export function ModelDetails() {
|
||||
{t('Back')}
|
||||
</Button>
|
||||
|
||||
<ModelHeader model={model} />
|
||||
|
||||
<PriceSection
|
||||
model={model}
|
||||
priceRate={priceRate ?? 1}
|
||||
usdExchangeRate={usdExchangeRate ?? 1}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={search.rechargePrice ?? false}
|
||||
groupRatio={groupRatio || {}}
|
||||
/>
|
||||
|
||||
<EndpointsSection
|
||||
model={model}
|
||||
endpointMap={
|
||||
(endpointMap as Record<
|
||||
string,
|
||||
{ path?: string; method?: string }
|
||||
>) || {}
|
||||
}
|
||||
/>
|
||||
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<div className='border-b'>
|
||||
<DynamicPricingBreakdown billingExpr={model.billing_expr} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GroupPricingSection
|
||||
<ModelDetailsContent
|
||||
model={model}
|
||||
groupRatio={groupRatio || {}}
|
||||
usableGroup={usableGroup || {}}
|
||||
@@ -583,6 +889,12 @@ export function ModelDetails() {
|
||||
usdExchangeRate={usdExchangeRate ?? 1}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={search.rechargePrice ?? false}
|
||||
endpointMap={
|
||||
(endpointMap as Record<
|
||||
string,
|
||||
{ path?: string; method?: string }
|
||||
>) || {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
|
||||
Reference in New Issue
Block a user