358 lines
9.8 KiB
TypeScript
Vendored

import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { GroupBadge } from '@/components/group-badge'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import {
formatPrice,
formatRequestPrice,
stripTrailingZeros,
} from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
// ----------------------------------------------------------------------------
// Pricing Table Columns
// ----------------------------------------------------------------------------
export interface PricingColumnsOptions {
tokenUnit?: TokenUnit
priceRate?: number
usdExchangeRate?: number
showRechargePrice?: boolean
}
function renderLimitedTags(
items: string[],
maxDisplay: number = 3
): React.ReactNode {
if (items.length === 0)
return <span className='text-muted-foreground/50 text-xs'></span>
const displayed = items.slice(0, maxDisplay)
const remaining = items.length - maxDisplay
return (
<span className='text-muted-foreground text-xs'>
{displayed.join(', ')}
{remaining > 0 && (
<span className='text-muted-foreground/50'> +{remaining}</span>
)}
</span>
)
}
function renderLimitedGroupBadges(
groups: string[],
maxDisplay: number = 2
): React.ReactNode {
if (groups.length === 0)
return <span className='text-muted-foreground/50 text-xs'></span>
const displayed = groups.slice(0, maxDisplay)
const remaining = groups.length - maxDisplay
return (
<div className='flex max-w-full items-center gap-1 overflow-hidden'>
{displayed.map((group) => (
<GroupBadge key={group} group={group} size='sm' />
))}
{remaining > 0 && (
<span className='text-muted-foreground/50 text-xs'>+{remaining}</span>
)}
</div>
)
}
export function usePricingColumns(
options: PricingColumnsOptions = {}
): ColumnDef<PricingModel>[] {
const { t } = useTranslation()
const {
tokenUnit = DEFAULT_TOKEN_UNIT,
priceRate = 1,
usdExchangeRate = 1,
showRechargePrice = false,
} = options
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
return [
// Model column
{
accessorKey: 'model_name',
meta: { label: t('Model') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model')} />
),
cell: ({ row }) => {
const model = row.original
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 14)
: null
return (
<div className='flex min-w-[200px] items-center gap-2'>
{vendorIcon}
<span className='truncate font-mono text-sm font-medium'>
{model.model_name}
</span>
</div>
)
},
minSize: 200,
},
// Type column
{
accessorKey: 'quota_type',
meta: { label: t('Type') },
header: t('Type'),
cell: ({ row }) => {
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
return (
<span className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{isTokenBased ? t('Token') : t('Request')}
</span>
)
},
size: 80,
enableSorting: false,
},
// Price column
{
accessorKey: 'price',
meta: { label: t('Price') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Price')} />
),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (isTokenBased) {
const inputPrice = stripTrailingZeros(
formatPrice(
model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
const outputPrice = stripTrailingZeros(
formatPrice(
model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[160px]'>
<span className='font-mono text-sm tabular-nums'>
{inputPrice}
<span className='text-muted-foreground/40 mx-1'>/</span>
{outputPrice}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {tokenUnitLabel} tokens
</div>
</div>
)
}
const price = stripTrailingZeros(
formatRequestPrice(
model,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[100px]'>
<span className='font-mono text-sm tabular-nums'>{price}</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {t('request')}
</div>
</div>
)
},
size: 180,
enableSorting: false,
},
// Cached price column (Vercel AI Gateway style)
{
id: 'cached_price',
meta: { label: t('Cached') },
header: t('Cached'),
cell: ({ row }) => {
const model = row.original
const isTokenBased = isTokenBasedModel(model)
if (!isTokenBased || model.cache_ratio == null) {
return <span className='text-muted-foreground/30 text-xs'></span>
}
const cachedPrice = stripTrailingZeros(
formatPrice(
model,
'cache',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)
)
return (
<div className='min-w-[80px]'>
<span className='font-mono text-sm tabular-nums'>
{cachedPrice}
</span>
<div className='text-muted-foreground/50 text-[10px]'>
/ {tokenUnitLabel}
</div>
</div>
)
},
size: 110,
enableSorting: false,
},
// Vendor column
{
accessorKey: 'vendor_name',
meta: { label: t('Vendor') },
header: t('Vendor'),
cell: ({ row }) => {
const model = row.original
if (!model.vendor_name) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 12)
: null
return (
<span className='text-muted-foreground flex items-center gap-1.5 text-xs'>
{vendorIcon}
{model.vendor_name}
</span>
)
},
size: 130,
enableSorting: false,
},
// Tags column
{
accessorKey: 'tags',
meta: { label: t('Tags') },
header: t('Tags'),
cell: ({ row }) => {
const tags = parseTags(row.original.tags)
if (tags.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedTags(tags, 2)}</div>
</TooltipTrigger>
{tags.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<span className='text-xs'>{tags.join(', ')}</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 140,
enableSorting: false,
},
// Endpoints column
{
accessorKey: 'supported_endpoint_types',
meta: { label: t('Endpoints') },
header: t('Endpoints'),
cell: ({ row }) => {
const endpoints = row.original.supported_endpoint_types || []
if (endpoints.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedTags(endpoints, 2)}</div>
</TooltipTrigger>
{endpoints.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<span className='text-xs'>{endpoints.join(', ')}</span>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 130,
enableSorting: false,
},
// Enable Groups column
{
accessorKey: 'enable_groups',
meta: { label: t('Groups') },
header: t('Groups'),
cell: ({ row }) => {
const groups = row.original.enable_groups || []
if (groups.length === 0) {
return <span className='text-muted-foreground/50 text-xs'></span>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedGroupBadges(groups, 2)}</div>
</TooltipTrigger>
{groups.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
<div className='flex flex-wrap gap-1'>
{groups.map((group) => (
<GroupBadge key={group} group={group} size='sm' />
))}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
},
size: 130,
enableSorting: false,
},
]
}