feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)

* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges

Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage.

Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx.

Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark).

Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi.

* 🎨 fix(web): align segmented controls with theme radius tokens

- Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart
  toolbars with radius-md so the active state follows --radius when users
  change Radius in Theme Settings.
- Use nested radii consistent with TabsList/TabsTrigger: outer
  rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px))
  so the track and active thumb stay concentric at small scales (e.g.
  0.3rem) instead of a squared “focus” block inside a rounded shell.
- Apply the same pattern to pricing SegmentedControl and the segmented
  groups in consumption-distribution-chart, model-charts, and user-charts.

Verified: bun run typecheck (web/default)

*  feat(pricing): enrich model details with uptime sparkline and API documentation

Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with
per-day tooltips, surface it in a status row under quick stats and in the
per-group performance table, and extend mock data so uptime series are stable
and optionally scoped by group.

Introduce an API tab with Shiki-highlighted code samples (cURL, Python,
TypeScript, JavaScript), endpoint-type switching, authentication guidance, a
supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer
vendor, tokenizer, license, and data-retention hints for a provider & data
privacy card on the Overview tab (capabilities/modalities stay with model
identity; rate limits stay with the API tab).

Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi.

* 🏆 feat(rankings): add comprehensive rankings dashboard

Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending.

Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales.

* fix(theme): correct theme preset selection state

- update Base UI Radio selectors to use data-checked/data-unchecked states.
- fix unchecked theme options still showing selected indicators.
- isolate the default theme preview tokens to prevent preset changes from leaking into it.

* fix(setup): correct usage mode radio state

- use Base UI data-checked/data-unchecked states for RadioGroup styling.
- hide radio indicators when options are unchecked to avoid setup page display issues.
- drive usage mode card and icon selection styles from Base UI state.

* fix(auth): submit sign-in and sign-up forms

* 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization

Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`)
and reinstall full component registry with `--overwrite`, including Hugeicons-backed
widgets and newly added registry components.

- Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies,
  preset UI from config drawer); rely on official semantic CSS tokens + light/dark only.
- Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and
  `@theme inline` mappings (plus skeleton token vars for existing shimmer usage).
- Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`;
  clarify scroll-lock override comment.

Application compatibility:
- Keep minimal shims where app code diverged from official APIs (popover collision props,
  combobox legacy `options` callers, Spinner prop typing).
- Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI
  semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.)

Tooling / docs / build:
- Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`.
- Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack.
- Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.)

Verified: `bun run typecheck` passes.

Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere;
not addressed in this change.

* 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters

- Refactor DataTableToolbar to a single wrapping flex row with a
  right-aligned action cluster (Reset / Search / View / Expand) for a
  cleaner Ant Design Pro–style filter bar; remove the dedicated stats row
  and the toolbar `stats` prop.
- Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive-
  data visibility toggle into the page header via CommonLogsHeaderActions
  and SectionPageLayout.Actions so the toolbar stays focused on filters.
- Slim CommonLogsFilterBar props (no stats / preActions eye control).
- Improve CompactDateTimeRangePicker: show minute-precision labels on the
  trigger (seconds omitted; aligns with datetime-local inputs); widen the
  trigger on sm+ breakpoints so the full range is visible without truncation;
  apply the same width in task logs filters.
- Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon.
- Earlier layout tweak: extra top padding on SectionPageLayout scroll
  content so control focus rings are not clipped by overflow.

* feat(web/default): Base UI migration and component foundation

Migrate from Radix UI to Base UI, rewrite core UI primitives,
update dependencies (recharts, date-fns, next-themes), add
shadcn agent skill documentation, and refresh AI element components.

This is the foundational work from the v2/localmain lineage that
was not covered by the individual feature commits above.

---------

Co-authored-by: t0ng7u <dev@aiass.cc>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
This commit is contained in:
Calcium-Ion
2026-05-06 12:39:36 +08:00
committed by GitHub
co-authored by t0ng7u QuentinHsu
parent dac55f0fde
commit 8b2b03d276
317 changed files with 19928 additions and 7065 deletions
@@ -242,8 +242,7 @@ export function DynamicPricingBreakdown({
key={`tier-mobile-${i}`}
className={cn(
'rounded-md border p-2',
isMatched &&
'border-emerald-500/40 bg-emerald-500/10'
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
)}
>
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
@@ -1,727 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
import {
ArrowUpDown,
Check,
ChevronDown,
Filter,
RotateCcw,
Table2,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
FILTER_ALL,
QUOTA_TYPES,
ENDPOINT_TYPES,
VIEW_MODES,
getSortLabels,
getQuotaTypeLabels,
getEndpointTypeLabels,
type SortOption,
type ViewMode,
} from '../constants'
import type { PricingVendor, TokenUnit } from '../types'
interface FilterOption {
value: string
label: string
icon?: React.ReactNode
}
interface FilterChipProps {
label: string
value: string
options: FilterOption[]
onChange: (value: string) => void
defaultValue?: string
searchPlaceholder?: string
className?: string
}
function FilterChip(props: FilterChipProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const defaultVal = props.defaultValue ?? FILTER_ALL
const isActive = props.value !== defaultVal
const selectedOption = props.options.find((o) => o.value === props.value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type='button'
role='combobox'
aria-expanded={open}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all',
isActive
? 'border-foreground/30 bg-foreground/5 text-foreground'
: 'border-border bg-muted/50 text-muted-foreground hover:border-border/80 hover:text-foreground hover:bg-muted',
props.className
)}
>
{selectedOption?.icon}
<span className='max-w-[100px] truncate'>
{isActive && selectedOption ? selectedOption.label : props.label}
</span>
<ChevronDown
className={cn(
'size-3.5 transition-transform',
open && 'rotate-180'
)}
/>
</button>
</PopoverTrigger>
<PopoverContent className='w-52 p-0' align='start'>
<Command>
<CommandInput
placeholder={props.searchPlaceholder || t('Search...')}
className='h-9'
/>
<CommandList className='max-h-64'>
<CommandEmpty>{t('No results found')}</CommandEmpty>
<CommandGroup>
{props.options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
props.onChange(option.value)
setOpen(false)
}}
className='gap-2'
>
<Check
className={cn(
'size-4 shrink-0',
props.value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.icon && (
<span className='shrink-0'>{option.icon}</span>
)}
<span className='truncate'>{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
interface SegmentedControlProps {
options: Array<{
value: string
label?: string
icon?: React.ComponentType<{ className?: string }>
tooltip?: string
}>
value: string
onChange: (value: string) => void
ariaLabel?: string
}
function SegmentedControl(props: SegmentedControlProps) {
return (
<div
role='group'
aria-label={props.ariaLabel}
className='bg-muted/60 inline-flex h-8 items-center rounded-md border p-0.5'
>
{props.options.map((option) => {
const isActive = option.value === props.value
const Icon = option.icon
const isIconOnly = Icon && !option.label
const button = (
<button
key={option.value}
type='button'
onClick={() => props.onChange(option.value)}
className={cn(
'inline-flex h-[calc(100%)] items-center justify-center rounded-[5px] text-xs font-medium transition-all',
isIconOnly ? 'w-7' : 'gap-1.5 px-2.5',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
aria-pressed={isActive}
>
{Icon && <Icon className='size-3.5' />}
{option.label}
</button>
)
if (option.tooltip) {
return (
<Tooltip key={option.value}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side='bottom' className='text-xs'>
{option.tooltip}
</TooltipContent>
</Tooltip>
)
}
return button
})}
</div>
)
}
interface ActiveFilterBadgeProps {
label: string
onRemove: () => void
}
function ActiveFilterBadge(props: ActiveFilterBadgeProps) {
const { t } = useTranslation()
return (
<Badge
variant='secondary'
className='gap-1 py-1 pr-1 pl-2.5 text-xs font-medium'
>
{props.label}
<button
type='button'
onClick={props.onRemove}
className='hover:bg-secondary-foreground/10 rounded-full p-0.5 transition-colors'
aria-label={`${t('Remove filter')}: ${props.label}`}
>
<X className='size-3' />
</button>
</Badge>
)
}
interface MobileFilterGroupProps {
title: string
value: string
options: FilterOption[]
onChange: (value: string) => void
}
function MobileFilterGroup(props: MobileFilterGroupProps) {
return (
<div className='space-y-2.5'>
<h3 className='text-foreground text-sm font-semibold'>{props.title}</h3>
<div className='flex flex-wrap gap-2'>
{props.options.map((option) => {
const isActive = props.value === option.value
return (
<button
key={option.value}
type='button'
onClick={() => props.onChange(option.value)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all',
isActive
? 'border-foreground/30 bg-foreground/5 text-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground border-transparent'
)}
>
{option.icon}
{option.label}
</button>
)
})}
</div>
</div>
)
}
export interface FilterBarProps {
quotaTypeFilter: string
endpointTypeFilter: string
vendorFilter: string
groupFilter: string
tagFilter: string
onQuotaTypeChange: (v: string) => void
onEndpointTypeChange: (v: string) => void
onVendorChange: (v: string) => void
onGroupChange: (v: string) => void
onTagChange: (v: string) => void
vendors: PricingVendor[]
groups: string[]
tags: string[]
sortBy: string
onSortChange: (v: string) => void
tokenUnit: TokenUnit
onTokenUnitChange: (v: TokenUnit) => void
showRechargePrice: boolean
onRechargePriceChange: (v: boolean) => void
viewMode: ViewMode
onViewModeChange: (v: ViewMode) => void
hasActiveFilters: boolean
activeFilterCount: number
onClearFilters: () => void
filteredCount: number
totalCount?: number
}
export function FilterBar(props: FilterBarProps) {
const { t } = useTranslation()
const [mobileOpen, setMobileOpen] = useState(false)
const {
quotaTypeFilter,
endpointTypeFilter,
vendorFilter,
groupFilter,
tagFilter,
onQuotaTypeChange,
onEndpointTypeChange,
onVendorChange,
onGroupChange,
onTagChange,
vendors,
groups,
tags,
onTokenUnitChange,
onViewModeChange,
onRechargePriceChange,
} = props
const quotaTypeLabels = getQuotaTypeLabels(t)
const endpointTypeLabels = getEndpointTypeLabels(t)
const sortLabels = getSortLabels(t)
const typeOptions = useMemo<FilterOption[]>(
() =>
Object.entries(quotaTypeLabels).map(([value, label]) => ({
value,
label,
})),
[quotaTypeLabels]
)
const endpointOptions = useMemo<FilterOption[]>(
() =>
Object.entries(endpointTypeLabels).map(([value, label]) => ({
value,
label,
})),
[endpointTypeLabels]
)
const vendorOptions = useMemo<FilterOption[]>(
() => [
{ value: FILTER_ALL, label: t('All Vendors') },
...vendors.map((v) => ({
value: v.name,
label: v.name,
icon: v.icon ? getLobeIcon(v.icon, 14) : undefined,
})),
],
[vendors, t]
)
const groupOptions = useMemo<FilterOption[]>(
() => [
{ value: FILTER_ALL, label: t('All Groups') },
...groups.map((g) => ({ value: g, label: g })),
],
[groups, t]
)
const tagOptions = useMemo<FilterOption[]>(
() => [
{ value: FILTER_ALL, label: t('All Tags') },
...tags.map((tag) => ({ value: tag, label: tag })),
],
[tags, t]
)
const activeFilters = useMemo(() => {
const filters: Array<{ key: string; label: string; onRemove: () => void }> =
[]
if (quotaTypeFilter !== QUOTA_TYPES.ALL) {
filters.push({
key: 'quotaType',
label:
quotaTypeLabels[quotaTypeFilter as keyof typeof quotaTypeLabels] ||
quotaTypeFilter,
onRemove: () => onQuotaTypeChange(QUOTA_TYPES.ALL),
})
}
if (endpointTypeFilter !== ENDPOINT_TYPES.ALL) {
filters.push({
key: 'endpointType',
label:
endpointTypeLabels[
endpointTypeFilter as keyof typeof endpointTypeLabels
] || endpointTypeFilter,
onRemove: () => onEndpointTypeChange(ENDPOINT_TYPES.ALL),
})
}
if (vendorFilter !== FILTER_ALL) {
filters.push({
key: 'vendor',
label: vendorFilter,
onRemove: () => onVendorChange(FILTER_ALL),
})
}
if (groupFilter !== FILTER_ALL) {
filters.push({
key: 'group',
label: groupFilter,
onRemove: () => onGroupChange(FILTER_ALL),
})
}
if (tagFilter !== FILTER_ALL) {
filters.push({
key: 'tag',
label: tagFilter,
onRemove: () => onTagChange(FILTER_ALL),
})
}
return filters
}, [
quotaTypeFilter,
endpointTypeFilter,
vendorFilter,
groupFilter,
tagFilter,
onQuotaTypeChange,
onEndpointTypeChange,
onVendorChange,
onGroupChange,
onTagChange,
quotaTypeLabels,
endpointTypeLabels,
])
const handleTokenUnitChange = useCallback(
(v: string) => onTokenUnitChange(v as TokenUnit),
[onTokenUnitChange]
)
const handleViewModeChange = useCallback(
(v: string) => onViewModeChange(v as ViewMode),
[onViewModeChange]
)
const handleRechargePriceChange = useCallback(
(v: string) => onRechargePriceChange(v === 'recharge'),
[onRechargePriceChange]
)
return (
<div className='space-y-3'>
<div className='flex items-center gap-3'>
<div className='hidden flex-1 flex-wrap items-center gap-2 lg:flex'>
<FilterChip
label={t('Pricing')}
value={props.quotaTypeFilter}
options={typeOptions}
onChange={props.onQuotaTypeChange}
defaultValue={QUOTA_TYPES.ALL}
/>
<FilterChip
label={t('Endpoint')}
value={props.endpointTypeFilter}
options={endpointOptions}
onChange={props.onEndpointTypeChange}
defaultValue={ENDPOINT_TYPES.ALL}
/>
{props.vendors.length > 0 && (
<FilterChip
label={t('Vendor')}
value={props.vendorFilter}
options={vendorOptions}
onChange={props.onVendorChange}
searchPlaceholder={t('Search vendors...')}
/>
)}
{props.groups.length > 0 && (
<FilterChip
label={t('Group')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
searchPlaceholder={t('Search groups...')}
/>
)}
{props.tags.length > 0 && (
<FilterChip
label={t('Tag')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
searchPlaceholder={t('Search tags...')}
/>
)}
</div>
<Button
variant='outline'
size='sm'
onClick={() => setMobileOpen(true)}
className='gap-1.5 lg:hidden'
>
<Filter className='size-4' />
{t('Filters')}
{props.activeFilterCount > 0 && (
<Badge className='ml-0.5 size-5 justify-center rounded-full p-0 text-[10px]'>
{props.activeFilterCount}
</Badge>
)}
</Button>
<div className='flex-1 lg:hidden' />
<div className='flex items-center gap-1.5'>
<div className='hidden items-center gap-1.5 sm:flex'>
<SegmentedControl
options={[
{ value: 'standard', label: t('Standard') },
{ value: 'recharge', label: t('Recharge') },
]}
value={props.showRechargePrice ? 'recharge' : 'standard'}
onChange={handleRechargePriceChange}
ariaLabel={t('Price display mode')}
/>
<SegmentedControl
options={[
{ value: 'M', label: '/1M' },
{ value: 'K', label: '/1K' },
]}
value={props.tokenUnit}
onChange={handleTokenUnitChange}
ariaLabel={t('Token unit')}
/>
<div className='bg-border mx-0.5 h-4 w-px' />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='h-8 gap-1.5 px-2.5 text-xs font-medium'
>
<ArrowUpDown className='size-3.5' />
<span className='hidden sm:inline'>
{sortLabels[props.sortBy as SortOption] || t('Sort')}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-44'>
{Object.entries(sortLabels).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => props.onSortChange(value)}
className='gap-2'
>
<Check
className={cn(
'size-4 shrink-0',
props.sortBy === value ? 'opacity-100' : 'opacity-0'
)}
/>
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<SegmentedControl
options={[
{
value: VIEW_MODES.TABLE,
icon: Table2,
tooltip: t('Table view'),
},
]}
value={props.viewMode}
onChange={handleViewModeChange}
ariaLabel={t('View mode')}
/>
</div>
</div>
{activeFilters.length > 0 && (
<div className='flex flex-wrap items-center gap-2'>
{activeFilters.map((filter) => (
<ActiveFilterBadge
key={filter.key}
label={filter.label}
onRemove={filter.onRemove}
/>
))}
<Button
variant='ghost'
size='sm'
onClick={props.onClearFilters}
className='text-muted-foreground hover:text-foreground h-6 gap-1 px-2 text-xs'
>
<RotateCcw className='size-3' />
{t('Clear all')}
</Button>
</div>
)}
<div className='text-muted-foreground flex items-baseline gap-1 text-sm'>
<span className='text-foreground font-semibold tabular-nums'>
{props.filteredCount.toLocaleString()}
</span>
<span>{props.filteredCount === 1 ? t('model') : t('models')}</span>
{props.hasActiveFilters && props.totalCount && (
<span className='text-muted-foreground/60'>
{t('of')} {props.totalCount.toLocaleString()}
</span>
)}
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent
side='right'
className='flex h-dvh w-full flex-col overflow-hidden p-0 sm:max-w-md'
>
<SheetHeader className='border-b px-4 py-3 sm:px-6 sm:py-4'>
<SheetTitle>{t('Filters')}</SheetTitle>
<SheetDescription className='sr-only'>
{t('Filter models by type, endpoint, vendor, group and tags')}
</SheetDescription>
</SheetHeader>
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4 sm:space-y-6 sm:px-6 sm:py-5'>
<MobileFilterGroup
title={t('Pricing Type')}
value={props.quotaTypeFilter}
options={typeOptions}
onChange={props.onQuotaTypeChange}
/>
<MobileFilterGroup
title={t('Endpoint Type')}
value={props.endpointTypeFilter}
options={endpointOptions}
onChange={props.onEndpointTypeChange}
/>
{props.vendors.length > 0 && (
<MobileFilterGroup
title={t('Vendor')}
value={props.vendorFilter}
options={vendorOptions}
onChange={props.onVendorChange}
/>
)}
{props.groups.length > 0 && (
<MobileFilterGroup
title={t('Group')}
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
/>
)}
{props.tags.length > 0 && (
<MobileFilterGroup
title={t('Tag')}
value={props.tagFilter}
options={tagOptions}
onChange={props.onTagChange}
/>
)}
<div className='border-t pt-5'>
<h3 className='text-foreground mb-3 text-sm font-semibold'>
{t('Display Options')}
</h3>
<div className='space-y-3 sm:space-y-4'>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs'>
{t('Price display')}
</p>
<SegmentedControl
options={[
{ value: 'standard', label: t('Standard') },
{ value: 'recharge', label: t('Recharge') },
]}
value={props.showRechargePrice ? 'recharge' : 'standard'}
onChange={handleRechargePriceChange}
ariaLabel={t('Price display mode')}
/>
</div>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs'>
{t('Token unit')}
</p>
<SegmentedControl
options={[
{ value: 'M', label: t('Per 1M tokens') },
{ value: 'K', label: t('Per 1K tokens') },
]}
value={props.tokenUnit}
onChange={handleTokenUnitChange}
ariaLabel={t('Token unit')}
/>
</div>
</div>
</div>
</div>
<SheetFooter className='border-t px-4 py-3 sm:px-6 sm:py-4'>
<div className='grid w-full grid-cols-2 gap-2 sm:flex sm:gap-3'>
{props.hasActiveFilters && (
<Button
variant='outline'
onClick={props.onClearFilters}
className='flex-1'
>
{t('Reset')}
</Button>
)}
<Button onClick={() => setMobileOpen(false)} className='flex-1'>
{t('Show')} {props.filteredCount} {t('models')}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
+5 -2
View File
@@ -1,4 +1,3 @@
export { FilterBar } from './filter-bar'
export { PricingSidebar } from './pricing-sidebar'
export { PricingToolbar } from './pricing-toolbar'
export { ModelCard } from './model-card'
@@ -6,5 +5,9 @@ export { ModelCardGrid } from './model-card-grid'
export { LoadingSkeleton } from './loading-skeleton'
export { EmptyState } from './empty-state'
export { SearchBar } from './search-bar'
export { ModelDetails, ModelDetailsDrawer } from './model-details'
export {
ModelDetails,
ModelDetailsContent,
ModelDetailsDrawer,
} from './model-details'
export { PricingTable } from './pricing-table'
+44 -18
View File
@@ -3,16 +3,16 @@ import { ChevronRight, Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { StatusBadge } from '@/components/status-badge'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { StatusBadge } from '@/components/status-badge'
import { DEFAULT_TOKEN_UNIT } from '../constants'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import { formatPrice, formatRequestPrice } from '../lib/price'
import {
getDynamicDisplayGroupRatio,
getDynamicPricingSummary,
} from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import { formatPrice, formatRequestPrice } from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
export interface ModelCardProps {
@@ -41,7 +41,8 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
: null
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
const isDynamicPricing =
props.model.billing_mode === 'tiered_expr' && Boolean(props.model.billing_expr)
props.model.billing_mode === 'tiered_expr' &&
Boolean(props.model.billing_expr)
const hasCachedPrice = isTokenBased && props.model.cache_ratio != null
const dynamicSummary = isDynamicPricing
? getDynamicPricingSummary(props.model, {
@@ -83,7 +84,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
)}
</div>
<div className='min-w-0'>
<h3 className='text-foreground truncate font-mono text-[15px] font-bold leading-tight'>
<h3 className='text-foreground truncate font-mono text-[15px] leading-tight font-bold'>
{props.model.model_name}
</h3>
<div className='mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs sm:mt-1 sm:gap-x-3'>
@@ -93,7 +94,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
<span className='text-amber-700 dark:text-amber-300'>
{t('Special billing expression')}
</span>
<code className='text-muted-foreground/70 mt-0.5 line-clamp-1 block break-all font-mono text-[11px]'>
<code className='text-muted-foreground/70 mt-0.5 line-clamp-1 block font-mono text-[11px] break-all'>
{dynamicSummary.rawExpression}
</code>
</span>
@@ -122,14 +123,28 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
<span className='text-muted-foreground whitespace-nowrap'>
{t('Input')}{' '}
<span className='text-foreground font-mono font-semibold'>
{formatPrice(props.model, 'input', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
{formatPrice(
props.model,
'input',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)}
</span>
/{tokenUnitLabel}
</span>
<span className='text-muted-foreground whitespace-nowrap'>
{t('Output')}{' '}
<span className='text-foreground font-mono font-semibold'>
{formatPrice(props.model, 'output', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
{formatPrice(
props.model,
'output',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)}
</span>
/{tokenUnitLabel}
</span>
@@ -137,7 +152,14 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
<span className='text-muted-foreground/60 whitespace-nowrap'>
{t('Cached')}{' '}
<span className='font-mono'>
{formatPrice(props.model, 'cache', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
{formatPrice(
props.model,
'cache',
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate
)}
</span>
</span>
)}
@@ -145,9 +167,14 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
) : (
<span className='text-muted-foreground whitespace-nowrap'>
<span className='text-foreground font-mono font-semibold'>
{formatRequestPrice(props.model, showRechargePrice, priceRate, usdExchangeRate)}
</span>
{' '}/ {t('request')}
{formatRequestPrice(
props.model,
showRechargePrice,
priceRate,
usdExchangeRate
)}
</span>{' '}
/ {t('request')}
</span>
)}
</div>
@@ -202,14 +229,13 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
{/* Footer row 2: endpoint + tag chips */}
<div className='mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 sm:mt-2 sm:gap-x-3 sm:gap-y-1'>
{bottomTags.map((item) => (
<span
key={item}
className='text-muted-foreground/70 text-xs'
>
<span key={item} className='text-muted-foreground/70 text-xs'>
{item}
</span>
))}
<span className='text-muted-foreground/50 text-xs'>{tokenUnitLabel}</span>
<span className='text-muted-foreground/50 text-xs'>
{tokenUnitLabel}
</span>
{hiddenCount > 0 && (
<span className='text-muted-foreground/40 text-xs'>
+{hiddenCount}
@@ -0,0 +1,871 @@
import { useMemo, useState } from 'react'
import {
ChevronRight,
ExternalLink,
Gauge,
KeyRound,
ScrollText,
ShieldCheck,
Sigma,
Zap,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { BundledLanguage } from 'shiki/bundle/web'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
CodeBlock,
CodeBlockCopyButton,
} from '@/components/ai-elements/code-block'
import {
buildRateLimits,
buildSupportedParameters,
formatRateLimit,
type SupportedParameter,
} from '../lib/mock-stats'
import { replaceModelInPath } from '../lib/model-helpers'
import { inferApiInfo } from '../lib/model-metadata'
import type { PricingModel } from '../types'
// ---------------------------------------------------------------------------
// Code-sample registry
// ---------------------------------------------------------------------------
//
// Each sample is keyed by language and endpoint type. The endpoint type comes
// from the model's `supported_endpoint_types`; we render samples only for the
// types the model actually supports. This keeps copy-pasted code accurate and
// provider-shaped (OpenAI vs Anthropic vs Gemini, etc.).
type Lang = 'curl' | 'python' | 'typescript' | 'javascript'
const LANG_LABELS: Record<Lang, string> = {
curl: 'cURL',
python: 'Python',
typescript: 'TypeScript',
javascript: 'JavaScript',
}
const LANG_HIGHLIGHT: Record<Lang, BundledLanguage> = {
curl: 'bash',
python: 'python',
typescript: 'typescript',
javascript: 'javascript',
}
type SampleContext = {
baseUrl: string
apiKeyEnv: string
modelName: string
endpointType: string
endpointPath: string
}
function buildChatSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const isResponses = ctx.endpointType === 'openai-response'
const isReasoning = /^o[1-4]|reasoning|thinking|deepseek-r/i.test(
ctx.modelName
)
const userMessage = 'Explain quantum entanglement in one paragraph.'
const bodyJson = isResponses
? JSON.stringify({ model: ctx.modelName, input: userMessage }, null, 2)
: JSON.stringify(
{
model: ctx.modelName,
messages: [{ role: 'user', content: userMessage }],
...(isReasoning ? {} : { temperature: 0.7 }),
},
null,
2
)
const fnCall = isResponses ? 'responses.create' : 'chat.completions.create'
if (lang === 'curl') {
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${bodyJson.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
'client = OpenAI(',
` base_url="${ctx.baseUrl}/v1",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
isResponses
? `response = client.${fnCall}(\n model="${ctx.modelName}",\n input="${userMessage}",\n)\n\nprint(response.output_text)`
: `completion = client.${fnCall}(\n model="${ctx.modelName}",\n messages=[\n {"role": "user", "content": "${userMessage}"}\n ],\n)\n\nprint(completion.choices[0].message.content)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
isResponses
? `const response = await client.${fnCall}({\n model: '${ctx.modelName}',\n input: '${userMessage}',\n})\n\nconsole.log(response.output_text)`
: `const completion = await client.${fnCall}({\n model: '${ctx.modelName}',\n messages: [{ role: 'user', content: '${userMessage}' }],\n})\n\nconsole.log(completion.choices[0].message.content)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify(${bodyJson}),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data)`,
].join('\n')
}
function buildAnthropicSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{
model: ctx.modelName,
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }],
},
null,
2
)
return [
`curl ${url} \\`,
` -H "x-api-key: $${ctx.apiKeyEnv}" \\`,
` -H "anthropic-version: 2023-06-01" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import anthropic',
'',
'client = anthropic.Anthropic(',
` base_url="${ctx.baseUrl}",`,
` api_key="<YOUR_API_KEY>",`,
')',
'',
`message = client.messages.create(`,
` model="${ctx.modelName}",`,
` max_tokens=1024,`,
` messages=[{"role": "user", "content": "${userMessage}"}],`,
')',
'',
'print(message.content[0].text)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import Anthropic from '@anthropic-ai/sdk'`,
'',
`const client = new Anthropic({`,
` baseURL: '${ctx.baseUrl}',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const message = await client.messages.create({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
`})`,
'',
`console.log(message.content[0].text)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` 'x-api-key': process.env.${ctx.apiKeyEnv},`,
` 'anthropic-version': '2023-06-01',`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` max_tokens: 1024,`,
` messages: [{ role: 'user', content: '${userMessage}' }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.content[0].text)`,
].join('\n')
}
function buildGeminiSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}?key=$${ctx.apiKeyEnv}`
const userMessage = 'Explain quantum entanglement in one paragraph.'
if (lang === 'curl') {
const body = JSON.stringify(
{ contents: [{ parts: [{ text: userMessage }] }] },
null,
2
)
return [
`curl '${url}' \\`,
` -H 'Content-Type: application/json' \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'import google.generativeai as genai',
'',
`genai.configure(api_key="<YOUR_API_KEY>")`,
'',
`model = genai.GenerativeModel("${ctx.modelName}")`,
`response = model.generate_content("${userMessage}")`,
'',
`print(response.text)`,
].join('\n')
}
if (lang === 'typescript') {
return [
`import { GoogleGenerativeAI } from '@google/generative-ai'`,
'',
`const genAI = new GoogleGenerativeAI(process.env.${ctx.apiKeyEnv}!)`,
`const model = genAI.getGenerativeModel({ model: '${ctx.modelName}' })`,
'',
`const result = await model.generateContent('${userMessage}')`,
`console.log(result.response.text())`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: { 'Content-Type': 'application/json' },`,
` body: JSON.stringify({`,
` contents: [{ parts: [{ text: '${userMessage}' }] }],`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.candidates[0].content.parts[0].text)`,
].join('\n')
}
function buildEmbeddingSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const text = 'The food was delicious and the waiter…'
if (lang === 'curl') {
const body = JSON.stringify({ model: ctx.modelName, input: text }, null, 2)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.embeddings.create(',
` model="${ctx.modelName}",`,
` input="${text}",`,
')',
'',
'print(response.data[0].embedding[:8])',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.embeddings.create({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
`})`,
'',
`console.log(response.data[0].embedding.slice(0, 8))`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` input: '${text}',`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].embedding.slice(0, 8))`,
].join('\n')
}
function buildImageSample(lang: Lang, ctx: SampleContext): string {
const url = `${ctx.baseUrl}${ctx.endpointPath}`
const prompt = 'A serene koi pond at sunset, ukiyo-e style.'
if (lang === 'curl') {
const body = JSON.stringify(
{ model: ctx.modelName, prompt, size: '1024x1024', n: 1 },
null,
2
)
return [
`curl ${url} \\`,
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '${body.replace(/\n/g, '\n ')}'`,
].join('\n')
}
if (lang === 'python') {
return [
'from openai import OpenAI',
'',
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
'',
'response = client.images.generate(',
` model="${ctx.modelName}",`,
` prompt="${prompt}",`,
` size="1024x1024",`,
` n=1,`,
')',
'',
'print(response.data[0].url)',
].join('\n')
}
if (lang === 'typescript') {
return [
`import OpenAI from 'openai'`,
'',
`const client = new OpenAI({`,
` baseURL: '${ctx.baseUrl}/v1',`,
` apiKey: process.env.${ctx.apiKeyEnv},`,
`})`,
'',
`const response = await client.images.generate({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
`})`,
'',
`console.log(response.data[0].url)`,
].join('\n')
}
return [
`const response = await fetch('${url}', {`,
` method: 'POST',`,
` headers: {`,
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
` 'Content-Type': 'application/json',`,
` },`,
` body: JSON.stringify({`,
` model: '${ctx.modelName}',`,
` prompt: '${prompt}',`,
` size: '1024x1024',`,
` n: 1,`,
` }),`,
`})`,
'',
`const data = await response.json()`,
`console.log(data.data[0].url)`,
].join('\n')
}
function buildSample(
lang: Lang,
endpointType: string,
ctx: SampleContext
): string {
if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx)
if (endpointType === 'gemini') return buildGeminiSample(lang, ctx)
if (endpointType === 'embeddings' || endpointType === 'jina-rerank')
return buildEmbeddingSample(lang, ctx)
if (endpointType === 'image-generation') return buildImageSample(lang, ctx)
return buildChatSample(lang, ctx)
}
// ---------------------------------------------------------------------------
// Code samples section
// ---------------------------------------------------------------------------
function CodeSamplesSection(props: {
model: PricingModel
endpointMap: Record<string, { path?: string; method?: string }>
}) {
const { t } = useTranslation()
const { status } = useStatus()
const baseUrl = useMemo(() => {
const candidate =
(status as Record<string, unknown> | null)?.server_address ??
(status as Record<string, unknown> | null)?.serverAddress ??
(status?.data as Record<string, unknown> | undefined)?.server_address ??
(status?.data as Record<string, unknown> | undefined)?.serverAddress
if (candidate && typeof candidate === 'string') {
return candidate.replace(/\/$/, '')
}
if (typeof window !== 'undefined') return window.location.origin
return 'https://api.example.com'
}, [status])
const endpoints = useMemo(() => {
const types = props.model.supported_endpoint_types || []
return types
.map((type) => {
const info = props.endpointMap[type] || {}
let path = info.path || ''
if (path && path.includes('{model}')) {
path = replaceModelInPath(path, props.model.model_name || '')
}
return { type, path, method: info.method || 'POST' }
})
.filter((e) => Boolean(e.path))
}, [props.model, props.endpointMap])
const [endpointType, setEndpointType] = useState<string>(
endpoints[0]?.type ?? ''
)
const [lang, setLang] = useState<Lang>('curl')
const activeEndpoint = useMemo(() => {
return endpoints.find((e) => e.type === endpointType) ?? endpoints[0]
}, [endpointType, endpoints])
if (endpoints.length === 0 || !activeEndpoint) {
return null
}
const code = buildSample(lang, activeEndpoint.type, {
baseUrl,
apiKeyEnv: 'NEW_API_KEY',
modelName: props.model.model_name || '',
endpointType: activeEndpoint.type,
endpointPath: activeEndpoint.path,
})
return (
<section>
<SectionTitle icon={ScrollText}>{t('Code samples')}</SectionTitle>
<div className='flex flex-wrap items-center gap-2'>
{endpoints.length > 1 && (
<Tabs value={endpointType} onValueChange={setEndpointType}>
<TabsList className='bg-muted/40 h-8 p-0.5'>
{endpoints.map((ep) => (
<TabsTrigger
key={ep.type}
value={ep.type}
className='h-7 px-2.5 text-xs'
>
{ep.type}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<Tabs
value={lang}
onValueChange={(v) => setLang(v as Lang)}
className='ml-auto'
>
<TabsList className='bg-muted/40 h-8 p-0.5'>
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => (
<TabsTrigger key={l} value={l} className='h-7 px-2.5 text-xs'>
{LANG_LABELS[l]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className='mt-3'>
<CodeBlock code={code} language={LANG_HIGHLIGHT[lang]}>
<CodeBlockCopyButton />
</CodeBlock>
</div>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Replace')}{' '}
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
{'<YOUR_API_KEY>'}
</code>{' '}
{t('with the API key from your token settings.')}
</p>
</section>
)
}
// ---------------------------------------------------------------------------
// Supported parameters table
// ---------------------------------------------------------------------------
function SupportedParametersSection(props: { model: PricingModel }) {
const { t } = useTranslation()
const params = useMemo(
() => buildSupportedParameters(props.model),
[props.model]
)
if (params.length === 0) return null
return (
<section>
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'>
<Table>
<TableHeader>
<TableRow className='bg-muted/30 hover:bg-muted/30'>
<TableHead className='h-9 w-44 text-xs'>
{t('Parameter')}
</TableHead>
<TableHead className='h-9 w-24 text-xs'>{t('Type')}</TableHead>
<TableHead className='h-9 w-32 text-xs'>
{t('Default / range')}
</TableHead>
<TableHead className='h-9 text-xs'>{t('Description')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{params.map((p) => (
<TableRow key={p.name} className='hover:bg-muted/20'>
<TableCell className='py-2 align-top'>
<div className='flex items-center gap-1.5'>
<code className='font-mono text-xs font-medium'>
{p.name}
</code>
{p.required && (
<Badge
variant='outline'
className='h-4 border-rose-500/40 px-1 text-[9px] text-rose-600 dark:text-rose-400'
>
{t('required')}
</Badge>
)}
</div>
</TableCell>
<TableCell className='py-2 align-top'>
<Badge
variant='secondary'
className='h-5 rounded-sm px-1.5 font-mono text-[10px] font-normal'
>
{p.type}
</Badge>
</TableCell>
<TableCell className='py-2 align-top'>
<ParamRangeCell param={p} />
</TableCell>
<TableCell className='text-muted-foreground py-2 align-top text-xs'>
{t(p.descriptionKey)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
)
}
function ParamRangeCell(props: { param: SupportedParameter }) {
const { defaultValue, range, enumValues } = props.param
if (defaultValue !== undefined) {
return (
<div className='flex flex-wrap items-center gap-1'>
<span className='text-muted-foreground text-[11px]'>=</span>
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
{String(defaultValue)}
</code>
{range && (
<span className='text-muted-foreground text-[11px]'>{range}</span>
)}
</div>
)
}
if (range) {
return (
<span className='text-muted-foreground font-mono text-[11px]'>
{range}
</span>
)
}
if (enumValues && enumValues.length > 0) {
return (
<div className='flex flex-wrap gap-0.5'>
{enumValues.map((v) => (
<code
key={v}
className='bg-muted text-muted-foreground rounded px-1 py-0.5 font-mono text-[10px]'
>
{v}
</code>
))}
</div>
)
}
return <span className='text-muted-foreground/60 text-[11px]'></span>
}
// ---------------------------------------------------------------------------
// Rate-limits table
// ---------------------------------------------------------------------------
function RateLimitsSection(props: { model: PricingModel }) {
const { t } = useTranslation()
const limits = useMemo(() => buildRateLimits(props.model), [props.model])
if (limits.length === 0) return null
return (
<section>
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'>
<Table>
<TableHeader>
<TableRow className='bg-muted/30 hover:bg-muted/30'>
<TableHead className='h-9 text-xs'>{t('Group')}</TableHead>
<TableHead className='h-9 text-right text-xs'>RPM</TableHead>
<TableHead className='h-9 text-right text-xs'>TPM</TableHead>
<TableHead className='h-9 text-right text-xs'>RPD</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{limits.map((l) => (
<TableRow key={l.group} className='hover:bg-muted/20'>
<TableCell className='py-2 font-mono text-xs'>
{l.group}
</TableCell>
<TableCell className='py-2 text-right font-mono text-xs'>
{formatRateLimit(l.rpm)}
</TableCell>
<TableCell className='py-2 text-right font-mono text-xs'>
{formatRateLimit(l.tpm)}
</TableCell>
<TableCell className='py-2 text-right font-mono text-xs'>
{formatRateLimit(l.rpd)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
{t(
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
)}
</p>
</section>
)
}
// ---------------------------------------------------------------------------
// Provider info card (vendor / tokenizer / license / privacy)
// ---------------------------------------------------------------------------
//
// Exported separately so the Overview tab can render it alongside capabilities
// and modalities (i.e. "what is this model?" rather than "how do I call it?").
export function ModelDetailsProviderInfo(props: { model: PricingModel }) {
const { t } = useTranslation()
const info = useMemo(() => inferApiInfo(props.model), [props.model])
return (
<section>
<SectionTitle icon={ShieldCheck}>
{t('Provider & data privacy')}
</SectionTitle>
<div className='border-border/60 bg-border/60 grid grid-cols-1 gap-px overflow-hidden rounded-lg border sm:grid-cols-2'>
<InfoCell label={t('Provider')}>
<div className='flex items-center gap-1.5'>
<span className='text-sm font-medium'>{info.vendor_label}</span>
{info.homepage && (
<a
href={info.homepage}
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground inline-flex items-center gap-0.5 text-[11px]'
>
{t('Docs')}
<ExternalLink className='size-3' />
</a>
)}
</div>
</InfoCell>
<InfoCell label={t('Tokenizer')}>
<div className='flex flex-col gap-0.5'>
<code className='font-mono text-xs'>{info.tokenizer}</code>
{info.tokenizer_note && (
<span className='text-muted-foreground text-[10px]'>
{info.tokenizer_note}
</span>
)}
</div>
</InfoCell>
<InfoCell label={t('License')}>
<div className='flex flex-col gap-1'>
<span className='text-sm'>{info.license}</span>
<Badge
variant='outline'
className={cn(
'h-4 w-fit px-1.5 text-[9px] font-medium',
info.license_kind === 'open' &&
'border-emerald-500/40 text-emerald-600 dark:text-emerald-400',
info.license_kind === 'open-weight' &&
'border-sky-500/40 text-sky-600 dark:text-sky-400',
info.license_kind === 'proprietary' &&
'border-amber-500/40 text-amber-600 dark:text-amber-400'
)}
>
{info.license_kind === 'open'
? t('Open source')
: info.license_kind === 'open-weight'
? t('Open weights')
: info.license_kind === 'proprietary'
? t('Proprietary')
: t('Unknown')}
</Badge>
</div>
</InfoCell>
<InfoCell label={t('Data retention')}>
<span className='text-sm'>
{info.data_retention_days === 0
? t('Zero retention')
: `${info.data_retention_days} ${t('days')}`}
</span>
<span className='text-muted-foreground text-[10px]'>
{info.training_opt_out
? t('Not used for upstream training by default')
: t('May be used for training by upstream provider')}
</span>
</InfoCell>
</div>
</section>
)
}
function InfoCell(props: { label: string; children: React.ReactNode }) {
return (
<div className='bg-card flex flex-col gap-1 px-3 py-2.5'>
<span className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
{props.label}
</span>
{props.children}
</div>
)
}
// ---------------------------------------------------------------------------
// Authentication preview
// ---------------------------------------------------------------------------
function AuthSection() {
const { t } = useTranslation()
return (
<section>
<SectionTitle icon={KeyRound}>{t('Authentication')}</SectionTitle>
<div className='border-border/60 bg-muted/20 flex items-start gap-2 rounded-lg border p-3'>
<ChevronRight className='text-muted-foreground mt-0.5 size-3.5 shrink-0' />
<div className='space-y-1.5 text-xs leading-relaxed'>
<p>
{t('All requests must include')}{' '}
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
Authorization: Bearer &lt;TOKEN&gt;
</code>{' '}
{t('header. Anthropic-formatted endpoints accept the')}{' '}
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
x-api-key
</code>{' '}
{t('header instead.')}
</p>
<p className='text-muted-foreground'>
{t(
'Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.'
)}
</p>
</div>
</div>
</section>
)
}
// ---------------------------------------------------------------------------
// Composite API tab
// ---------------------------------------------------------------------------
export function ModelDetailsApi(props: {
model: PricingModel
endpointMap: Record<string, { path?: string; method?: string }>
}) {
return (
<div className='space-y-6'>
<CodeSamplesSection model={props.model} endpointMap={props.endpointMap} />
<AuthSection />
<SupportedParametersSection model={props.model} />
<RateLimitsSection model={props.model} />
</div>
)
}
// ---------------------------------------------------------------------------
// Local UI helpers
// ---------------------------------------------------------------------------
function SectionTitle(props: {
children: React.ReactNode
icon: React.ComponentType<{ className?: string }>
}) {
const Icon = props.icon
return (
<h3 className='text-foreground mb-3 flex items-center gap-1.5 text-sm font-semibold'>
<Icon className='text-muted-foreground/70 size-3.5' />
{props.children}
</h3>
)
}
// Re-export so the parent can keep its own SectionTitle if it wants:
export { Zap as ApiTabIcon }
@@ -0,0 +1,212 @@
import { useMemo } from 'react'
import {
ArrowDownRight,
ArrowUpRight,
ExternalLink,
Trophy,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
buildAppRankings,
formatTokenVolume,
type AppRanking,
} from '../lib/mock-stats'
import type { PricingModel } from '../types'
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
})
function RankBadge(props: { rank: number }) {
const rank = props.rank
const isPodium = rank <= 3
const palette =
rank === 1
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
: rank === 2
? 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300'
: rank === 3
? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
: 'bg-muted text-muted-foreground'
return (
<span
className={cn(
'inline-flex size-7 shrink-0 items-center justify-center rounded-full font-mono text-xs font-bold tabular-nums',
palette
)}
>
{isPodium ? <Trophy className='size-3.5' /> : rank}
</span>
)
}
function GrowthChip(props: { value: number }) {
const value = props.value
const isUp = value > 0
const isDown = value < 0
const palette = isUp
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
: isDown
? 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300'
: 'bg-muted text-muted-foreground'
const Icon = isUp ? ArrowUpRight : isDown ? ArrowDownRight : null
const formatted = `${value > 0 ? '+' : ''}${value.toFixed(1)}%`
return (
<span
className={cn(
'inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold tabular-nums',
palette
)}
>
{Icon && <Icon className='size-3' />}
{formatted}
</span>
)
}
function AppLink(props: { app: AppRanking }) {
if (!props.app.url) {
return <span className='text-foreground'>{props.app.name}</span>
}
return (
<a
href={props.app.url}
target='_blank'
rel='noreferrer'
className='text-foreground hover:text-primary inline-flex items-center gap-1 transition-colors'
>
{props.app.name}
<ExternalLink className='text-muted-foreground/40 size-3' />
</a>
)
}
export function ModelDetailsApps(props: { model: PricingModel }) {
const { t } = useTranslation()
const apps = useMemo(() => buildAppRankings(props.model, 12), [props.model])
if (apps.length === 0) {
return (
<div className='text-muted-foreground rounded-lg border p-6 text-center text-sm'>
{t('No app usage data available for this model.')}
</div>
)
}
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
const top = apps[0]
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return (
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
<div className='bg-muted/20 rounded-lg border p-3'>
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
{t('Tracked apps')}
</div>
<div className='text-foreground mt-1 font-mono text-lg font-semibold tabular-nums'>
{apps.length}
</div>
<p className='text-muted-foreground/70 text-[11px]'>
{t('Top integrations using this model')}
</p>
</div>
<div className='bg-muted/20 rounded-lg border p-3'>
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
{t('Monthly tokens')}
</div>
<div className='text-foreground mt-1 font-mono text-lg font-semibold tabular-nums'>
{COMPACT_NUMBER.format(totalMonthlyTokens)}
</div>
<p className='text-muted-foreground/70 text-[11px]'>
{t('Aggregated across the apps below')}
</p>
</div>
<div className='bg-muted/20 rounded-lg border p-3'>
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
{t('#1 by usage')}
</div>
<div className='text-foreground mt-1 truncate text-base font-semibold'>
{top.name}
</div>
<p className='text-muted-foreground/70 truncate text-[11px]'>
{top.category} · {formatTokenVolume(top.monthly_tokens)}{' '}
{t('tokens / mo')}
</p>
</div>
</div>
<div className='overflow-x-auto rounded-lg border'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={cn(headerCellClass, 'w-12')}>#</TableHead>
<TableHead className={headerCellClass}>{t('App')}</TableHead>
<TableHead
className={cn(headerCellClass, 'hidden md:table-cell')}
>
{t('Category')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Monthly tokens')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('30d change')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apps.map((app) => (
<TableRow key={`${app.rank}-${app.name}`}>
<TableCell className='py-2.5'>
<RankBadge rank={app.rank} />
</TableCell>
<TableCell className='py-2.5'>
<div className='flex items-center gap-3'>
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
{app.initial}
</span>
<div className='min-w-0'>
<div className='text-sm font-medium'>
<AppLink app={app} />
</div>
<p className='text-muted-foreground line-clamp-1 text-xs'>
{app.description}
</p>
</div>
</div>
</TableCell>
<TableCell className='text-muted-foreground hidden py-2.5 text-xs md:table-cell'>
{app.category}
</TableCell>
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
{formatTokenVolume(app.monthly_tokens)}
</TableCell>
<TableCell className='py-2.5 text-right'>
<GrowthChip value={app.growth_pct} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
{t(
'App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.'
)}
</p>
</div>
)
}
@@ -0,0 +1,163 @@
import {
BookOpenCheck,
Braces,
Code2,
Database,
FileCode,
Globe,
type LucideIcon,
PanelTopOpen,
ScanEye,
Settings2,
Sparkles,
Workflow,
Zap,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { ModelCapability } from '../types'
type CapabilityMeta = {
icon: LucideIcon
labelKey: string
descriptionKey: string
}
const CAPABILITY_META: Record<ModelCapability, CapabilityMeta> = {
function_calling: {
icon: Workflow,
labelKey: 'Function calling',
descriptionKey:
'Invoke developer-defined functions with structured arguments',
},
streaming: {
icon: Zap,
labelKey: 'Streaming',
descriptionKey: 'Stream tokens incrementally as they are generated',
},
vision: {
icon: ScanEye,
labelKey: 'Vision',
descriptionKey: 'Understand image inputs alongside text',
},
json_mode: {
icon: Braces,
labelKey: 'JSON mode',
descriptionKey: 'Force a syntactically valid JSON response',
},
structured_output: {
icon: FileCode,
labelKey: 'Structured output',
descriptionKey: 'Return data conforming to a JSON schema',
},
reasoning: {
icon: Sparkles,
labelKey: 'Reasoning',
descriptionKey: 'Multi-step thinking before final answer',
},
tools: {
icon: Settings2,
labelKey: 'Tools',
descriptionKey: 'Use external tools to extend capabilities',
},
system_prompt: {
icon: PanelTopOpen,
labelKey: 'System prompt',
descriptionKey: 'Steer behaviour with a system instruction',
},
web_search: {
icon: Globe,
labelKey: 'Web search',
descriptionKey: 'Search the public web at inference time',
},
code_interpreter: {
icon: Code2,
labelKey: 'Code interpreter',
descriptionKey: 'Execute code in a sandbox during the response',
},
caching: {
icon: Database,
labelKey: 'Prompt caching',
descriptionKey: 'Cache repeated prompt prefixes for cheaper, faster reuse',
},
embeddings: {
icon: BookOpenCheck,
labelKey: 'Embeddings',
descriptionKey: 'Return vector embeddings for inputs',
},
}
/**
* Order capabilities for display. We put the most user-facing capabilities
* first, then the rest. Anything not listed sinks to the bottom in a stable
* order so the layout looks tidy across models.
*/
const CAPABILITY_ORDER: ModelCapability[] = [
'streaming',
'function_calling',
'tools',
'json_mode',
'structured_output',
'vision',
'reasoning',
'caching',
'system_prompt',
'web_search',
'code_interpreter',
'embeddings',
]
function orderCapabilities(capabilities: ModelCapability[]): ModelCapability[] {
const set = new Set(capabilities)
const ordered = CAPABILITY_ORDER.filter((c) => set.has(c))
for (const c of capabilities) {
if (!ordered.includes(c)) ordered.push(c)
}
return ordered
}
export function ModelDetailsCapabilities(props: {
capabilities: ModelCapability[]
}) {
const { t } = useTranslation()
const ordered = orderCapabilities(props.capabilities)
if (ordered.length === 0) {
return (
<p className='text-muted-foreground text-sm'>
{t('No capabilities reported for this model.')}
</p>
)
}
return (
<div className='grid grid-cols-2 gap-2 @md/details:grid-cols-3 @2xl/details:grid-cols-4'>
{ordered.map((capability) => {
const meta = CAPABILITY_META[capability]
if (!meta) return null
const Icon = meta.icon
return (
<div
key={capability}
className={cn(
'group flex items-start gap-2 rounded-lg border p-3 transition-colors',
'hover:bg-muted/30'
)}
>
<span className='bg-muted text-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md transition-colors group-hover:bg-emerald-100 group-hover:text-emerald-700 dark:group-hover:bg-emerald-500/20 dark:group-hover:text-emerald-300'>
<Icon className='size-3.5' />
</span>
<div className='min-w-0 flex-1'>
<div className='text-foreground truncate text-xs font-semibold'>
{t(meta.labelKey)}
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[11px] leading-snug'>
{t(meta.descriptionKey)}
</p>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,298 @@
import { useMemo } from 'react'
import { VChart } from '@visactor/react-vchart'
import { useTranslation } from 'react-i18next'
import { useChartTheme } from '@/lib/use-chart-theme'
import { cn } from '@/lib/utils'
import { VCHART_OPTION } from '@/lib/vchart'
import type { LatencyTimePoint, UptimeDayPoint } from '../lib/mock-stats'
function formatHourLabel(iso: string): string {
const date = new Date(iso)
const hours = date.getHours()
return `${String(hours).padStart(2, '0')}:00`
}
function formatDayLabel(date: string): string {
const parsed = new Date(date)
return parsed.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
}
// ---------------------------------------------------------------------------
// Latency trend chart (24h, multi-group line chart)
// ---------------------------------------------------------------------------
export function LatencyTrendChart(props: {
series: LatencyTimePoint[]
className?: string
}) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const spec = useMemo(() => {
if (props.series.length === 0) return null
const data = props.series.map((point) => ({
time: formatHourLabel(point.timestamp),
group: point.group,
ttft: point.ttft_ms,
}))
return {
type: 'line' as const,
data: [{ id: 'latency', values: data }],
xField: 'time',
yField: 'ttft',
seriesField: 'group',
smooth: true,
point: { visible: false },
legends: { visible: true, orient: 'top', position: 'start' },
tooltip: {
mark: {
title: { value: (d: { time: string }) => d.time },
content: [
{
key: (d: { group: string }) => d.group,
value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
},
],
},
},
axes: [
{
orient: 'bottom',
label: {
style: { fill: 'currentColor', fontSize: 10 },
},
tick: { visible: false },
},
{
orient: 'left',
label: {
formatMethod: (val: number | string) => `${val} ms`,
style: { fill: 'currentColor', fontSize: 10 },
},
grid: { visible: true, style: { lineDash: [3, 3] } },
},
],
}
}, [props.series])
if (props.series.length === 0) {
return (
<div
className={cn(
'text-muted-foreground flex h-48 items-center justify-center rounded-lg border text-xs',
props.className
)}
>
{t('No latency data available')}
</div>
)
}
return (
<div className={cn('h-64 sm:h-72', props.className)}>
{themeReady && spec && (
<VChart
key={`latency-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Uptime bar chart (30 days)
// ---------------------------------------------------------------------------
export function UptimeBarChart(props: {
series: UptimeDayPoint[]
className?: string
}) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const spec = useMemo(() => {
if (props.series.length === 0) return null
const data = props.series.map((point) => ({
date: formatDayLabel(point.date),
uptime: point.uptime_pct,
incidents: point.incidents,
outage: point.outage_minutes,
}))
return {
type: 'bar' as const,
data: [{ id: 'uptime', values: data }],
xField: 'date',
yField: 'uptime',
bar: {
style: {
fill: (datum: { uptime: number }) => {
if (datum.uptime >= 99.9) return '#10b981'
if (datum.uptime >= 99.0) return '#f59e0b'
return '#ef4444'
},
cornerRadius: 2,
},
},
tooltip: {
mark: {
title: { value: (d: { date: string }) => d.date },
content: [
{
key: t('Uptime'),
value: (d: { uptime: number }) => `${d.uptime.toFixed(2)}%`,
},
{
key: t('Incidents'),
value: (d: { incidents: number }) => `${d.incidents}`,
},
{
key: t('Outage'),
value: (d: { outage: number }) => `${d.outage} ${t('minutes')}`,
},
],
},
},
axes: [
{
orient: 'bottom',
label: {
style: { fill: 'currentColor', fontSize: 10 },
autoLimit: true,
},
tick: { visible: false },
},
{
orient: 'left',
min: 95,
max: 100,
label: {
formatMethod: (val: number | string) => `${val}%`,
style: { fill: 'currentColor', fontSize: 10 },
},
grid: { visible: true, style: { lineDash: [3, 3] } },
},
],
}
}, [props.series, t])
if (props.series.length === 0) {
return (
<div
className={cn(
'text-muted-foreground flex h-48 items-center justify-center rounded-lg border text-xs',
props.className
)}
>
{t('No uptime data available')}
</div>
)
}
return (
<div className={cn('h-56 sm:h-64', props.className)}>
{themeReady && spec && (
<VChart
key={`uptime-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Throughput by group (horizontal bar)
// ---------------------------------------------------------------------------
export function ThroughputBarChart(props: {
rows: { group: string; throughput_tps: number }[]
className?: string
}) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const filtered = useMemo(
() => props.rows.filter((r) => r.throughput_tps > 0),
[props.rows]
)
const spec = useMemo(() => {
if (filtered.length === 0) return null
return {
type: 'bar' as const,
direction: 'horizontal' as const,
data: [{ id: 'tput', values: filtered.map((r) => ({ ...r })) }],
xField: 'throughput_tps',
yField: 'group',
bar: {
style: { fill: '#6366f1', cornerRadius: 2 },
},
label: {
visible: true,
position: 'right',
style: { fontSize: 11, fill: 'currentColor' },
formatMethod: (text: string) => `${text} t/s`,
},
axes: [
{
orient: 'left',
label: { style: { fill: 'currentColor', fontSize: 10 } },
tick: { visible: false },
},
{
orient: 'bottom',
label: { style: { fill: 'currentColor', fontSize: 10 } },
grid: { visible: true, style: { lineDash: [3, 3] } },
},
],
tooltip: {
mark: {
title: { value: (d: { group: string }) => d.group },
content: [
{
key: t('Throughput'),
value: (d: { throughput_tps: number }) =>
`${d.throughput_tps.toFixed(1)} t/s`,
},
],
},
},
}
}, [filtered, t])
if (filtered.length === 0) {
return null
}
return (
<div className={cn('h-48 sm:h-56', props.className)}>
{themeReady && spec && (
<VChart
key={`tput-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
)
}
@@ -0,0 +1,155 @@
import {
FileText,
Image as ImageIcon,
Mic2,
Type as TypeIcon,
Video,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import type { Modality } from '../types'
type IconComponent = React.ComponentType<{ className?: string }>
const MODALITY_META: Record<
Modality,
{ icon: IconComponent; labelKey: string }
> = {
text: { icon: TypeIcon, labelKey: 'Text' },
image: { icon: ImageIcon, labelKey: 'Image' },
audio: { icon: Mic2, labelKey: 'Audio' },
video: { icon: Video, labelKey: 'Video' },
file: { icon: FileText, labelKey: 'File' },
}
const ALL_MODALITIES: Modality[] = ['text', 'image', 'audio', 'video', 'file']
/** Inline modality icons (used by the quick-stats flow). */
export function ModalityIcons(props: {
modalities: Modality[]
className?: string
}) {
const { t } = useTranslation()
if (props.modalities.length === 0) {
return <span className='text-muted-foreground text-xs'></span>
}
return (
<span className='inline-flex items-center gap-1'>
{props.modalities.map((modality) => {
const meta = MODALITY_META[modality]
const Icon = meta.icon
return (
<Tooltip key={modality}>
<TooltipTrigger
render={
<span
aria-label={t(meta.labelKey)}
className='text-foreground/80 inline-flex'
/>
}
>
<Icon className={cn('size-3.5', props.className)} />
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{t(meta.labelKey)}
</TooltipContent>
</Tooltip>
)
})}
</span>
)
}
/**
* 2 × N matrix showing which modalities are supported as input vs output.
* Cells with a checkmark indicate support; empty cells show a dash.
*/
export function ModalitiesMatrix(props: {
input: Modality[]
output: Modality[]
}) {
const { t } = useTranslation()
const inputSet = new Set(props.input)
const outputSet = new Set(props.output)
const renderRow = (label: string, set: Set<Modality>) => (
<tr>
<th
scope='row'
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{label}
</th>
{ALL_MODALITIES.map((modality) => {
const enabled = set.has(modality)
const Icon = MODALITY_META[modality].icon
return (
<td
key={modality}
className={cn(
'border-l px-3 py-2 text-center',
enabled
? 'bg-emerald-50/40 dark:bg-emerald-500/10'
: 'bg-background'
)}
>
<span
className={cn(
'inline-flex items-center justify-center',
enabled
? 'text-emerald-700 dark:text-emerald-300'
: 'text-muted-foreground/40'
)}
aria-label={
enabled
? t('{{modality}} supported', {
modality: t(MODALITY_META[modality].labelKey),
})
: t('{{modality}} not supported', {
modality: t(MODALITY_META[modality].labelKey),
})
}
>
<Icon className='size-4' />
</span>
</td>
)
})}
</tr>
)
return (
<div className='overflow-x-auto rounded-lg border'>
<table className='w-full text-sm'>
<thead>
<tr className='bg-muted/40'>
<th
scope='col'
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
>
{t('Modality')}
</th>
{ALL_MODALITIES.map((modality) => (
<th
key={modality}
scope='col'
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
>
{t(MODALITY_META[modality].labelKey)}
</th>
))}
</tr>
</thead>
<tbody>
{renderRow(t('Input'), inputSet)}
{renderRow(t('Output'), outputSet)}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,335 @@
import { useMemo } from 'react'
import {
Activity,
AlertTriangle,
Gauge,
HeartPulse,
Timer,
TrendingUp,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { GroupBadge } from '@/components/group-badge'
import {
aggregateUptime,
buildGroupPerformance,
buildLatencyTimeSeries,
buildUptimeSeries,
formatLatency,
formatThroughput,
formatUptimePct,
type UptimeDayPoint,
} from '../lib/mock-stats'
import type { PricingModel } from '../types'
import {
LatencyTrendChart,
ThroughputBarChart,
UptimeBarChart,
} from './model-details-charts'
import { UptimeSparkline } from './model-details-uptime-sparkline'
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
})
function StatCard(props: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
hint?: string
intent?: 'default' | 'warning' | 'success'
}) {
const Icon = props.icon
const intent = props.intent ?? 'default'
return (
<div className='bg-background flex flex-col gap-1 rounded-lg border p-3'>
<span className='text-muted-foreground inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wider uppercase'>
<Icon className='size-3' />
{props.label}
</span>
<span
className={cn(
'text-foreground font-mono text-lg font-semibold tabular-nums',
intent === 'warning' && 'text-amber-600 dark:text-amber-400',
intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
)}
>
{props.value}
</span>
{props.hint && (
<span className='text-muted-foreground/70 text-[11px]'>
{props.hint}
</span>
)}
</div>
)
}
export function ModelDetailsPerformance(props: { model: PricingModel }) {
const { t } = useTranslation()
const performances = useMemo(
() => buildGroupPerformance(props.model),
[props.model]
)
const latencySeries = useMemo(
() => buildLatencyTimeSeries(props.model),
[props.model]
)
const uptimeSeries = useMemo(
() => buildUptimeSeries(props.model),
[props.model]
)
const aggregated = useMemo(
() => aggregateUptime(uptimeSeries),
[uptimeSeries]
)
const uptimeByGroup = useMemo<Record<string, UptimeDayPoint[]>>(() => {
const map: Record<string, UptimeDayPoint[]> = {}
for (const perf of performances) {
map[perf.group] = buildUptimeSeries(props.model, perf.group)
}
return map
}, [performances, props.model])
if (performances.length === 0) {
return (
<div className='text-muted-foreground rounded-lg border p-6 text-center text-sm'>
{t('Performance data is not yet available for this model.')}
</div>
)
}
const bestTtft = Math.min(...performances.map((p) => p.ttft_p50_ms))
const bestThroughput = Math.max(...performances.map((p) => p.throughput_tps))
const totalRequests = performances.reduce(
(s, p) => s + p.request_volume_24h,
0
)
const intent =
aggregated.uptime_pct >= 99.9
? 'success'
: aggregated.uptime_pct >= 99
? 'default'
: 'warning'
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return (
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-2 lg:grid-cols-4'>
<StatCard
icon={Timer}
label={t('Best TTFT')}
value={formatLatency(bestTtft)}
hint={t('Lowest median first-token latency')}
/>
<StatCard
icon={Gauge}
label={t('Peak throughput')}
value={formatThroughput(bestThroughput)}
hint={t('Across all groups')}
/>
<StatCard
icon={HeartPulse}
label={t('Uptime (30d)')}
value={formatUptimePct(aggregated.uptime_pct)}
hint={
aggregated.incidents > 0
? t('{{count}} incidents in the last 30 days', {
count: aggregated.incidents,
})
: t('No incidents in the last 30 days')
}
intent={intent}
/>
<StatCard
icon={TrendingUp}
label={t('Requests (24h)')}
value={COMPACT_NUMBER.format(totalRequests)}
hint={t('Aggregated across enabled groups')}
/>
</div>
<section>
<SectionHeader
icon={Activity}
title={t('Per-group performance')}
description={t(
'TTFT percentiles, throughput, and 30-day uptime by group'
)}
/>
<div className='overflow-x-auto rounded-lg border'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={headerCellClass}>{t('Group')}</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('TTFT P50')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('TTFT P95')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('TTFT P99')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Throughput')}
</TableHead>
<TableHead
className={`${headerCellClass} min-w-[160px] text-left`}
>
{t('Uptime (30d)')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Requests / 24h')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{performances.map((perf) => {
const isBestTtft = perf.ttft_p50_ms === bestTtft
const isBestTput = perf.throughput_tps === bestThroughput
return (
<TableRow key={perf.group}>
<TableCell className='py-2.5'>
<GroupBadge group={perf.group} size='sm' />
</TableCell>
<TableCell
className={cn(
'py-2.5 text-right font-mono',
isBestTtft && 'text-emerald-600 dark:text-emerald-400'
)}
>
{formatLatency(perf.ttft_p50_ms)}
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
{formatLatency(perf.ttft_p95_ms)}
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
{formatLatency(perf.ttft_p99_ms)}
</TableCell>
<TableCell
className={cn(
'py-2.5 text-right font-mono',
isBestTput &&
perf.throughput_tps > 0 &&
'text-emerald-600 dark:text-emerald-400'
)}
>
{formatThroughput(perf.throughput_tps)}
</TableCell>
<TableCell className='py-2.5'>
<UptimeSparkline
size='sm'
series={uptimeByGroup[perf.group] ?? []}
/>
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
{COMPACT_NUMBER.format(perf.request_volume_24h)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</section>
<section>
<SectionHeader
icon={Timer}
title={t('Latency trend (last 24h)')}
description={t(
'Median time-to-first-token (TTFT) sampled hourly per group'
)}
/>
<LatencyTrendChart series={latencySeries} />
</section>
{bestThroughput > 0 && (
<section>
<SectionHeader
icon={Gauge}
title={t('Throughput by group')}
description={t('Average tokens per second sustained per group')}
/>
<ThroughputBarChart rows={performances} />
</section>
)}
<section>
<SectionHeader
icon={HeartPulse}
title={t('Uptime (last 30 days)')}
description={
aggregated.incidents > 0
? t(
'Daily uptime; {{incidents}} incidents totalling {{minutes}} minutes',
{
incidents: aggregated.incidents,
minutes: aggregated.outage_minutes,
}
)
: t('Daily uptime over the last 30 days')
}
accent={
aggregated.incidents > 0 ? (
<span className='inline-flex items-center gap-1 text-amber-600 dark:text-amber-400'>
<AlertTriangle className='size-3.5' />
{t('{{count}} incidents', {
count: aggregated.incidents,
})}
</span>
) : null
}
/>
<UptimeBarChart series={uptimeSeries} />
</section>
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
{t(
'Performance metrics shown here are simulated for preview purposes and will be replaced with live observability data once the backend integration is complete.'
)}
</p>
</div>
)
}
function SectionHeader(props: {
icon: React.ComponentType<{ className?: string }>
title: string
description?: string
accent?: React.ReactNode
}) {
const Icon = props.icon
return (
<div className='mb-2 flex flex-wrap items-center justify-between gap-2'>
<div className='flex min-w-0 items-center gap-2'>
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
<div className='min-w-0'>
<div className='text-foreground text-sm font-semibold'>
{props.title}
</div>
{props.description && (
<p className='text-muted-foreground/80 text-xs'>
{props.description}
</p>
)}
</div>
</div>
{props.accent && (
<div className='shrink-0 text-xs font-medium'>{props.accent}</div>
)}
</div>
)
}
@@ -0,0 +1,129 @@
import {
CalendarClock,
FileText,
Layers,
Maximize2,
Sparkles,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
formatTokenCount,
formatYearMonth,
type ModelMetadata,
} from '../lib/model-metadata'
import type { Modality } from '../types'
import { ModalityIcons } from './model-details-modalities'
type QuickStatsProps = {
metadata: ModelMetadata
}
type Stat = {
key: string
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
hint?: string
}
function buildStats(
metadata: ModelMetadata,
t: (key: string) => string
): Stat[] {
const stats: Stat[] = [
{
key: 'context',
icon: Layers,
label: t('Context'),
value: formatTokenCount(metadata.context_length),
hint: t('Maximum input window'),
},
]
if (metadata.max_output_tokens > 0) {
stats.push({
key: 'max-output',
icon: Maximize2,
label: t('Max output'),
value: formatTokenCount(metadata.max_output_tokens),
hint: t('Maximum tokens per response'),
})
}
stats.push({
key: 'modalities',
icon: FileText,
label: t('Modalities'),
value: (
<ModalityFlow
input={metadata.input_modalities}
output={metadata.output_modalities}
/>
),
})
if (metadata.knowledge_cutoff) {
stats.push({
key: 'knowledge',
icon: Sparkles,
label: t('Knowledge cutoff'),
value: formatYearMonth(metadata.knowledge_cutoff),
})
}
if (metadata.release_date) {
stats.push({
key: 'release',
icon: CalendarClock,
label: t('Released'),
value: formatYearMonth(metadata.release_date),
})
}
return stats
}
function ModalityFlow(props: { input: Modality[]; output: Modality[] }) {
return (
<span className='inline-flex items-center gap-1 align-middle'>
<ModalityIcons modalities={props.input} className='size-3.5' />
<span className='text-muted-foreground/40'></span>
<ModalityIcons modalities={props.output} className='size-3.5' />
</span>
)
}
export function ModelDetailsQuickStats(props: QuickStatsProps) {
const { t } = useTranslation()
const stats = buildStats(props.metadata, t)
return (
<div className='bg-muted/20 grid grid-cols-2 gap-px overflow-hidden rounded-lg border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
{stats.map((stat) => {
const Icon = stat.icon
return (
<div
key={stat.key}
className={cn(
'bg-background flex min-w-0 flex-col gap-0.5 px-3 py-2.5'
)}
>
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1 text-[10px] font-medium tracking-wider uppercase'>
<Icon className='size-3 shrink-0' />
<span className='truncate'>{stat.label}</span>
</span>
<span className='text-foreground truncate text-sm font-semibold tabular-nums'>
{stat.value}
</span>
{stat.hint && (
<span className='text-muted-foreground/60 truncate text-[10px]'>
{stat.hint}
</span>
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,211 @@
import { useMemo } from 'react'
import { Activity, AlertCircle, CheckCircle2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
aggregateUptime,
formatUptimePct,
type UptimeDayPoint,
} from '../lib/mock-stats'
// ---------------------------------------------------------------------------
// Uptime sparkline
// ---------------------------------------------------------------------------
//
// Compact 30-day uptime visualisation: a row of small coloured bars where:
// - Bar colour reflects per-day uptime (green / amber / red)
// - Bar height reflects severity (the worse the day, the shorter the bar)
// - Hovering a bar reveals the exact date and uptime
//
// Useful as a header strip ("at-a-glance" status) and as a per-row visual
// inside the per-group performance table.
type SparklineSize = 'sm' | 'md'
type UptimeSparklineProps = {
series: UptimeDayPoint[]
size?: SparklineSize
showOverall?: boolean
emptyLabel?: string
className?: string
}
function colourFor(uptime: number): string {
if (uptime >= 99.9) return 'bg-emerald-500'
if (uptime >= 99.0) return 'bg-emerald-400'
if (uptime >= 95.0) return 'bg-amber-500'
if (uptime >= 90.0) return 'bg-amber-600'
return 'bg-rose-500'
}
function heightFor(uptime: number): string {
if (uptime >= 99.9) return 'h-full'
if (uptime >= 99.0) return 'h-[88%]'
if (uptime >= 95.0) return 'h-[72%]'
if (uptime >= 90.0) return 'h-[55%]'
return 'h-[40%]'
}
function overallTextColour(pct: number): string {
if (pct >= 99.9) return 'text-emerald-600 dark:text-emerald-400'
if (pct >= 99.0) return 'text-emerald-600 dark:text-emerald-400'
if (pct >= 95.0) return 'text-amber-600 dark:text-amber-400'
return 'text-rose-600 dark:text-rose-400'
}
export function UptimeSparkline(props: UptimeSparklineProps) {
const size = props.size ?? 'md'
const showOverall = props.showOverall ?? true
if (props.series.length === 0) {
return (
<span className={cn('text-muted-foreground text-xs', props.className)}>
{props.emptyLabel ?? '—'}
</span>
)
}
const overall =
props.series.reduce((s, p) => s + p.uptime_pct, 0) / props.series.length
const containerHeight = size === 'sm' ? 'h-3.5' : 'h-5'
const barWidth = size === 'sm' ? 'w-[3px]' : 'w-1'
const gap = size === 'sm' ? 'gap-px' : 'gap-[2px]'
return (
<div className={cn('flex items-center gap-2', props.className)}>
<div
className={cn('flex items-end', containerHeight, gap)}
role='img'
aria-label={`30 day uptime ${overall.toFixed(2)}%`}
>
{props.series.map((day) => (
<Tooltip key={day.date}>
<TooltipTrigger
render={
<div
className={cn(
'rounded-[1px] transition-opacity hover:opacity-80',
barWidth,
containerHeight,
'flex items-end'
)}
/>
}
>
<div
className={cn(
'w-full rounded-[1px]',
colourFor(day.uptime_pct),
heightFor(day.uptime_pct)
)}
aria-hidden
/>
</TooltipTrigger>
<TooltipContent side='top' className='font-mono text-xs'>
<div className='font-medium'>{day.date}</div>
<div>{day.uptime_pct.toFixed(2)}%</div>
{day.outage_minutes > 0 && (
<div className='text-muted-foreground'>
{day.outage_minutes} min outage
</div>
)}
</TooltipContent>
</Tooltip>
))}
</div>
{showOverall && (
<span
className={cn(
'font-mono text-sm font-semibold tabular-nums',
overallTextColour(overall)
)}
>
{overall.toFixed(1)}%
</span>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Uptime status row — sparkline + summary text + status icon
// ---------------------------------------------------------------------------
export function UptimeStatusRow(props: {
series: UptimeDayPoint[]
className?: string
}) {
const { t } = useTranslation()
const summary = useMemo(() => aggregateUptime(props.series), [props.series])
const status = useMemo(() => {
if (summary.uptime_pct >= 99.9) return 'operational'
if (summary.uptime_pct >= 99.0) return 'minor'
if (summary.uptime_pct >= 95.0) return 'degraded'
return 'major'
}, [summary.uptime_pct])
const StatusIcon =
status === 'operational'
? CheckCircle2
: status === 'minor'
? Activity
: AlertCircle
const statusColour =
status === 'operational'
? 'text-emerald-600 dark:text-emerald-400'
: status === 'minor'
? 'text-emerald-600 dark:text-emerald-400'
: status === 'degraded'
? 'text-amber-600 dark:text-amber-400'
: 'text-rose-600 dark:text-rose-400'
const statusLabel =
status === 'operational'
? t('All systems operational')
: status === 'minor'
? t('Minor blips in the last 30 days')
: status === 'degraded'
? t('Degraded performance recently')
: t('Significant outages detected')
return (
<div
className={cn(
'border-border/60 bg-muted/30 flex flex-wrap items-center gap-3 rounded-lg border px-3 py-2 sm:gap-4 sm:px-4',
props.className
)}
>
<div className='flex items-center gap-2'>
<StatusIcon className={cn('size-4 shrink-0', statusColour)} />
<span className='text-sm font-medium'>{t('Last 30 days uptime')}</span>
</div>
<UptimeSparkline series={props.series} className='ml-auto' />
<div className='flex items-center gap-3 text-xs'>
<span className={cn('font-medium', statusColour)}>{statusLabel}</span>
{summary.incidents > 0 && (
<span className='text-muted-foreground'>
{summary.incidents}{' '}
{summary.incidents === 1 ? t('incident') : t('incidents')}
</span>
)}
{summary.outage_minutes > 0 && (
<span className='text-muted-foreground'>
{summary.outage_minutes} {t('min downtime')}
</span>
)}
<span className='text-muted-foreground hidden sm:inline'>
{formatUptimePct(summary.uptime_pct)} {t('overall')}
</span>
</div>
</div>
)
}
+296 -170
View File
@@ -1,9 +1,24 @@
import { useMemo } from 'react'
import { useParams, useNavigate, useSearch } from '@tanstack/react-router'
import { ArrowLeft } from 'lucide-react'
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import {
ArrowLeft,
Boxes,
Code2,
HeartPulse,
Info,
ReceiptText,
Rocket,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
@@ -13,33 +28,40 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button'
import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data'
import { parseTags } from '../lib/filters'
import {
getAvailableGroups,
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 { parseTags } from '../lib/filters'
import { buildUptimeSeries } from '../lib/mock-stats'
import {
getAvailableGroups,
isTokenBasedModel,
replaceModelInPath,
} from '../lib/model-helpers'
import { inferModelMetadata } from '../lib/model-metadata'
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
import type { PriceType, PricingModel, TokenUnit } from '../types'
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
import { ModelDetailsApps } from './model-details-apps'
import { ModelDetailsCapabilities } from './model-details-capabilities'
import { ModalitiesMatrix } from './model-details-modalities'
import { ModelDetailsPerformance } from './model-details-performance'
import { ModelDetailsQuickStats } from './model-details-quick-stats'
import { UptimeStatusRow } from './model-details-uptime-sparkline'
// ----------------------------------------------------------------------------
// Local UI helpers
// ----------------------------------------------------------------------------
function SectionTitle(props: { children: React.ReactNode }) {
return (
@@ -49,6 +71,10 @@ function SectionTitle(props: { children: React.ReactNode }) {
)
}
// ----------------------------------------------------------------------------
// Model header (always visible above the tabs)
// ----------------------------------------------------------------------------
function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation()
const model = props.model
@@ -63,7 +89,7 @@ function ModelHeader(props: { model: PricingModel }) {
getDynamicPricingTiers(model).length === 0
return (
<header className='pb-5'>
<header className='pb-4'>
<div className='flex items-center gap-2.5'>
{vendorIcon}
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
@@ -78,12 +104,12 @@ function ModelHeader(props: { model: PricingModel }) {
aria-label={t('Copy model name')}
/>
</div>
<div className='mt-1 flex items-center gap-1.5 text-xs'>
<div className='mt-1 flex flex-wrap items-center gap-1.5 text-xs'>
{model.vendor_name && (
<span className='text-muted-foreground'>{model.vendor_name}</span>
)}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/50'>
<span className='text-muted-foreground/70'>
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
? t('Token-based')
: t('Per Request')}
@@ -120,6 +146,10 @@ function ModelHeader(props: { model: PricingModel }) {
)
}
// ----------------------------------------------------------------------------
// Base price card (used in the Overview tab)
// ----------------------------------------------------------------------------
function PriceSection(props: {
model: PricingModel
priceRate: number
@@ -128,22 +158,15 @@ function PriceSection(props: {
showRechargePrice: boolean
}) {
const { t } = useTranslation()
const {
model,
priceRate,
usdExchangeRate,
tokenUnit,
showRechargePrice,
} = props
const isTokenBased = isTokenBasedModel(model)
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
const isTokenBased = isTokenBasedModel(props.model)
const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
const baseGroupKey = '_base'
const baseGroupRatioMap = { [baseGroupKey]: 1 }
const dynamicSummary = getDynamicPricingSummary(model, {
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
const dynamicSummary = getDynamicPricingSummary(props.model, {
tokenUnit: props.tokenUnit,
showRechargePrice: props.showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: 1,
})
@@ -159,38 +182,39 @@ function PriceSection(props: {
{
label: t('Cached input'),
type: 'cache',
available: model.cache_ratio != null,
available: props.model.cache_ratio != null,
},
{
label: t('Cache write'),
type: 'create_cache',
available: model.create_cache_ratio != null,
available: props.model.create_cache_ratio != null,
},
{
label: t('Image input'),
type: 'image',
available: model.image_ratio != null,
available: props.model.image_ratio != null,
},
{
label: t('Audio input'),
type: 'audio_input',
available: model.audio_ratio != null,
available: props.model.audio_ratio != null,
},
{
label: t('Audio output'),
type: 'audio_output',
available:
model.audio_ratio != null && model.audio_completion_ratio != null,
props.model.audio_ratio != null &&
props.model.audio_completion_ratio != null,
},
]
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
return (
<section className='border-b py-4'>
<section>
<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'>
<div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
{t('Special billing expression')}
</div>
<p className='text-muted-foreground mt-1 text-xs'>
@@ -200,7 +224,7 @@ function PriceSection(props: {
<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'>
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
{dynamicSummary.rawExpression}
</code>
</div>
@@ -210,12 +234,15 @@ function PriceSection(props: {
}
return (
<section className='border-b py-4'>
<section>
<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
key={entry.key}
className='bg-muted/20 rounded-lg border p-3'
>
<div className='text-muted-foreground text-xs'>
{t(entry.shortLabel)}
</div>
@@ -261,7 +288,7 @@ function PriceSection(props: {
if (!isTokenBased) {
return (
<section className='border-b py-4'>
<section>
<SectionTitle>{t('Base Price')}</SectionTitle>
<div className='flex items-baseline justify-between'>
<span className='text-muted-foreground text-sm'>
@@ -269,11 +296,11 @@ function PriceSection(props: {
</span>
<span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
{formatFixedPrice(
model,
props.model,
baseGroupKey,
showRechargePrice,
priceRate,
usdExchangeRate,
props.showRechargePrice,
props.priceRate,
props.usdExchangeRate,
baseGroupRatioMap
)}
</span>
@@ -286,13 +313,13 @@ function PriceSection(props: {
const renderPrice = (type: PriceType) => (
<>
{formatGroupPrice(
model,
props.model,
baseGroupKey,
type,
tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
props.tokenUnit,
props.showRechargePrice,
props.priceRate,
props.usdExchangeRate,
baseGroupRatioMap
)}
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
@@ -302,11 +329,11 @@ function PriceSection(props: {
)
return (
<section className='border-b py-4'>
<section>
<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 key={item.type} className='bg-muted/20 rounded-lg border 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)}
@@ -337,29 +364,31 @@ function PriceSection(props: {
)
}
// ----------------------------------------------------------------------------
// API endpoints list
// ----------------------------------------------------------------------------
function EndpointsSection(props: {
model: PricingModel
endpointMap: Record<string, { path?: string; method?: string }>
}) {
const { t } = useTranslation()
const { model, endpointMap } = props
const endpoints = useMemo(() => {
const types = model.supported_endpoint_types || []
const types = props.model.supported_endpoint_types || []
return types.map((type) => {
const info = endpointMap[type] || {}
const info = props.endpointMap[type] || {}
let path = info.path || ''
if (path.includes('{model}')) {
path = replaceModelInPath(path, model.model_name || '')
path = replaceModelInPath(path, props.model.model_name || '')
}
return { type, path, method: info.method || 'POST' }
})
}, [model, endpointMap])
}, [props.model, props.endpointMap])
if (endpoints.length === 0) return null
return (
<section className='border-b py-4'>
<section>
<SectionTitle>{t('API Endpoints')}</SectionTitle>
<div className='space-y-1'>
{endpoints.map(({ type, path, method }) => (
@@ -384,6 +413,10 @@ function EndpointsSection(props: {
)
}
// ----------------------------------------------------------------------------
// Auto group chain (used inside group pricing section)
// ----------------------------------------------------------------------------
function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
const { t } = useTranslation()
const modelEnableGroups = Array.isArray(props.model.enable_groups)
@@ -411,6 +444,10 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
)
}
// ----------------------------------------------------------------------------
// Group pricing table
// ----------------------------------------------------------------------------
function GroupPricingSection(props: {
model: PricingModel
groupRatio: Record<string, number>
@@ -422,45 +459,39 @@ function GroupPricingSection(props: {
showRechargePrice?: boolean
}) {
const { t } = useTranslation()
const {
model,
groupRatio,
usableGroup,
autoGroups,
priceRate,
usdExchangeRate,
tokenUnit,
showRechargePrice = false,
} = props
const showRechargePrice = props.showRechargePrice ?? false
const availableGroups = useMemo(
() => getAvailableGroups(model, usableGroup || {}),
[model, usableGroup]
() => getAvailableGroups(props.model, props.usableGroup || {}),
[props.model, props.usableGroup]
)
const isTokenBased = isTokenBasedModel(model)
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
const isTokenBased = isTokenBasedModel(props.model)
const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
const extraPriceTypes = useMemo(() => {
const types: { label: string; type: PriceType }[] = []
if (model.cache_ratio != null)
if (props.model.cache_ratio != null)
types.push({ label: t('Cache'), type: 'cache' })
if (model.create_cache_ratio != null)
if (props.model.create_cache_ratio != null)
types.push({ label: t('Cache Write'), type: 'create_cache' })
if (model.image_ratio != null)
if (props.model.image_ratio != null)
types.push({ label: t('Image'), type: 'image' })
if (model.audio_ratio != null)
if (props.model.audio_ratio != null)
types.push({ label: t('Audio In'), type: 'audio_input' })
if (model.audio_ratio != null && model.audio_completion_ratio != null)
if (
props.model.audio_ratio != null &&
props.model.audio_completion_ratio != null
)
types.push({ label: t('Audio Out'), type: 'audio_output' })
return types
}, [model, t])
}, [props.model, t])
if (availableGroups.length === 0) {
return (
<section className='py-4'>
<section>
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={model} autoGroups={autoGroups} />
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
<p className='text-muted-foreground text-sm'>
{t(
'This model is not available in any group, or no group pricing information is configured.'
@@ -473,16 +504,16 @@ 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 (isDynamicPricingModel(props.model)) {
const dynamicTiers = getDynamicPricingTiers(props.model)
if (dynamicTiers.length === 0) {
return (
<section className='py-4'>
<section>
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={model} autoGroups={autoGroups} />
<AutoGroupChain model={props.model} autoGroups={props.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'>
<div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
{t('Special billing expression')}
</div>
<p className='text-muted-foreground mt-1 text-xs'>
@@ -494,8 +525,8 @@ function GroupPricingSection(props: {
<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 className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
{props.model.billing_expr}
</code>
</div>
</div>
@@ -508,10 +539,10 @@ function GroupPricingSection(props: {
dynamicTiers
.flatMap((tier) =>
getDynamicPriceEntries(tier, {
tokenUnit,
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: 1,
})
)
@@ -520,12 +551,12 @@ function GroupPricingSection(props: {
)
return (
<section className='py-4'>
<section>
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={model} autoGroups={autoGroups} />
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
<div className='space-y-3'>
{availableGroups.map((group) => {
const ratio = groupRatio[group] || 1
const ratio = props.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'>
@@ -552,10 +583,10 @@ function GroupPricingSection(props: {
<TableBody>
{dynamicTiers.map((tier, tierIndex) => {
const entries = getDynamicPriceEntries(tier, {
tokenUnit,
tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
})
const entryMap = new Map(
@@ -587,7 +618,7 @@ function GroupPricingSection(props: {
</div>
)
})}
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
<p className='text-muted-foreground/40 mt-1.5 text-[10px]'>
{t('Prices shown per')} {tokenUnitLabel} tokens
</p>
</div>
@@ -596,10 +627,10 @@ function GroupPricingSection(props: {
}
return (
<section className='py-4'>
<section>
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={model} autoGroups={autoGroups} />
<div className='-mx-4 sm:mx-0'>
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
<div className='-mx-4 overflow-x-auto sm:mx-0'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
@@ -631,7 +662,7 @@ function GroupPricingSection(props: {
</TableHeader>
<TableBody>
{availableGroups.map((group) => {
const ratio = groupRatio[group] || 1
const ratio = props.groupRatio[group] || 1
return (
<TableRow key={group}>
<TableCell className='py-2.5'>
@@ -644,26 +675,26 @@ function GroupPricingSection(props: {
<>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
model,
props.model,
group,
'input',
tokenUnit,
props.tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatGroupPrice(
model,
props.model,
group,
'output',
tokenUnit,
props.tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
{extraPriceTypes.map((ep) => (
@@ -672,14 +703,14 @@ function GroupPricingSection(props: {
className='py-2.5 text-right font-mono'
>
{formatGroupPrice(
model,
props.model,
group,
ep.type,
tokenUnit,
props.tokenUnit,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
))}
@@ -687,12 +718,12 @@ function GroupPricingSection(props: {
) : (
<TableCell className='py-2.5 text-right font-mono'>
{formatFixedPrice(
model,
props.model,
group,
showRechargePrice,
priceRate,
usdExchangeRate,
groupRatio
props.priceRate,
props.usdExchangeRate,
props.groupRatio
)}
</TableCell>
)}
@@ -711,6 +742,30 @@ function GroupPricingSection(props: {
)
}
// ----------------------------------------------------------------------------
// Tabbed details content
// ----------------------------------------------------------------------------
const TAB_VALUES = [
'overview',
'pricing',
'performance',
'api',
'apps',
] as const
type TabValue = (typeof TAB_VALUES)[number]
const TAB_META: Record<
TabValue,
{ icon: React.ComponentType<{ className?: string }>; labelKey: string }
> = {
overview: { icon: Info, labelKey: 'Overview' },
pricing: { icon: ReceiptText, labelKey: 'Pricing' },
performance: { icon: HeartPulse, labelKey: 'Performance' },
api: { icon: Code2, labelKey: 'API' },
apps: { icon: Rocket, labelKey: 'Apps' },
}
export interface ModelDetailsContentProps {
model: PricingModel
groupRatio: Record<string, number>
@@ -724,52 +779,121 @@ export interface ModelDetailsContentProps {
}
export function ModelDetailsContent(props: ModelDetailsContentProps) {
const {
model,
groupRatio,
usableGroup,
endpointMap,
autoGroups,
priceRate,
usdExchangeRate,
tokenUnit,
showRechargePrice = false,
} = props
const { t } = useTranslation()
const showRechargePrice = props.showRechargePrice ?? false
const metadata = useMemo(() => inferModelMetadata(props.model), [props.model])
const uptimeSeries = useMemo(
() => buildUptimeSeries(props.model),
[props.model]
)
const isDynamic =
props.model.billing_mode === 'tiered_expr' &&
Boolean(props.model.billing_expr)
return (
<>
<ModelHeader model={model} />
<div className='@container/details space-y-4'>
<ModelHeader model={props.model} />
<PriceSection
model={model}
priceRate={priceRate}
usdExchangeRate={usdExchangeRate}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
/>
<ModelDetailsQuickStats metadata={metadata} />
<EndpointsSection model={model} endpointMap={endpointMap} />
<UptimeStatusRow series={uptimeSeries} />
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
<div className='border-b'>
<DynamicPricingBreakdown billingExpr={model.billing_expr} />
</div>
)}
<Tabs defaultValue='overview' className='gap-4'>
<TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
{TAB_VALUES.map((value) => {
const Icon = TAB_META[value].icon
return (
<TabsTrigger
key={value}
value={value}
className='h-8 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
>
<Icon className='size-3.5' />
<span>{t(TAB_META[value].labelKey)}</span>
</TabsTrigger>
)
})}
</TabsList>
<GroupPricingSection
model={model}
groupRatio={groupRatio}
usableGroup={usableGroup}
autoGroups={autoGroups}
priceRate={priceRate}
usdExchangeRate={usdExchangeRate}
tokenUnit={tokenUnit}
showRechargePrice={showRechargePrice}
/>
</>
<TabsContent value='overview' className='space-y-5 outline-none'>
<section>
<div className='mb-3 flex items-center gap-2'>
<Boxes className='text-muted-foreground/70 size-3.5' />
<h3 className='text-foreground text-sm font-semibold'>
{t('Capabilities')}
</h3>
</div>
<ModelDetailsCapabilities capabilities={metadata.capabilities} />
</section>
<section>
<div className='mb-3 flex items-center gap-2'>
<h3 className='text-foreground text-sm font-semibold'>
{t('Supported modalities')}
</h3>
</div>
<ModalitiesMatrix
input={metadata.input_modalities}
output={metadata.output_modalities}
/>
</section>
<ModelDetailsProviderInfo model={props.model} />
<PriceSection
model={props.model}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
<EndpointsSection
model={props.model}
endpointMap={props.endpointMap}
/>
</TabsContent>
<TabsContent value='pricing' className='space-y-5 outline-none'>
{isDynamic && (
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
)}
<GroupPricingSection
model={props.model}
groupRatio={props.groupRatio}
usableGroup={props.usableGroup}
autoGroups={props.autoGroups}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
</TabsContent>
<TabsContent value='performance' className='outline-none'>
<ModelDetailsPerformance model={props.model} />
</TabsContent>
<TabsContent value='api' className='outline-none'>
<ModelDetailsApi
model={props.model}
endpointMap={props.endpointMap}
/>
</TabsContent>
<TabsContent value='apps' className='outline-none'>
<ModelDetailsApps model={props.model} />
</TabsContent>
</Tabs>
</div>
)
}
// ----------------------------------------------------------------------------
// Drawer & page wrappers
// ----------------------------------------------------------------------------
export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -783,7 +907,7 @@ export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side='right'
className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl'
>
<SheetHeader className='sr-only'>
<SheetTitle>{props.model.model_name}</SheetTitle>
@@ -829,19 +953,21 @@ export function ModelDetails() {
if (isLoading) {
return (
<PublicLayout>
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
<Skeleton className='mb-4 h-5 w-16' />
<div className='space-y-2'>
<Skeleton className='h-7 w-64' />
<Skeleton className='h-4 w-40' />
<Skeleton className='h-4 w-full max-w-md' />
</div>
<div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' />
))}
</div>
<div className='mt-6 space-y-3'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='flex justify-between'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-5 w-20' />
</div>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-24 w-full' />
))}
</div>
</div>
@@ -869,7 +995,7 @@ export function ModelDetails() {
return (
<PublicLayout>
<div className='mx-auto max-w-2xl px-4 sm:px-6'>
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
<Button
variant='ghost'
size='sm'
@@ -10,12 +10,12 @@ import {
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 {
getDynamicDisplayGroupRatio,
getDynamicPricingSummary,
} from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import {
formatPrice,
formatRequestPrice,
@@ -152,14 +152,14 @@ export function usePricingColumns(
if (dynamicSummary) {
if (dynamicSummary.isSpecialExpression) {
return (
<div className='min-w-[200px] max-w-[320px]'>
<div className='text-amber-700 text-xs font-medium dark:text-amber-300'>
<div className='max-w-[320px] min-w-[200px]'>
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
{t('Special billing expression')}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('Unable to parse structured pricing')}
</div>
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block break-all font-mono text-[10px] leading-relaxed'>
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-[10px] leading-relaxed break-all'>
{dynamicSummary.rawExpression}
</code>
</div>
@@ -371,8 +371,8 @@ export function usePricingColumns(
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedTags(tags, 2)}</div>
<TooltipTrigger render={<div />}>
{renderLimitedTags(tags, 2)}
</TooltipTrigger>
{tags.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
@@ -401,8 +401,8 @@ export function usePricingColumns(
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedTags(endpoints, 2)}</div>
<TooltipTrigger render={<div />}>
{renderLimitedTags(endpoints, 2)}
</TooltipTrigger>
{endpoints.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
@@ -431,8 +431,8 @@ export function usePricingColumns(
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>{renderLimitedGroupBadges(groups, 2)}</div>
<TooltipTrigger render={<div />}>
{renderLimitedGroupBadges(groups, 2)}
</TooltipTrigger>
{groups.length > 2 && (
<TooltipContent side='top' className='max-w-[280px] p-2'>
@@ -88,7 +88,9 @@ function FilterChip(props: {
)}
title={props.option.label}
>
{props.option.icon && <span className='shrink-0'>{props.option.icon}</span>}
{props.option.icon && (
<span className='shrink-0'>{props.option.icon}</span>
)}
<span className='truncate'>{props.option.label}</span>
{(props.option.suffix || props.option.count != null) && (
<span
@@ -108,12 +110,15 @@ function FilterChip(props: {
function FilterSection(props: FilterSectionProps) {
return (
<Collapsible defaultOpen className='border-border/70 border-b pb-3 last:border-b-0'>
<Collapsible
defaultOpen
className='border-border/70 border-b pb-3 last:border-b-0'
>
<CollapsibleTrigger className='group flex w-full items-center justify-between py-2.5 text-left'>
<span className='text-foreground text-sm font-semibold'>
{props.title}
</span>
<ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-180' />
<ChevronDown className='text-muted-foreground size-4 transition-transform group-data-[panel-open]:rotate-180' />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='flex flex-wrap gap-1.5'>
@@ -213,19 +218,15 @@ export function PricingSidebar(props: PricingSidebarProps) {
.map(([value, label]) => ({
value,
label,
count: countBy(props.models, (model) =>
model.supported_endpoint_types?.includes(value) ?? false
count: countBy(
props.models,
(model) => model.supported_endpoint_types?.includes(value) ?? false
),
})),
]
return (
<aside
className={cn(
'rounded-xl border p-3',
props.className
)}
>
<aside className={cn('rounded-xl border p-3', props.className)}>
<div className='mb-2.5 flex items-center justify-between gap-2'>
<div>
<h2 className='text-foreground text-sm font-bold'>{t('Filter')}</h2>
@@ -1,11 +1,5 @@
import { useCallback, useState } from 'react'
import {
ArrowUpDown,
Check,
Filter,
Grid2X2,
Table2,
} from 'lucide-react'
import { ArrowUpDown, Check, Filter, Grid2X2, Table2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
@@ -85,7 +79,7 @@ function SegmentedControl(props: {
<div
role='group'
aria-label={props.ariaLabel}
className='bg-muted/60 inline-flex h-8 items-center rounded-md border p-0.5'
className='bg-muted/60 inline-flex h-8 items-center rounded-lg border p-0.5'
>
{props.options.map((option) => {
const Icon = option.icon
@@ -97,10 +91,10 @@ function SegmentedControl(props: {
onClick={() => props.onChange(option.value)}
aria-pressed={isActive}
className={cn(
'inline-flex h-full items-center justify-center rounded-[5px] text-xs font-medium transition-all',
'inline-flex h-full items-center justify-center rounded-md text-xs font-medium transition-all',
Icon && !option.label ? 'w-7' : 'gap-1.5 px-3',
isActive
? 'bg-foreground text-background shadow-sm'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
@@ -115,7 +109,7 @@ function SegmentedControl(props: {
return (
<Tooltip key={option.value}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipTrigger render={button}></TooltipTrigger>
<TooltipContent side='bottom' className='text-xs'>
{option.tooltip}
</TooltipContent>
@@ -170,9 +164,7 @@ export function PricingToolbar(props: PricingToolbarProps) {
<span className='text-foreground font-semibold tabular-nums'>
{props.filteredCount.toLocaleString()}
</span>
<span>
{props.filteredCount === 1 ? t('model') : t('models')}
</span>
<span>{props.filteredCount === 1 ? t('model') : t('models')}</span>
{props.hasActiveFilters && props.totalCount && (
<span className='text-muted-foreground/60 text-xs'>
/ {props.totalCount.toLocaleString()}
@@ -204,18 +196,18 @@ export function PricingToolbar(props: PricingToolbarProps) {
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type='button'
variant='outline'
size='sm'
className='h-8 gap-1.5 px-3 text-xs'
>
<ArrowUpDown className='size-3.5' />
<span>
{sortLabels[props.sortBy as SortOption] || t('Sort')}
</span>
</Button>
<DropdownMenuTrigger
render={
<Button
type='button'
variant='outline'
size='sm'
className='h-8 gap-1.5 px-3 text-xs'
/>
}
>
<ArrowUpDown className='size-3.5' />
<span>{sortLabels[props.sortBy as SortOption] || t('Sort')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-44'>
{Object.entries(sortLabels).map(([value, label]) => (
+10 -13
View File
@@ -59,20 +59,17 @@ export function useFilters(models: PricingModel[]) {
const viewMode = normalizeViewMode(filterState.view)
const showRechargePrice = filterState.rechargePrice === true
const updateFilters = useCallback(
(updates: Record<string, unknown>) => {
setFilterState((prev) => {
const next: Record<string, unknown> = { ...prev, ...updates }
for (const key of Object.keys(next)) {
if (next[key] === undefined || next[key] === null) {
delete next[key]
}
const updateFilters = useCallback((updates: Record<string, unknown>) => {
setFilterState((prev) => {
const next: Record<string, unknown> = { ...prev, ...updates }
for (const key of Object.keys(next)) {
if (next[key] === undefined || next[key] === null) {
delete next[key]
}
return next as FilterState
})
},
[]
)
}
return next as FilterState
})
}, [])
const setSearchInput = useCallback(
(v: string) => updateFilters({ search: v || undefined }),
+18 -14
View File
@@ -18,7 +18,9 @@ import { usePricingData } from './hooks/use-pricing-data'
export function Pricing() {
const { t } = useTranslation()
const [selectedModelName, setSelectedModelName] = useState<string | null>(null)
const [selectedModelName, setSelectedModelName] = useState<string | null>(
null
)
const {
models,
@@ -61,18 +63,16 @@ export function Pricing() {
clearSearch,
} = useFilters(models || [])
const handleModelClick = useCallback(
(modelName: string) => {
setSelectedModelName(modelName)
},
[]
)
const handleModelClick = useCallback((modelName: string) => {
setSelectedModelName(modelName)
}, [])
const selectedModel = useMemo(
() =>
selectedModelName
? (models || []).find((model) => model.model_name === selectedModelName) ||
null
? (models || []).find(
(model) => model.model_name === selectedModelName
) || null
: null,
[models, selectedModelName]
)
@@ -148,8 +148,10 @@ export function Pricing() {
'radial-gradient(ellipse 50% 40% at 80% 15%, oklch(0.65 0.15 200 / 60%) 0%, transparent 70%)',
'radial-gradient(ellipse 40% 35% at 50% 70%, oklch(0.70 0.12 280 / 40%) 0%, transparent 70%)',
].join(', '),
maskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
maskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
WebkitMaskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
}}
/>
<PageTransition className='relative mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
@@ -174,12 +176,14 @@ export function Pricing() {
value={searchInput}
onChange={setSearchInput}
onClear={clearSearch}
placeholder={t('Search model name, provider, endpoint, or tag...')}
placeholder={t(
'Search model name, provider, endpoint, or tag...'
)}
className='mx-auto mt-4 max-w-2xl sm:mt-6'
/>
</header>
<div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)] 2xl:grid-cols-[330px_minmax(0,1fr)]'>
<div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)]'>
<PricingSidebar
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
@@ -198,7 +202,7 @@ export function Pricing() {
models={models || []}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
className='sticky top-20 hidden max-h-[calc(100vh-6rem)] overflow-y-auto xl:block'
className='hover-scrollbar sticky top-4 hidden max-h-[calc(100dvh-2rem)] self-start overflow-y-auto xl:block'
/>
<main className='min-w-0 space-y-4'>
+4 -2
View File
@@ -1,5 +1,6 @@
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { TOKEN_UNIT_DIVISORS } from '../constants'
import type { PricingModel, TokenUnit } from '../types'
import {
BILLING_PRICING_VARS,
parseTiersFromExpr,
@@ -8,7 +9,6 @@ import {
type BillingVar,
type ParsedTier,
} from './billing-expr'
import type { PricingModel, TokenUnit } from '../types'
type DynamicPriceOptions = {
tokenUnit: TokenUnit
@@ -98,7 +98,9 @@ export function formatDynamicUnitPrice(
export function getDynamicPricingTiers(model: PricingModel): ParsedTier[] {
if (!isDynamicPricingModel(model)) return []
const { billingExpr } = splitBillingExprAndRequestRules(model.billing_expr || '')
const { billingExpr } = splitBillingExprAndRequestRules(
model.billing_expr || ''
)
return parseTiersFromExpr(billingExpr)
}
+3
View File
@@ -7,3 +7,6 @@ export * from './price'
export * from './model-helpers'
export * from './billing-expr'
export * from './tier-expr'
export * from './model-metadata'
export * from './mock-stats'
export * from './seed'
+844
View File
@@ -0,0 +1,844 @@
import type { PricingModel } from '../types'
import {
hashStringToSeed,
randomInRange,
randomIntInRange,
seededRandom,
} from './seed'
// ----------------------------------------------------------------------------
// Mock model statistics
// ----------------------------------------------------------------------------
//
// The backend has not yet implemented latency / uptime / app-ranking data.
// These helpers generate plausible, deterministic mock values seeded from
// the model name (and optionally the group name) so that:
// - Every render of the same model shows the same numbers
// - Different models / different groups render visibly distinct values
//
// When the backend ships real metrics, callers should switch to the
// real API and these helpers can be deleted. The shape of the returned
// data is designed to mirror what we expect the real endpoints to return.
export type GroupPerformance = {
group: string
ttft_p50_ms: number
ttft_p95_ms: number
ttft_p99_ms: number
throughput_tps: number
uptime_30d_pct: number
/** Number of monitored requests in the last 24h (display only). */
request_volume_24h: number
}
export type LatencyTimePoint = {
timestamp: string
group: string
ttft_ms: number
}
export type UptimeDayPoint = {
date: string
uptime_pct: number
incidents: number
outage_minutes: number
}
export type AppRanking = {
rank: number
name: string
description: string
category: string
growth_pct: number
monthly_tokens: number
url?: string
initial: string
}
const APP_TEMPLATES: Array<
Omit<AppRanking, 'rank' | 'monthly_tokens' | 'growth_pct' | 'initial'>
> = [
{
name: 'Cline',
description: 'Autonomous coding agent inside the IDE',
category: 'Coding',
url: 'https://cline.bot',
},
{
name: 'Roo Code',
description: 'AI agent for VS Code with multi-step planning',
category: 'Coding',
url: 'https://roocode.com',
},
{
name: 'Open WebUI',
description: 'Self-hosted ChatGPT-like web interface',
category: 'Chat',
url: 'https://openwebui.com',
},
{
name: 'LibreChat',
description: 'Open-source chat platform with multi-model support',
category: 'Chat',
url: 'https://librechat.ai',
},
{
name: 'Lobe Chat',
description: 'Modern open-source chat UI with plugins',
category: 'Chat',
url: 'https://lobehub.com',
},
{
name: 'NextChat',
description: 'Cross-platform private ChatGPT client',
category: 'Chat',
url: 'https://nextchat.dev',
},
{
name: 'Continue',
description: 'Open-source AI code assistant for editors',
category: 'Coding',
url: 'https://continue.dev',
},
{
name: 'Aider',
description: 'Pair-programming agent in your terminal',
category: 'Coding',
url: 'https://aider.chat',
},
{
name: 'Dify',
description: 'LLM application development platform',
category: 'Platform',
url: 'https://dify.ai',
},
{
name: 'FastGPT',
description: 'Knowledge base orchestration and chat platform',
category: 'Platform',
url: 'https://fastgpt.in',
},
{
name: 'Flowise',
description: 'Low-code LLM workflow builder',
category: 'Platform',
url: 'https://flowiseai.com',
},
{
name: 'OpenInterpreter',
description: 'Natural-language code execution agent',
category: 'Coding',
url: 'https://openinterpreter.com',
},
{
name: 'Devika',
description: 'Open-source AI software engineer',
category: 'Coding',
url: 'https://github.com/stitionai/devika',
},
{
name: 'Cherry Studio',
description: 'Multi-model desktop chat client',
category: 'Chat',
url: 'https://cherry-ai.com',
},
{
name: 'AnythingLLM',
description: 'Workspaces around your private documents',
category: 'Platform',
url: 'https://anythingllm.com',
},
{
name: 'OpenHands',
description: 'Coding agent with browser-and-code tools',
category: 'Coding',
url: 'https://docs.all-hands.dev',
},
{
name: 'Cursor',
description: 'AI-native code editor',
category: 'Coding',
url: 'https://cursor.com',
},
{
name: 'Zed',
description: 'Multiplayer code editor with AI',
category: 'Coding',
url: 'https://zed.dev',
},
{
name: 'Notion AI',
description: 'Documents and writing assistant',
category: 'Productivity',
url: 'https://notion.so',
},
{
name: 'Raycast AI',
description: 'AI on your macOS launcher',
category: 'Productivity',
url: 'https://raycast.com',
},
{
name: 'Obsidian Smart Connections',
description: 'Connect notes with semantic search',
category: 'Productivity',
},
{
name: 'Bolt.new',
description: 'Prompt-to-app full-stack builder',
category: 'Coding',
url: 'https://bolt.new',
},
{
name: 'Pieces',
description: 'AI workflow companion for developers',
category: 'Productivity',
url: 'https://pieces.app',
},
{
name: 'AmazingAI',
description: 'Personal AI knowledge assistant',
category: 'Productivity',
},
{
name: 'TypingMind',
description: 'Better UI for ChatGPT and Claude',
category: 'Chat',
url: 'https://typingmind.com',
},
]
const PROFILE_BY_NAME = (name: string) => {
const n = name.toLowerCase()
if (/embed|rerank/.test(n)) return 'embedding'
if (/image|sora|veo|kling|pika|jimeng|dalle|imagen/.test(n)) return 'image'
if (/whisper|tts|voice|audio/.test(n)) return 'audio'
if (/o1|o3|o4|reasoning|thinking|deepseek-r/.test(n)) return 'reasoning'
if (/flash|haiku|mini|small|nano|fast/.test(n)) return 'fast'
if (/gpt-5|opus|ultra|405|70b/.test(n)) return 'large'
return 'standard'
}
type ProfileSpec = {
ttftRange: [number, number]
throughputRange: [number, number]
uptimeRange: [number, number]
}
const PROFILE_SPECS: Record<string, ProfileSpec> = {
embedding: {
ttftRange: [40, 120],
throughputRange: [0, 0],
uptimeRange: [99.9, 99.99],
},
image: {
ttftRange: [2_500, 12_000],
throughputRange: [0, 0],
uptimeRange: [98.5, 99.8],
},
audio: {
ttftRange: [180, 600],
throughputRange: [0, 0],
uptimeRange: [99.5, 99.95],
},
reasoning: {
ttftRange: [1_800, 5_500],
throughputRange: [25, 70],
uptimeRange: [99.4, 99.95],
},
fast: {
ttftRange: [180, 480],
throughputRange: [110, 240],
uptimeRange: [99.7, 99.99],
},
large: {
ttftRange: [600, 1_400],
throughputRange: [55, 95],
uptimeRange: [99.5, 99.95],
},
standard: {
ttftRange: [400, 900],
throughputRange: [70, 140],
uptimeRange: [99.6, 99.97],
},
}
function rangeFromSeed(
rand: () => number,
[min, max]: [number, number]
): number {
return randomInRange(rand, min, max)
}
function applyGroupFactor(value: number, factor: number): number {
return value * factor
}
function groupFactor(
group: string,
baseSeed: number
): { ttft: number; throughput: number; uptime: number } {
const rand = seededRandom(baseSeed ^ hashStringToSeed(group || 'default'))
return {
ttft: 0.85 + rand() * 0.55,
throughput: 0.85 + rand() * 0.4,
uptime: 0.997 + rand() * 0.003,
}
}
/**
* Build per-group performance stats for a model. Always returns at least one
* row for each enabled group, sorted alphabetically.
*/
export function buildGroupPerformance(model: PricingModel): GroupPerformance[] {
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
const targets = groups.length > 0 ? groups : ['default']
const profile = PROFILE_BY_NAME(model.model_name)
const spec = PROFILE_SPECS[profile]
const baseSeed = hashStringToSeed(model.model_name)
return targets
.slice()
.sort((a, b) => a.localeCompare(b))
.map<GroupPerformance>((group) => {
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
const factor = groupFactor(group, baseSeed)
const ttftP50 = applyGroupFactor(
rangeFromSeed(rand, spec.ttftRange),
factor.ttft
)
const throughput = applyGroupFactor(
rangeFromSeed(rand, spec.throughputRange),
factor.throughput
)
const uptimePct = Math.min(
99.99,
rangeFromSeed(rand, spec.uptimeRange) * factor.uptime
)
const requestVolume = randomIntInRange(rand, 18_000, 480_000)
return {
group,
ttft_p50_ms: Math.round(ttftP50),
ttft_p95_ms: Math.round(ttftP50 * (1.6 + rand() * 0.4)),
ttft_p99_ms: Math.round(ttftP50 * (2.4 + rand() * 0.6)),
throughput_tps: throughput === 0 ? 0 : Math.round(throughput * 10) / 10,
uptime_30d_pct: Math.round(uptimePct * 100) / 100,
request_volume_24h: requestVolume,
}
})
}
/**
* Build a 24-hour latency series for each group. Returns one point per hour
* (24 buckets), oldest first.
*/
export function buildLatencyTimeSeries(
model: PricingModel
): LatencyTimePoint[] {
const performances = buildGroupPerformance(model)
if (performances.length === 0) return []
const now = new Date()
now.setMinutes(0, 0, 0)
const baseSeed = hashStringToSeed(`${model.model_name}:lat`)
const points: LatencyTimePoint[] = []
for (const perf of performances) {
const rand = seededRandom(baseSeed ^ hashStringToSeed(perf.group))
for (let i = 23; i >= 0; i--) {
const ts = new Date(now.getTime() - i * 3_600_000)
const noise = 0.7 + rand() * 0.7
const trend = 0.85 + Math.sin(i / 3) * 0.1
const value = Math.max(50, Math.round(perf.ttft_p50_ms * noise * trend))
points.push({
timestamp: ts.toISOString(),
group: perf.group,
ttft_ms: value,
})
}
}
return points
}
/**
* Build a 30-day uptime series. Returns one point per day, oldest first.
*
* If `group` is provided the series is anchored on that group's mean uptime,
* otherwise it uses the per-model average. Either way the seed is derived
* deterministically so re-renders are stable.
*/
export function buildUptimeSeries(
model: PricingModel,
group?: string
): UptimeDayPoint[] {
const performances = buildGroupPerformance(model)
if (performances.length === 0) return []
const target = group ? performances.find((p) => p.group === group) : null
const baseUptime = target
? target.uptime_30d_pct
: performances.reduce((s, p) => s + p.uptime_30d_pct, 0) /
performances.length
const baseSeed = hashStringToSeed(`${model.model_name}:up:${group ?? '_all'}`)
const rand = seededRandom(baseSeed)
const today = new Date()
today.setHours(0, 0, 0, 0)
const points: UptimeDayPoint[] = []
for (let i = 29; i >= 0; i--) {
const date = new Date(today.getTime() - i * 86_400_000)
const isoDate = date.toISOString().slice(0, 10)
const incidentChance = rand()
const incidents = incidentChance > 0.92 ? 1 : 0
const outageMinutes = incidents > 0 ? Math.round(rand() * 30 + 5) : 0
const downtimePct = (outageMinutes / 1_440) * 100
const dayUptime = Math.max(85, Math.min(100, baseUptime - downtimePct))
points.push({
date: isoDate,
uptime_pct: Math.round(dayUptime * 100) / 100,
incidents,
outage_minutes: outageMinutes,
})
}
return points
}
/**
* Build a deterministic top-apps ranking for the model. The first three apps
* always come from the same template list; the rest is shuffled by the seed
* so different models surface different long tails.
*/
export function buildAppRankings(
model: PricingModel,
count = 12
): AppRanking[] {
const baseSeed = hashStringToSeed(`${model.model_name}:apps`)
const rand = seededRandom(baseSeed)
const candidates = [...APP_TEMPLATES]
// FisherYates shuffle.
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1))
;[candidates[i], candidates[j]] = [candidates[j], candidates[i]]
}
const top = candidates.slice(0, count)
const baseTokens = randomInRange(rand, 90_000_000, 320_000_000)
return top.map((app, idx) => {
const decay = Math.pow(0.78, idx)
const monthlyTokens = Math.round(baseTokens * decay * (0.85 + rand() * 0.3))
const growthPctRaw = randomInRange(rand, -28, 84)
const growthPct = Math.round(growthPctRaw * 10) / 10
return {
rank: idx + 1,
name: app.name,
description: app.description,
category: app.category,
url: app.url,
growth_pct: growthPct,
monthly_tokens: monthlyTokens,
initial: app.name.charAt(0).toUpperCase(),
}
})
}
/** Aggregate uptime over the most recent 30 days. */
export function aggregateUptime(points: UptimeDayPoint[]): {
uptime_pct: number
incidents: number
outage_minutes: number
} {
if (points.length === 0) {
return { uptime_pct: 0, incidents: 0, outage_minutes: 0 }
}
const incidents = points.reduce((s, p) => s + p.incidents, 0)
const outageMinutes = points.reduce((s, p) => s + p.outage_minutes, 0)
const totalMinutes = points.length * 1_440
const uptimePct = ((totalMinutes - outageMinutes) / totalMinutes) * 100
return {
incidents,
outage_minutes: outageMinutes,
uptime_pct: Math.round(uptimePct * 1000) / 1000,
}
}
/** Format throughput for display: "0" → "—". */
export function formatThroughput(tps: number): string {
if (tps <= 0) return '—'
if (tps >= 1_000) return `${(tps / 1_000).toFixed(1)}K t/s`
return `${tps.toFixed(tps < 10 ? 2 : 1)} t/s`
}
/** Format latency in ms with proper unit selection. */
export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
if (ms >= 1_000) return `${(ms / 1_000).toFixed(2)}s`
return `${Math.round(ms)}ms`
}
/** Format uptime percentage with 2 decimal places. */
export function formatUptimePct(pct: number): string {
if (!Number.isFinite(pct)) return '—'
return `${pct.toFixed(2)}%`
}
/** Compact integer formatter for token counts in apps tab. */
export function formatTokenVolume(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0'
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return n.toString()
}
// ---------------------------------------------------------------------------
// Mock supported-parameters & rate-limits & misc API metadata
// ---------------------------------------------------------------------------
export type SupportedParameter = {
name: string
type:
| 'number'
| 'integer'
| 'boolean'
| 'string'
| 'object'
| 'array'
| 'enum'
defaultValue?: string | number | boolean
range?: string
enumValues?: string[]
descriptionKey: string
required?: boolean
}
const COMMON_CHAT_PARAMS: SupportedParameter[] = [
{
name: 'temperature',
type: 'number',
defaultValue: 1,
range: '0 ~ 2',
descriptionKey: 'Sampling temperature; lower is more deterministic',
},
{
name: 'top_p',
type: 'number',
defaultValue: 1,
range: '0 ~ 1',
descriptionKey: 'Nucleus sampling probability mass',
},
{
name: 'max_tokens',
type: 'integer',
range: '>= 1',
descriptionKey: 'Maximum number of tokens in the response',
},
{
name: 'frequency_penalty',
type: 'number',
defaultValue: 0,
range: '-2 ~ 2',
descriptionKey: 'Penalises repetition of frequent tokens',
},
{
name: 'presence_penalty',
type: 'number',
defaultValue: 0,
range: '-2 ~ 2',
descriptionKey: 'Encourages introducing new topics',
},
{
name: 'stop',
type: 'array',
descriptionKey: 'Up to 4 strings that stop generation',
},
{
name: 'seed',
type: 'integer',
descriptionKey: 'Deterministic sampling seed (best-effort)',
},
{
name: 'n',
type: 'integer',
defaultValue: 1,
range: '>= 1',
descriptionKey: 'Number of completions to generate',
},
{
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey: 'Stream tokens via Server-Sent Events',
},
{
name: 'response_format',
type: 'object',
descriptionKey: 'Force JSON object or schema-conforming output',
},
{
name: 'tools',
type: 'array',
descriptionKey: 'Tool / function declarations the model may call',
},
{
name: 'tool_choice',
type: 'string',
enumValues: ['auto', 'none', 'required'],
descriptionKey: 'Tool-choice policy or specific tool name',
},
{
name: 'logprobs',
type: 'boolean',
defaultValue: false,
descriptionKey: 'Return per-token log probabilities',
},
{
name: 'top_logprobs',
type: 'integer',
range: '0 ~ 20',
descriptionKey: 'Number of top log probabilities returned per token',
},
{
name: 'logit_bias',
type: 'object',
descriptionKey: 'Per-token logit bias map',
},
{
name: 'user',
type: 'string',
descriptionKey: 'End-user identifier for abuse monitoring',
},
]
const REASONING_PARAMS: SupportedParameter[] = [
{
name: 'reasoning_effort',
type: 'enum',
enumValues: ['low', 'medium', 'high'],
defaultValue: 'medium',
descriptionKey: 'Controls how much the model thinks before answering',
},
{
name: 'max_completion_tokens',
type: 'integer',
range: '>= 1',
descriptionKey: 'Maximum tokens including hidden reasoning tokens',
},
{
name: 'stop',
type: 'array',
descriptionKey: 'Up to 4 strings that stop generation',
},
{
name: 'seed',
type: 'integer',
descriptionKey: 'Deterministic sampling seed (best-effort)',
},
{
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey: 'Stream tokens via Server-Sent Events',
},
{
name: 'response_format',
type: 'object',
descriptionKey: 'Force JSON object or schema-conforming output',
},
{
name: 'tools',
type: 'array',
descriptionKey: 'Tool / function declarations the model may call',
},
{
name: 'tool_choice',
type: 'string',
enumValues: ['auto', 'none', 'required'],
descriptionKey: 'Tool-choice policy or specific tool name',
},
{
name: 'user',
type: 'string',
descriptionKey: 'End-user identifier for abuse monitoring',
},
]
const EMBEDDING_PARAMS: SupportedParameter[] = [
{
name: 'input',
type: 'string',
required: true,
descriptionKey: 'Text or array of texts to embed',
},
{
name: 'dimensions',
type: 'integer',
range: '>= 1',
descriptionKey: 'Truncate embeddings to this many dimensions',
},
{
name: 'encoding_format',
type: 'enum',
enumValues: ['float', 'base64'],
defaultValue: 'float',
descriptionKey: 'Wire encoding for the embedding vectors',
},
{
name: 'user',
type: 'string',
descriptionKey: 'End-user identifier for abuse monitoring',
},
]
const IMAGE_PARAMS: SupportedParameter[] = [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: 'Text description of the desired image',
},
{
name: 'size',
type: 'enum',
enumValues: ['256x256', '512x512', '1024x1024', '1024x1792', '1792x1024'],
defaultValue: '1024x1024',
descriptionKey: 'Output image size',
},
{
name: 'quality',
type: 'enum',
enumValues: ['standard', 'hd'],
defaultValue: 'standard',
descriptionKey: 'Generation quality preset',
},
{
name: 'style',
type: 'enum',
enumValues: ['vivid', 'natural'],
defaultValue: 'vivid',
descriptionKey: 'Aesthetic style',
},
{
name: 'n',
type: 'integer',
defaultValue: 1,
range: '1 ~ 10',
descriptionKey: 'Number of images to generate',
},
{
name: 'response_format',
type: 'enum',
enumValues: ['url', 'b64_json'],
defaultValue: 'url',
descriptionKey: 'How to deliver the resulting image',
},
]
const VIDEO_PARAMS: SupportedParameter[] = [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: 'Text description of the desired video',
},
{
name: 'duration',
type: 'integer',
range: '1 ~ 60',
descriptionKey: 'Video length in seconds',
},
{
name: 'aspect_ratio',
type: 'enum',
enumValues: ['16:9', '9:16', '1:1'],
defaultValue: '16:9',
descriptionKey: 'Output aspect ratio',
},
{
name: 'fps',
type: 'integer',
range: '8 ~ 60',
defaultValue: 24,
descriptionKey: 'Frames per second',
},
]
type ApiCategory = 'reasoning' | 'embedding' | 'image' | 'video' | 'chat'
/**
* Refine the broad PROFILE_BY_NAME bucket into an API-shape category. The
* `image` bucket from `PROFILE_BY_NAME` lumps still-image and video models
* together (because their performance profiles overlap); for the API tab we
* need to distinguish them so the request-parameter table is accurate.
*/
function apiCategoryOf(model: PricingModel): ApiCategory {
const profile = PROFILE_BY_NAME(model.model_name)
if (profile === 'embedding' || profile === 'reasoning') return profile
if (profile === 'image') {
return /sora|veo|kling|pika|video|wan-|hunyuanvideo/i.test(model.model_name)
? 'video'
: 'image'
}
return 'chat'
}
/**
* Build the list of request parameters that the model accepts. The list is
* shaped per-modality so reasoning, embedding, image, video and chat models
* each show their relevant parameter set.
*/
export function buildSupportedParameters(
model: PricingModel
): SupportedParameter[] {
const cat = apiCategoryOf(model)
if (cat === 'reasoning') return REASONING_PARAMS
if (cat === 'embedding') return EMBEDDING_PARAMS
if (cat === 'image') return IMAGE_PARAMS
if (cat === 'video') return VIDEO_PARAMS
return COMMON_CHAT_PARAMS
}
export type RateLimit = {
group: string
rpm: number
tpm: number
rpd: number
}
/** Build per-group RPM / TPM / RPD limits for the model. */
export function buildRateLimits(model: PricingModel): RateLimit[] {
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
const targets = groups.length > 0 ? groups : ['default']
const cat = apiCategoryOf(model)
const baseSeed = hashStringToSeed(`${model.model_name}:rl`)
const isHeavy = cat === 'image' || cat === 'video'
const isLight = cat === 'embedding'
const baseRpm = isHeavy ? 60 : isLight ? 5_000 : 500
const baseTpm = isHeavy ? 0 : isLight ? 1_000_000 : 200_000
const baseRpd = isHeavy ? 1_000 : isLight ? 100_000 : 10_000
return targets
.slice()
.sort((a, b) => a.localeCompare(b))
.map((group) => {
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
const tier = 0.6 + rand() * 1.4
return {
group,
rpm: Math.round((baseRpm * tier) / 10) * 10,
tpm: baseTpm === 0 ? 0 : Math.round((baseTpm * tier) / 1_000) * 1_000,
rpd: Math.round((baseRpd * tier) / 100) * 100,
}
})
}
/** Format an integer rate-limit value compactly. */
export function formatRateLimit(value: number): string {
if (value <= 0) return '—'
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000)
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`
return value.toLocaleString()
}
+548
View File
@@ -0,0 +1,548 @@
import type { Modality, ModelCapability, PricingModel } from '../types'
import { hashStringToSeed, seededRandom } from './seed'
// ----------------------------------------------------------------------------
// Model metadata inference
// ----------------------------------------------------------------------------
//
// The backend does not currently return `context_length`, `max_output_tokens`,
// `knowledge_cutoff`, `release_date`, `parameter_count`, or modality/capability
// flags for a model. Until it does, we infer reasonable values client-side
// from the data we already have (endpoint types, ratios, tags, model name)
// and fall back to a deterministic mock seeded from the model name so that
// every render of the same model shows the same numbers.
//
// When the backend starts returning these fields, callers should prefer the
// explicit values on `model.*` and only fall back to the inferred ones.
const TEXT_INPUT_ENDPOINTS = new Set([
'openai',
'openai-response',
'anthropic',
'gemini',
'embeddings',
'jina-rerank',
])
const IMAGE_OUTPUT_ENDPOINTS = new Set(['image-generation'])
const VIDEO_OUTPUT_ENDPOINTS = new Set(['openai-video'])
const EMBEDDING_ENDPOINTS = new Set(['embeddings', 'jina-rerank'])
const REASONING_NAME_PATTERNS = [
/^o[1-4](?:[-:_].+)?$/i,
/reasoning/i,
/thinking/i,
/qwq/i,
/deepseek-r\d/i,
/grok.*-(?:thinking|reasoning)/i,
]
const VISION_NAME_PATTERNS = [
/vision/i,
/vl(?:[-_]|$)/i,
/multimodal/i,
/-omni/i,
]
const AUDIO_NAME_PATTERNS = [
/audio/i,
/whisper/i,
/tts/i,
/voice/i,
/-realtime/i,
]
const VIDEO_NAME_PATTERNS = [/video/i, /sora/i, /veo/i, /kling/i, /pika/i]
const CODE_NAME_PATTERNS = [/code/i, /-coder/i]
const WEB_SEARCH_PATTERNS = [/web[-_ ]?search/i, /-online/i, /perplexity/i]
const KNOWLEDGE_CUTOFFS = [
'2023-04',
'2023-10',
'2023-12',
'2024-04',
'2024-06',
'2024-08',
'2024-10',
'2024-12',
'2025-02',
'2025-04',
'2025-08',
]
const PARAM_BUCKETS = [
'1.5B',
'3B',
'7B',
'8B',
'14B',
'32B',
'70B',
'120B',
'405B',
]
const CONTEXT_BUCKETS = [
8_192, 16_384, 32_768, 65_536, 128_000, 200_000, 1_000_000,
]
const MAX_OUTPUT_BUCKETS = [2_048, 4_096, 8_192, 16_384, 32_768, 65_536]
const TAG_TO_CAPABILITY: Record<string, ModelCapability> = {
vision: 'vision',
multimodal: 'vision',
reasoning: 'reasoning',
thinking: 'reasoning',
tools: 'tools',
function: 'function_calling',
'function-calling': 'function_calling',
streaming: 'streaming',
json: 'json_mode',
structured: 'structured_output',
search: 'web_search',
code: 'code_interpreter',
embedding: 'embeddings',
}
const TAG_TO_MODALITY: Record<string, Modality> = {
text: 'text',
image: 'image',
audio: 'audio',
video: 'video',
file: 'file',
document: 'file',
pdf: 'file',
}
function pickFromBuckets<T>(buckets: T[], rand: () => number): T {
return buckets[Math.floor(rand() * buckets.length)]
}
function parseModelTags(tagsString?: string): string[] {
if (!tagsString) return []
return tagsString
.split(/[,;|\s]+/)
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
}
function nameMatches(name: string, patterns: RegExp[]): boolean {
return patterns.some((re) => re.test(name))
}
function inferInputModalities(
model: PricingModel,
tags: string[],
endpoints: string[],
name: string
): Modality[] {
const set = new Set<Modality>()
if (
endpoints.length === 0 ||
endpoints.some((e) => TEXT_INPUT_ENDPOINTS.has(e))
) {
set.add('text')
}
if (model.image_ratio != null || nameMatches(name, VISION_NAME_PATTERNS)) {
set.add('image')
}
if (model.audio_ratio != null || nameMatches(name, AUDIO_NAME_PATTERNS)) {
set.add('audio')
}
if (nameMatches(name, VIDEO_NAME_PATTERNS)) {
set.add('video')
}
for (const tag of tags) {
const m = TAG_TO_MODALITY[tag]
if (m) set.add(m)
}
if (set.size === 0) set.add('text')
return ordered(set)
}
function inferOutputModalities(
model: PricingModel,
endpoints: string[],
name: string
): Modality[] {
const set = new Set<Modality>()
if (endpoints.some((e) => IMAGE_OUTPUT_ENDPOINTS.has(e))) set.add('image')
if (endpoints.some((e) => VIDEO_OUTPUT_ENDPOINTS.has(e))) set.add('video')
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('text')
if (
model.audio_completion_ratio != null ||
/tts|voice|audio-out/i.test(name)
) {
set.add('audio')
}
if (set.size === 0) set.add('text')
return ordered(set)
}
function inferCapabilities(
model: PricingModel,
tags: string[],
endpoints: string[],
name: string,
outputs: Modality[],
inputs: Modality[]
): ModelCapability[] {
const set = new Set<ModelCapability>()
if (outputs.includes('text') && !endpoints.includes('image-generation')) {
set.add('streaming')
set.add('system_prompt')
}
if (
!endpoints.includes('image-generation') &&
!endpoints.includes('embeddings') &&
!endpoints.includes('jina-rerank')
) {
set.add('function_calling')
set.add('tools')
set.add('json_mode')
set.add('structured_output')
}
if (inputs.includes('image')) set.add('vision')
if (model.cache_ratio != null) set.add('caching')
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('embeddings')
if (nameMatches(name, REASONING_NAME_PATTERNS)) set.add('reasoning')
if (nameMatches(name, CODE_NAME_PATTERNS)) set.add('code_interpreter')
if (nameMatches(name, WEB_SEARCH_PATTERNS)) set.add('web_search')
for (const tag of tags) {
const cap = TAG_TO_CAPABILITY[tag]
if (cap) set.add(cap)
}
return Array.from(set)
}
function ordered(modalities: Set<Modality>): Modality[] {
const order: Modality[] = ['text', 'image', 'audio', 'video', 'file']
return order.filter((m) => modalities.has(m))
}
function inferContextAndOutputs(
name: string,
rand: () => number,
endpoints: string[]
): { context: number; maxOutput: number } {
if (endpoints.includes('embeddings') || endpoints.includes('jina-rerank')) {
return { context: 8_192, maxOutput: 0 }
}
if (
endpoints.includes('image-generation') ||
endpoints.includes('openai-video')
) {
return { context: 4_096, maxOutput: 0 }
}
const lower = name.toLowerCase()
if (lower.includes('1m') || lower.includes('-long')) {
return { context: 1_000_000, maxOutput: 65_536 }
}
if (
lower.includes('200k') ||
lower.includes('claude-3') ||
lower.includes('claude-4')
) {
return { context: 200_000, maxOutput: 16_384 }
}
if (lower.includes('128k') || /gpt-4o|gpt-4\.1|gpt-5|o1|o3|o4/.test(lower)) {
return { context: 128_000, maxOutput: 16_384 }
}
if (/gemini.*-2|gemini.*pro|gemini.*flash/.test(lower)) {
return { context: 1_000_000, maxOutput: 8_192 }
}
if (/gpt-3\.5|claude-2/.test(lower)) {
return { context: 16_384, maxOutput: 4_096 }
}
const context = pickFromBuckets(CONTEXT_BUCKETS, rand)
const maxOutput = Math.min(context, pickFromBuckets(MAX_OUTPUT_BUCKETS, rand))
return { context, maxOutput }
}
function inferReleaseAndCutoff(rand: () => number): {
release: string
cutoff: string
} {
const cutoff = pickFromBuckets(KNOWLEDGE_CUTOFFS, rand)
const [year, month] = cutoff.split('-').map(Number)
const offsetMonths = 4 + Math.floor(rand() * 6)
const releaseMonth = month + offsetMonths
const releaseYear = year + Math.floor((releaseMonth - 1) / 12)
const finalMonth = ((releaseMonth - 1) % 12) + 1
const release = `${releaseYear}-${String(finalMonth).padStart(2, '0')}-15`
return { release, cutoff }
}
export type ModelMetadata = {
context_length: number
max_output_tokens: number
knowledge_cutoff: string
release_date: string
parameter_count: string
input_modalities: Modality[]
output_modalities: Modality[]
capabilities: ModelCapability[]
}
/**
* Infer / mock model metadata. Prefers explicit fields on `model.*` and
* falls back to inference + a deterministic seed otherwise.
*/
export function inferModelMetadata(model: PricingModel): ModelMetadata {
const name = model.model_name || ''
const rand = seededRandom(hashStringToSeed(name))
const tags = parseModelTags(model.tags)
const endpoints = model.supported_endpoint_types || []
const inputs =
model.input_modalities ?? inferInputModalities(model, tags, endpoints, name)
const outputs =
model.output_modalities ?? inferOutputModalities(model, endpoints, name)
const capabilities =
model.capabilities ??
inferCapabilities(model, tags, endpoints, name, outputs, inputs)
const fallback = inferContextAndOutputs(name, rand, endpoints)
const cutoffAndRelease = inferReleaseAndCutoff(rand)
return {
context_length: model.context_length ?? fallback.context,
max_output_tokens: model.max_output_tokens ?? fallback.maxOutput,
knowledge_cutoff: model.knowledge_cutoff ?? cutoffAndRelease.cutoff,
release_date: model.release_date ?? cutoffAndRelease.release,
parameter_count:
model.parameter_count ?? pickFromBuckets(PARAM_BUCKETS, rand),
input_modalities: inputs,
output_modalities: outputs,
capabilities,
}
}
const TOKEN_FORMAT = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
})
/** Format a token count compactly: 128_000 → "128K", 1_000_000 → "1M". */
export function formatTokenCount(tokens: number): string {
if (!Number.isFinite(tokens) || tokens <= 0) return '—'
if (tokens >= 1_000_000) {
const value = tokens / 1_000_000
return `${TOKEN_FORMAT.format(value)}M`
}
if (tokens >= 1_000) {
const value = tokens / 1_000
return `${TOKEN_FORMAT.format(value)}K`
}
return TOKEN_FORMAT.format(tokens)
}
/** Format a YYYY-MM (or YYYY-MM-DD) date as `Mon YYYY` for display. */
export function formatYearMonth(value: string): string {
if (!value) return '—'
const [yearStr, monthStr] = value.split('-')
const year = Number(yearStr)
const month = Number(monthStr)
if (!Number.isFinite(year) || !Number.isFinite(month)) return value
const date = new Date(Date.UTC(year, month - 1, 1))
return date.toLocaleString(undefined, { year: 'numeric', month: 'short' })
}
// ---------------------------------------------------------------------------
// Provider / vendor / tokenizer / license inference
// ---------------------------------------------------------------------------
//
// These helpers derive vendor-style metadata from the model name. They are
// purely heuristic and serve only the API-info display until the backend
// returns explicit fields.
export type ModelVendor =
| 'openai'
| 'anthropic'
| 'google'
| 'meta'
| 'mistral'
| 'qwen'
| 'deepseek'
| 'xai'
| 'cohere'
| 'baidu'
| 'zhipu'
| 'moonshot'
| 'minimax'
| 'tencent'
| 'bytedance'
| 'midjourney'
| 'stability'
| 'unknown'
export type ApiInfo = {
vendor: ModelVendor
vendor_label: string
tokenizer: string
tokenizer_note?: string
license: string
license_kind: 'proprietary' | 'open' | 'open-weight' | 'unknown'
data_retention_days: number
training_opt_out: boolean
homepage?: string
}
const VENDOR_LABELS: Record<ModelVendor, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google',
meta: 'Meta',
mistral: 'Mistral AI',
qwen: 'Alibaba (Qwen)',
deepseek: 'DeepSeek',
xai: 'xAI',
cohere: 'Cohere',
baidu: 'Baidu',
zhipu: 'Zhipu AI',
moonshot: 'Moonshot AI',
minimax: 'MiniMax',
tencent: 'Tencent',
bytedance: 'ByteDance',
midjourney: 'Midjourney',
stability: 'Stability AI',
unknown: 'Unknown',
}
function detectVendor(name: string): ModelVendor {
const n = name.toLowerCase()
if (/^gpt|^o[1-4]|davinci|babbage|whisper|tts|dall.?e|sora|^omni/.test(n))
return 'openai'
if (/claude/.test(n)) return 'anthropic'
if (/gemini|gemma|imagen|veo|palm/.test(n)) return 'google'
if (/llama|^codellama/.test(n)) return 'meta'
if (/mistral|mixtral|codestral|magistral|pixtral/.test(n)) return 'mistral'
if (/qwen|qwq|qvq/.test(n)) return 'qwen'
if (/deepseek/.test(n)) return 'deepseek'
if (/grok/.test(n)) return 'xai'
if (/command|cohere|aya/.test(n)) return 'cohere'
if (/ernie|wenxin/.test(n)) return 'baidu'
if (/glm|chatglm|cogview|cogvideo/.test(n)) return 'zhipu'
if (/kimi|moonshot/.test(n)) return 'moonshot'
if (/abab|minimax|hailuo/.test(n)) return 'minimax'
if (/hunyuan/.test(n)) return 'tencent'
if (/doubao|seed|jimeng/.test(n)) return 'bytedance'
if (/midjourney|niji/.test(n)) return 'midjourney'
if (/^sd-|stable[-_]?diffusion|sdxl/.test(n)) return 'stability'
return 'unknown'
}
const TOKENIZER_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
openai: 'o200k_base',
anthropic: 'Anthropic Claude tokenizer',
google: 'SentencePiece (Gemini)',
meta: 'Llama 3 tokenizer',
mistral: 'Mistral tokenizer (BPE)',
qwen: 'Qwen tokenizer (tiktoken-compat)',
deepseek: 'DeepSeek tokenizer (BPE)',
xai: 'Grok tokenizer (BPE)',
cohere: 'Cohere tokenizer',
baidu: 'Ernie tokenizer',
zhipu: 'GLM tokenizer',
moonshot: 'Kimi tokenizer',
minimax: 'ABAB tokenizer',
tencent: 'Hunyuan tokenizer',
bytedance: 'Doubao tokenizer',
}
function inferTokenizer(
model: PricingModel,
vendor: ModelVendor
): {
tokenizer: string
note?: string
} {
const name = model.model_name.toLowerCase()
if (vendor === 'openai') {
if (/gpt-3|davinci|babbage|whisper|tts/.test(name)) {
return { tokenizer: 'cl100k_base', note: 'Older GPT-3.5 family' }
}
return { tokenizer: 'o200k_base' }
}
return { tokenizer: TOKENIZER_BY_VENDOR[vendor] ?? 'BPE (vendor-specific)' }
}
const LICENSE_BY_VENDOR: Record<
ModelVendor,
{ license: string; kind: ApiInfo['license_kind'] }
> = {
openai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
anthropic: { license: 'Proprietary (commercial)', kind: 'proprietary' },
google: { license: 'Proprietary (commercial)', kind: 'proprietary' },
meta: { license: 'Llama Community License', kind: 'open-weight' },
mistral: { license: 'Apache 2.0 / Commercial', kind: 'open-weight' },
qwen: { license: 'Tongyi Qianwen License', kind: 'open-weight' },
deepseek: { license: 'DeepSeek License', kind: 'open-weight' },
xai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
cohere: { license: 'Proprietary (commercial)', kind: 'proprietary' },
baidu: { license: 'Proprietary (commercial)', kind: 'proprietary' },
zhipu: { license: 'GLM-4 License', kind: 'open-weight' },
moonshot: { license: 'Proprietary (commercial)', kind: 'proprietary' },
minimax: { license: 'Proprietary (commercial)', kind: 'proprietary' },
tencent: { license: 'Hunyuan License', kind: 'open-weight' },
bytedance: { license: 'Proprietary (commercial)', kind: 'proprietary' },
midjourney: { license: 'Proprietary (commercial)', kind: 'proprietary' },
stability: { license: 'Stability AI Community License', kind: 'open-weight' },
unknown: { license: 'Provider-specific', kind: 'unknown' },
}
const HOMEPAGE_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
openai: 'https://platform.openai.com/docs/models',
anthropic: 'https://docs.anthropic.com/claude/docs/models-overview',
google: 'https://ai.google.dev/models',
meta: 'https://llama.meta.com/',
mistral: 'https://docs.mistral.ai/getting-started/models/',
qwen: 'https://qwenlm.github.io/',
deepseek: 'https://api-docs.deepseek.com/',
xai: 'https://x.ai/api',
cohere: 'https://docs.cohere.com/docs/models',
baidu: 'https://cloud.baidu.com/product/wenxinworkshop',
zhipu: 'https://open.bigmodel.cn/dev/api',
moonshot: 'https://platform.moonshot.cn/docs',
minimax: 'https://platform.minimaxi.com/document/notice',
tencent: 'https://cloud.tencent.com/document/product/1729',
bytedance: 'https://www.volcengine.com/docs/82379',
midjourney: 'https://www.midjourney.com/',
stability: 'https://platform.stability.ai/',
}
/**
* Build vendor / tokenizer / license / privacy metadata for the model.
* Returns deterministic values keyed off the model name so each render is
* stable.
*/
export function inferApiInfo(model: PricingModel): ApiInfo {
const vendor = detectVendor(model.model_name || '')
const tk = inferTokenizer(model, vendor)
const license = LICENSE_BY_VENDOR[vendor]
const rand = seededRandom(hashStringToSeed(`${model.model_name}:api`))
const retention = vendor === 'openai' ? 30 : Math.round(rand() * 90)
return {
vendor,
vendor_label: VENDOR_LABELS[vendor],
tokenizer: tk.tokenizer,
tokenizer_note: tk.note,
license: license.license,
license_kind: license.kind,
data_retention_days: retention,
training_opt_out: true,
homepage: HOMEPAGE_BY_VENDOR[vendor],
}
}
+45
View File
@@ -0,0 +1,45 @@
// ----------------------------------------------------------------------------
// Deterministic seeding helpers
// ----------------------------------------------------------------------------
//
// These utilities are used to generate stable, repeatable mock metrics for
// model details (latency, throughput, uptime, app rankings) until the
// backend ships real values. Seeding the PRNG from the model name (and
// optionally the group name) ensures the same model always renders the same
// numbers, instead of jittering on every render.
/** djb2-inspired string hash → non-negative 31-bit integer. */
export function hashStringToSeed(input: string): number {
let hash = 5381
for (let i = 0; i < input.length; i++) {
hash = (hash * 33) ^ input.charCodeAt(i)
}
return Math.abs(hash | 0)
}
/** Linear-congruential generator producing pseudo-random numbers in [0, 1). */
export function seededRandom(seed: number): () => number {
let state = (seed || 1) >>> 0
return () => {
state = (state * 1664525 + 1013904223) >>> 0
return state / 0x1_0000_0000
}
}
/** Pick a number in [min, max] from a seeded PRNG. */
export function randomInRange(
rand: () => number,
min: number,
max: number
): number {
return min + rand() * (max - min)
}
/** Pick an integer in [min, max] (inclusive) from a seeded PRNG. */
export function randomIntInRange(
rand: () => number,
min: number,
max: number
): number {
return Math.floor(randomInRange(rand, min, max + 1))
}
+32
View File
@@ -37,8 +37,40 @@ export type PricingModel = {
billing_expr?: string
/** Pricing version returned by backend, useful for cache busting */
pricing_version?: string
/**
* Optional model metadata fields. These are not yet returned by the backend
* and are populated client-side from {@link inferModelMetadata}.
* When the backend ships these fields, the inference layer becomes a
* fallback rather than the source of truth.
*/
context_length?: number
max_output_tokens?: number
knowledge_cutoff?: string
release_date?: string
parameter_count?: string
input_modalities?: Modality[]
output_modalities?: Modality[]
capabilities?: ModelCapability[]
}
/** Input/output modalities supported by a model. */
export type Modality = 'text' | 'image' | 'audio' | 'video' | 'file'
/** Functional capabilities a model exposes. */
export type ModelCapability =
| 'function_calling'
| 'streaming'
| 'vision'
| 'json_mode'
| 'structured_output'
| 'reasoning'
| 'tools'
| 'system_prompt'
| 'web_search'
| 'code_interpreter'
| 'caching'
| 'embeddings'
export type PricingData = {
success: boolean
message?: string