358 lines
9.8 KiB
TypeScript
Vendored
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,
|
|
},
|
|
]
|
|
}
|