model-details.tsx 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109
  1. import { useMemo } from 'react'
  2. import { useQuery } from '@tanstack/react-query'
  3. import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
  4. import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
  5. import { useTranslation } from 'react-i18next'
  6. import { getLobeIcon } from '@/lib/lobe-icon'
  7. import { cn } from '@/lib/utils'
  8. import { Button } from '@/components/ui/button'
  9. import {
  10. Sheet,
  11. SheetContent,
  12. SheetDescription,
  13. SheetHeader,
  14. SheetTitle,
  15. } from '@/components/ui/sheet'
  16. import { Skeleton } from '@/components/ui/skeleton'
  17. import {
  18. Table,
  19. TableBody,
  20. TableCell,
  21. TableHead,
  22. TableHeader,
  23. TableRow,
  24. } from '@/components/ui/table'
  25. import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
  26. import { CopyButton } from '@/components/copy-button'
  27. import { GroupBadge } from '@/components/group-badge'
  28. import { PublicLayout } from '@/components/layout'
  29. import { getPerfMetrics } from '../api'
  30. import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
  31. import { usePricingData } from '../hooks/use-pricing-data'
  32. import {
  33. getDynamicPriceEntries,
  34. getDynamicPricingSummary,
  35. getDynamicPricingTiers,
  36. isDynamicPricingModel,
  37. } from '../lib/dynamic-price'
  38. import { parseTags } from '../lib/filters'
  39. import {
  40. formatLatency,
  41. formatThroughput,
  42. formatUptimePct,
  43. } from '../lib/mock-stats'
  44. import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
  45. import { inferModelMetadata } from '../lib/model-metadata'
  46. import { formatFixedPrice, formatGroupPrice } from '../lib/price'
  47. import type {
  48. Modality,
  49. ModelCapability,
  50. PriceType,
  51. PricingModel,
  52. TokenUnit,
  53. } from '../types'
  54. import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
  55. import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
  56. import { ModalityIcons } from './model-details-modalities'
  57. import { ModelDetailsPerformance } from './model-details-performance'
  58. import { ModelDetailsQuickStats } from './model-details-quick-stats'
  59. // ----------------------------------------------------------------------------
  60. // Local UI helpers
  61. // ----------------------------------------------------------------------------
  62. function SectionTitle(props: { children: React.ReactNode }) {
  63. return (
  64. <h2 className='text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase'>
  65. {props.children}
  66. </h2>
  67. )
  68. }
  69. const CAPABILITY_LABEL_KEYS: Record<ModelCapability, string> = {
  70. function_calling: 'Function calling',
  71. streaming: 'Streaming',
  72. vision: 'Vision',
  73. json_mode: 'JSON mode',
  74. structured_output: 'Structured output',
  75. reasoning: 'Reasoning',
  76. tools: 'Tools',
  77. system_prompt: 'System prompt',
  78. web_search: 'Web search',
  79. code_interpreter: 'Code interpreter',
  80. caching: 'Prompt caching',
  81. embeddings: 'Embeddings',
  82. }
  83. function CompactCapabilityList(props: { capabilities: ModelCapability[] }) {
  84. const { t } = useTranslation()
  85. if (props.capabilities.length === 0) {
  86. return (
  87. <span className='text-muted-foreground text-xs'>
  88. {t('No capabilities reported for this model.')}
  89. </span>
  90. )
  91. }
  92. return (
  93. <div className='flex flex-wrap gap-1.5'>
  94. {props.capabilities.map((capability) => (
  95. <span
  96. key={capability}
  97. className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
  98. >
  99. {t(CAPABILITY_LABEL_KEYS[capability] ?? capability)}
  100. </span>
  101. ))}
  102. </div>
  103. )
  104. }
  105. function CompactModalities(props: { input: Modality[]; output: Modality[] }) {
  106. const { t } = useTranslation()
  107. return (
  108. <div className='grid gap-2 sm:grid-cols-2'>
  109. <div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
  110. <span className='text-muted-foreground text-xs font-medium'>
  111. {t('Input')}
  112. </span>
  113. <ModalityIcons modalities={props.input} />
  114. </div>
  115. <div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
  116. <span className='text-muted-foreground text-xs font-medium'>
  117. {t('Output')}
  118. </span>
  119. <ModalityIcons modalities={props.output} />
  120. </div>
  121. </div>
  122. )
  123. }
  124. function ModelSignalsSection(props: {
  125. capabilities: ModelCapability[]
  126. input: Modality[]
  127. output: Modality[]
  128. }) {
  129. const { t } = useTranslation()
  130. return (
  131. <section>
  132. <SectionTitle>
  133. {t('Capabilities')} / {t('Supported modalities')}
  134. </SectionTitle>
  135. <div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
  136. <CompactCapabilityList capabilities={props.capabilities} />
  137. <CompactModalities input={props.input} output={props.output} />
  138. </div>
  139. </section>
  140. )
  141. }
  142. function OverviewMetric(props: {
  143. icon: React.ComponentType<{ className?: string }>
  144. label: string
  145. value: React.ReactNode
  146. intent?: 'default' | 'warning' | 'success'
  147. }) {
  148. const Icon = props.icon
  149. const intent = props.intent ?? 'default'
  150. return (
  151. <div className='flex min-w-0 items-center gap-2 px-3 py-2'>
  152. <Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
  153. <div className='min-w-0 flex-1'>
  154. <div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
  155. {props.label}
  156. </div>
  157. <div
  158. className={cn(
  159. 'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
  160. intent === 'warning' && 'text-amber-600 dark:text-amber-400',
  161. intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
  162. )}
  163. >
  164. {props.value}
  165. </div>
  166. </div>
  167. </div>
  168. )
  169. }
  170. function OverviewSummaryGrid(props: { model: PricingModel }) {
  171. const { t } = useTranslation()
  172. const metricsQuery = useQuery({
  173. queryKey: ['perf-metrics', props.model.model_name],
  174. queryFn: () => getPerfMetrics(props.model.model_name, 24),
  175. staleTime: 60 * 1000,
  176. })
  177. const groups = metricsQuery.data?.data.groups ?? []
  178. const successRates = groups
  179. .map((group) => group.success_rate)
  180. .filter((rate) => Number.isFinite(rate))
  181. const successRate =
  182. successRates.length > 0
  183. ? successRates.reduce((sum, rate) => sum + rate, 0) / successRates.length
  184. : Number.NaN
  185. let successIntent: 'default' | 'warning' | 'success' = 'warning'
  186. if (successRate >= 99.9) {
  187. successIntent = 'success'
  188. } else if (successRate >= 99) {
  189. successIntent = 'default'
  190. }
  191. const tpsValues = groups
  192. .map((group) => group.avg_tps)
  193. .filter((value) => value > 0)
  194. const avgTps =
  195. tpsValues.length > 0
  196. ? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
  197. : 0
  198. const latencyValues = groups
  199. .map((group) => group.avg_latency_ms)
  200. .filter((value) => value > 0)
  201. const avgLatency =
  202. latencyValues.length > 0
  203. ? Math.round(
  204. latencyValues.reduce((sum, value) => sum + value, 0) /
  205. latencyValues.length
  206. )
  207. : 0
  208. return (
  209. <div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
  210. <OverviewMetric
  211. icon={Timer}
  212. label='TPS'
  213. value={formatThroughput(avgTps)}
  214. />
  215. <OverviewMetric
  216. icon={Timer}
  217. label={t('Average latency')}
  218. value={formatLatency(avgLatency)}
  219. />
  220. <OverviewMetric
  221. icon={HeartPulse}
  222. label={t('Success rate')}
  223. value={formatUptimePct(successRate)}
  224. intent={successIntent}
  225. />
  226. </div>
  227. )
  228. }
  229. // ----------------------------------------------------------------------------
  230. // Model header (always visible above the detail sections)
  231. // ----------------------------------------------------------------------------
  232. function ModelHeader(props: { model: PricingModel }) {
  233. const { t } = useTranslation()
  234. const model = props.model
  235. const vendorIcon = model.vendor_icon
  236. ? getLobeIcon(model.vendor_icon, 20)
  237. : null
  238. const description = model.description || model.vendor_description || null
  239. const tags = parseTags(model.tags)
  240. const isSpecialExpression =
  241. model.billing_mode === 'tiered_expr' &&
  242. Boolean(model.billing_expr) &&
  243. getDynamicPricingTiers(model).length === 0
  244. return (
  245. <header className='pb-4'>
  246. <div className='flex items-center gap-2.5'>
  247. {vendorIcon}
  248. <h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
  249. {model.model_name}
  250. </h1>
  251. <CopyButton
  252. value={model.model_name || ''}
  253. className='size-6'
  254. iconClassName='size-3'
  255. tooltip={t('Copy model name')}
  256. successTooltip={t('Copied!')}
  257. aria-label={t('Copy model name')}
  258. />
  259. </div>
  260. <div className='mt-1 flex flex-wrap items-center gap-1.5 text-xs'>
  261. {model.vendor_name && (
  262. <span className='text-muted-foreground'>{model.vendor_name}</span>
  263. )}
  264. <span className='text-muted-foreground/30'>·</span>
  265. <span className='text-muted-foreground/70'>
  266. {model.quota_type === QUOTA_TYPE_VALUES.TOKEN
  267. ? t('Token-based')
  268. : t('Per Request')}
  269. </span>
  270. {model.billing_mode === 'tiered_expr' && model.billing_expr && (
  271. <>
  272. <span className='text-muted-foreground/30'>·</span>
  273. <span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
  274. {isSpecialExpression
  275. ? t('Special billing expression')
  276. : t('Dynamic Pricing')}
  277. </span>
  278. </>
  279. )}
  280. </div>
  281. {description && (
  282. <p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
  283. {description}
  284. </p>
  285. )}
  286. {tags.length > 0 && (
  287. <div className='mt-2.5 flex flex-wrap gap-1'>
  288. {tags.map((tag) => (
  289. <span
  290. key={tag}
  291. className='bg-muted text-muted-foreground rounded px-2 py-0.5 text-[11px] font-medium'
  292. >
  293. {tag}
  294. </span>
  295. ))}
  296. </div>
  297. )}
  298. </header>
  299. )
  300. }
  301. // ----------------------------------------------------------------------------
  302. // Base price card (used in the Overview tab)
  303. // ----------------------------------------------------------------------------
  304. function PriceSection(props: {
  305. model: PricingModel
  306. priceRate: number
  307. usdExchangeRate: number
  308. tokenUnit: TokenUnit
  309. showRechargePrice: boolean
  310. }) {
  311. const { t } = useTranslation()
  312. const isTokenBased = isTokenBasedModel(props.model)
  313. const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
  314. const baseGroupKey = '_base'
  315. const baseGroupRatioMap = { [baseGroupKey]: 1 }
  316. const dynamicSummary = getDynamicPricingSummary(props.model, {
  317. tokenUnit: props.tokenUnit,
  318. showRechargePrice: props.showRechargePrice,
  319. priceRate: props.priceRate,
  320. usdExchangeRate: props.usdExchangeRate,
  321. groupRatioMultiplier: 1,
  322. })
  323. const primaryPriceTypes: { label: string; type: PriceType }[] = [
  324. { label: t('Input'), type: 'input' },
  325. { label: t('Output'), type: 'output' },
  326. ]
  327. const secondaryPriceTypes: {
  328. label: string
  329. type: PriceType
  330. available: boolean
  331. }[] = [
  332. {
  333. label: t('Cached input'),
  334. type: 'cache',
  335. available: props.model.cache_ratio != null,
  336. },
  337. {
  338. label: t('Cache write'),
  339. type: 'create_cache',
  340. available: props.model.create_cache_ratio != null,
  341. },
  342. {
  343. label: t('Image input'),
  344. type: 'image',
  345. available: props.model.image_ratio != null,
  346. },
  347. {
  348. label: t('Audio input'),
  349. type: 'audio_input',
  350. available: props.model.audio_ratio != null,
  351. },
  352. {
  353. label: t('Audio output'),
  354. type: 'audio_output',
  355. available:
  356. props.model.audio_ratio != null &&
  357. props.model.audio_completion_ratio != null,
  358. },
  359. ]
  360. if (dynamicSummary) {
  361. if (dynamicSummary.isSpecialExpression) {
  362. return (
  363. <section>
  364. <SectionTitle>{t('Base Price')}</SectionTitle>
  365. <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'>
  366. <div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
  367. {t('Special billing expression')}
  368. </div>
  369. <p className='text-muted-foreground mt-1 text-xs'>
  370. {t('Unable to parse structured pricing')}
  371. </p>
  372. <div className='mt-3'>
  373. <div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
  374. {t('Raw expression')}
  375. </div>
  376. <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'>
  377. {dynamicSummary.rawExpression}
  378. </code>
  379. </div>
  380. </div>
  381. </section>
  382. )
  383. }
  384. return (
  385. <section>
  386. <SectionTitle>{t('Base Price')}</SectionTitle>
  387. {dynamicSummary.primaryEntries.length > 0 ? (
  388. <div className='grid grid-cols-2 gap-2'>
  389. {dynamicSummary.primaryEntries.map((entry) => (
  390. <div
  391. key={entry.key}
  392. className='bg-muted/20 rounded-lg border p-3'
  393. >
  394. <div className='text-muted-foreground text-xs'>
  395. {t(entry.shortLabel)}
  396. </div>
  397. <div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
  398. {entry.formatted}
  399. <span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
  400. / {tokenUnitLabel}
  401. </span>
  402. </div>
  403. </div>
  404. ))}
  405. </div>
  406. ) : (
  407. <p className='text-muted-foreground text-sm'>
  408. {t('Dynamic Pricing')}
  409. </p>
  410. )}
  411. {dynamicSummary.secondaryEntries.length > 0 && (
  412. <div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
  413. <div className='space-y-1.5'>
  414. {dynamicSummary.secondaryEntries.map((entry) => (
  415. <div
  416. key={entry.key}
  417. className='flex items-baseline justify-between gap-4'
  418. >
  419. <span className='text-muted-foreground/70 text-sm'>
  420. {t(entry.shortLabel)}
  421. </span>
  422. <span className='text-muted-foreground font-mono text-sm tabular-nums'>
  423. {entry.formatted}
  424. <span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
  425. / {tokenUnitLabel}
  426. </span>
  427. </span>
  428. </div>
  429. ))}
  430. </div>
  431. </div>
  432. )}
  433. </section>
  434. )
  435. }
  436. if (!isTokenBased) {
  437. return (
  438. <section>
  439. <SectionTitle>{t('Base Price')}</SectionTitle>
  440. <div className='flex items-baseline justify-between'>
  441. <span className='text-muted-foreground text-sm'>
  442. {t('Per request')}
  443. </span>
  444. <span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
  445. {formatFixedPrice(
  446. props.model,
  447. baseGroupKey,
  448. props.showRechargePrice,
  449. props.priceRate,
  450. props.usdExchangeRate,
  451. baseGroupRatioMap
  452. )}
  453. </span>
  454. </div>
  455. </section>
  456. )
  457. }
  458. const secondaryItems = secondaryPriceTypes.filter((p) => p.available)
  459. const renderPrice = (type: PriceType) => (
  460. <>
  461. {formatGroupPrice(
  462. props.model,
  463. baseGroupKey,
  464. type,
  465. props.tokenUnit,
  466. props.showRechargePrice,
  467. props.priceRate,
  468. props.usdExchangeRate,
  469. baseGroupRatioMap
  470. )}
  471. <span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
  472. / {tokenUnitLabel}
  473. </span>
  474. </>
  475. )
  476. return (
  477. <section>
  478. <SectionTitle>{t('Base Price')}</SectionTitle>
  479. <div className='grid grid-cols-2 gap-2'>
  480. {primaryPriceTypes.map((item) => (
  481. <div key={item.type} className='bg-muted/20 rounded-lg border p-3'>
  482. <div className='text-muted-foreground text-xs'>{item.label}</div>
  483. <div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
  484. {renderPrice(item.type)}
  485. </div>
  486. </div>
  487. ))}
  488. </div>
  489. {secondaryItems.length > 0 && (
  490. <div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
  491. <div className='space-y-1.5'>
  492. {secondaryItems.map((item) => (
  493. <div
  494. key={item.type}
  495. className='flex items-baseline justify-between gap-4'
  496. >
  497. <span className='text-muted-foreground/70 text-sm'>
  498. {item.label}
  499. </span>
  500. <span className='text-muted-foreground font-mono text-sm tabular-nums'>
  501. {renderPrice(item.type)}
  502. </span>
  503. </div>
  504. ))}
  505. </div>
  506. </div>
  507. )}
  508. </section>
  509. )
  510. }
  511. // ----------------------------------------------------------------------------
  512. // Auto group chain (used inside group pricing section)
  513. // ----------------------------------------------------------------------------
  514. function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
  515. const { t } = useTranslation()
  516. const modelEnableGroups = Array.isArray(props.model.enable_groups)
  517. ? props.model.enable_groups
  518. : []
  519. const autoChain = props.autoGroups.filter((g) =>
  520. modelEnableGroups.includes(g)
  521. )
  522. if (autoChain.length === 0) return null
  523. return (
  524. <div className='text-muted-foreground mb-3 flex flex-wrap items-center gap-1 text-xs'>
  525. <span className='font-medium'>{t('Auto Group Chain')}</span>
  526. <span className='text-muted-foreground/40'>→</span>
  527. {autoChain.map((g, idx) => (
  528. <span key={g} className='flex items-center gap-1'>
  529. <GroupBadge group={g} size='sm' />
  530. {idx < autoChain.length - 1 && (
  531. <span className='text-muted-foreground/40'>→</span>
  532. )}
  533. </span>
  534. ))}
  535. </div>
  536. )
  537. }
  538. // ----------------------------------------------------------------------------
  539. // Group pricing table
  540. // ----------------------------------------------------------------------------
  541. function GroupPricingSection(props: {
  542. model: PricingModel
  543. groupRatio: Record<string, number>
  544. usableGroup: Record<string, { desc: string; ratio: number }>
  545. autoGroups: string[]
  546. priceRate: number
  547. usdExchangeRate: number
  548. tokenUnit: TokenUnit
  549. showRechargePrice?: boolean
  550. }) {
  551. const { t } = useTranslation()
  552. const showRechargePrice = props.showRechargePrice ?? false
  553. const availableGroups = useMemo(
  554. () => getAvailableGroups(props.model, props.usableGroup || {}),
  555. [props.model, props.usableGroup]
  556. )
  557. const isTokenBased = isTokenBasedModel(props.model)
  558. const tokenUnitLabel = props.tokenUnit === 'K' ? '1K' : '1M'
  559. const extraPriceTypes = useMemo(() => {
  560. const types: { label: string; type: PriceType }[] = []
  561. if (props.model.cache_ratio != null)
  562. types.push({ label: t('Cache'), type: 'cache' })
  563. if (props.model.create_cache_ratio != null)
  564. types.push({ label: t('Cache Write'), type: 'create_cache' })
  565. if (props.model.image_ratio != null)
  566. types.push({ label: t('Image'), type: 'image' })
  567. if (props.model.audio_ratio != null)
  568. types.push({ label: t('Audio In'), type: 'audio_input' })
  569. if (
  570. props.model.audio_ratio != null &&
  571. props.model.audio_completion_ratio != null
  572. )
  573. types.push({ label: t('Audio Out'), type: 'audio_output' })
  574. return types
  575. }, [props.model, t])
  576. if (availableGroups.length === 0) {
  577. return (
  578. <section>
  579. <SectionTitle>{t('Pricing by Group')}</SectionTitle>
  580. <AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
  581. <p className='text-muted-foreground text-sm'>
  582. {t(
  583. 'This model is not available in any group, or no group pricing information is configured.'
  584. )}
  585. </p>
  586. </section>
  587. )
  588. }
  589. const thClass =
  590. 'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
  591. if (isDynamicPricingModel(props.model)) {
  592. const dynamicTiers = getDynamicPricingTiers(props.model)
  593. if (dynamicTiers.length === 0) {
  594. return (
  595. <section>
  596. <SectionTitle>{t('Pricing by Group')}</SectionTitle>
  597. <AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
  598. <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'>
  599. <div className='text-sm font-medium text-amber-800 dark:text-amber-200'>
  600. {t('Special billing expression')}
  601. </div>
  602. <p className='text-muted-foreground mt-1 text-xs'>
  603. {t(
  604. 'Group prices cannot be expanded because this expression is not a standard tiered pricing expression.'
  605. )}
  606. </p>
  607. <div className='mt-3'>
  608. <div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
  609. {t('Raw expression')}
  610. </div>
  611. <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'>
  612. {props.model.billing_expr}
  613. </code>
  614. </div>
  615. </div>
  616. </section>
  617. )
  618. }
  619. const priceFields = Array.from(
  620. new Map(
  621. dynamicTiers
  622. .flatMap((tier) =>
  623. getDynamicPriceEntries(tier, {
  624. tokenUnit: props.tokenUnit,
  625. showRechargePrice,
  626. priceRate: props.priceRate,
  627. usdExchangeRate: props.usdExchangeRate,
  628. groupRatioMultiplier: 1,
  629. })
  630. )
  631. .map((entry) => [entry.field, entry])
  632. ).values()
  633. )
  634. return (
  635. <section>
  636. <SectionTitle>{t('Pricing by Group')}</SectionTitle>
  637. <AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
  638. <div className='space-y-3'>
  639. {availableGroups.map((group) => {
  640. const ratio = props.groupRatio[group] || 1
  641. return (
  642. <div key={group} className='overflow-hidden rounded-lg border'>
  643. <div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
  644. <GroupBadge group={group} size='sm' />
  645. <span className='text-muted-foreground font-mono text-xs'>
  646. {ratio}x
  647. </span>
  648. </div>
  649. <div className='overflow-x-auto'>
  650. <Table className='text-sm'>
  651. <TableHeader>
  652. <TableRow className='hover:bg-transparent'>
  653. <TableHead className={thClass}>{t('Tier')}</TableHead>
  654. {priceFields.map((entry) => (
  655. <TableHead
  656. key={entry.field}
  657. className={`${thClass} text-right`}
  658. >
  659. {t(entry.shortLabel)}
  660. </TableHead>
  661. ))}
  662. </TableRow>
  663. </TableHeader>
  664. <TableBody>
  665. {dynamicTiers.map((tier, tierIndex) => {
  666. const entries = getDynamicPriceEntries(tier, {
  667. tokenUnit: props.tokenUnit,
  668. showRechargePrice,
  669. priceRate: props.priceRate,
  670. usdExchangeRate: props.usdExchangeRate,
  671. groupRatioMultiplier: ratio,
  672. })
  673. const entryMap = new Map(
  674. entries.map((entry) => [entry.field, entry])
  675. )
  676. return (
  677. <TableRow key={`${group}-${tier.label || tierIndex}`}>
  678. <TableCell className='text-muted-foreground py-2.5 text-xs'>
  679. {tier.label || t('Default')}
  680. </TableCell>
  681. {priceFields.map((fieldEntry) => {
  682. const entry = entryMap.get(fieldEntry.field)
  683. return (
  684. <TableCell
  685. key={fieldEntry.field}
  686. className='py-2.5 text-right font-mono'
  687. >
  688. {entry?.formatted ?? '-'}
  689. </TableCell>
  690. )
  691. })}
  692. </TableRow>
  693. )
  694. })}
  695. </TableBody>
  696. </Table>
  697. </div>
  698. </div>
  699. )
  700. })}
  701. <p className='text-muted-foreground/40 mt-1.5 text-[10px]'>
  702. {t('Prices shown per')} {tokenUnitLabel} tokens
  703. </p>
  704. </div>
  705. </section>
  706. )
  707. }
  708. return (
  709. <section>
  710. <SectionTitle>{t('Pricing by Group')}</SectionTitle>
  711. <AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
  712. <div className='-mx-4 overflow-x-auto sm:mx-0'>
  713. <Table className='text-sm'>
  714. <TableHeader>
  715. <TableRow className='hover:bg-transparent'>
  716. <TableHead className={thClass}>{t('Group')}</TableHead>
  717. <TableHead className={thClass}>{t('Ratio')}</TableHead>
  718. {isTokenBased ? (
  719. <>
  720. <TableHead className={`${thClass} text-right`}>
  721. {t('Input')}
  722. </TableHead>
  723. <TableHead className={`${thClass} text-right`}>
  724. {t('Output')}
  725. </TableHead>
  726. {extraPriceTypes.map((ep) => (
  727. <TableHead
  728. key={ep.type}
  729. className={`${thClass} text-right`}
  730. >
  731. {ep.label}
  732. </TableHead>
  733. ))}
  734. </>
  735. ) : (
  736. <TableHead className={`${thClass} text-right`}>
  737. {t('Price')}
  738. </TableHead>
  739. )}
  740. </TableRow>
  741. </TableHeader>
  742. <TableBody>
  743. {availableGroups.map((group) => {
  744. const ratio = props.groupRatio[group] || 1
  745. return (
  746. <TableRow key={group}>
  747. <TableCell className='py-2.5'>
  748. <GroupBadge group={group} size='sm' />
  749. </TableCell>
  750. <TableCell className='text-muted-foreground py-2.5 font-mono text-xs'>
  751. {ratio}x
  752. </TableCell>
  753. {isTokenBased ? (
  754. <>
  755. <TableCell className='py-2.5 text-right font-mono'>
  756. {formatGroupPrice(
  757. props.model,
  758. group,
  759. 'input',
  760. props.tokenUnit,
  761. showRechargePrice,
  762. props.priceRate,
  763. props.usdExchangeRate,
  764. props.groupRatio
  765. )}
  766. </TableCell>
  767. <TableCell className='py-2.5 text-right font-mono'>
  768. {formatGroupPrice(
  769. props.model,
  770. group,
  771. 'output',
  772. props.tokenUnit,
  773. showRechargePrice,
  774. props.priceRate,
  775. props.usdExchangeRate,
  776. props.groupRatio
  777. )}
  778. </TableCell>
  779. {extraPriceTypes.map((ep) => (
  780. <TableCell
  781. key={ep.type}
  782. className='py-2.5 text-right font-mono'
  783. >
  784. {formatGroupPrice(
  785. props.model,
  786. group,
  787. ep.type,
  788. props.tokenUnit,
  789. showRechargePrice,
  790. props.priceRate,
  791. props.usdExchangeRate,
  792. props.groupRatio
  793. )}
  794. </TableCell>
  795. ))}
  796. </>
  797. ) : (
  798. <TableCell className='py-2.5 text-right font-mono'>
  799. {formatFixedPrice(
  800. props.model,
  801. group,
  802. showRechargePrice,
  803. props.priceRate,
  804. props.usdExchangeRate,
  805. props.groupRatio
  806. )}
  807. </TableCell>
  808. )}
  809. </TableRow>
  810. )
  811. })}
  812. </TableBody>
  813. </Table>
  814. {isTokenBased && (
  815. <p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
  816. {t('Prices shown per')} {tokenUnitLabel} tokens
  817. </p>
  818. )}
  819. </div>
  820. </section>
  821. )
  822. }
  823. const TAB_VALUES = ['overview', 'performance', 'api'] as const
  824. type TabValue = (typeof TAB_VALUES)[number]
  825. const TAB_META: Record<
  826. TabValue,
  827. { icon: React.ComponentType<{ className?: string }>; labelKey: string }
  828. > = {
  829. overview: { icon: Info, labelKey: 'Overview' },
  830. performance: { icon: HeartPulse, labelKey: 'Performance' },
  831. api: { icon: Code2, labelKey: 'API' },
  832. }
  833. export interface ModelDetailsContentProps {
  834. model: PricingModel
  835. groupRatio: Record<string, number>
  836. usableGroup: Record<string, { desc: string; ratio: number }>
  837. endpointMap: Record<string, { path?: string; method?: string }>
  838. autoGroups: string[]
  839. priceRate: number
  840. usdExchangeRate: number
  841. tokenUnit: TokenUnit
  842. showRechargePrice?: boolean
  843. }
  844. export function ModelDetailsContent(props: ModelDetailsContentProps) {
  845. const { t } = useTranslation()
  846. const showRechargePrice = props.showRechargePrice ?? false
  847. const metadata = useMemo(() => inferModelMetadata(props.model), [props.model])
  848. const isDynamic =
  849. props.model.billing_mode === 'tiered_expr' &&
  850. Boolean(props.model.billing_expr)
  851. return (
  852. <div className='@container/details space-y-4'>
  853. <ModelHeader model={props.model} />
  854. <Tabs defaultValue='overview' className='gap-4'>
  855. <TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
  856. {TAB_VALUES.map((value) => {
  857. const Icon = TAB_META[value].icon
  858. return (
  859. <TabsTrigger
  860. key={value}
  861. value={value}
  862. className='h-8 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
  863. >
  864. <Icon className='size-3.5' />
  865. <span>{t(TAB_META[value].labelKey)}</span>
  866. </TabsTrigger>
  867. )
  868. })}
  869. </TabsList>
  870. <TabsContent value='overview' className='space-y-6 outline-none'>
  871. <OverviewSummaryGrid model={props.model} />
  872. <section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
  873. <SectionTitle>{t('Pricing')}</SectionTitle>
  874. <PriceSection
  875. model={props.model}
  876. priceRate={props.priceRate}
  877. usdExchangeRate={props.usdExchangeRate}
  878. tokenUnit={props.tokenUnit}
  879. showRechargePrice={showRechargePrice}
  880. />
  881. {isDynamic && (
  882. <DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
  883. )}
  884. <GroupPricingSection
  885. model={props.model}
  886. groupRatio={props.groupRatio}
  887. usableGroup={props.usableGroup}
  888. autoGroups={props.autoGroups}
  889. priceRate={props.priceRate}
  890. usdExchangeRate={props.usdExchangeRate}
  891. tokenUnit={props.tokenUnit}
  892. showRechargePrice={showRechargePrice}
  893. />
  894. </section>
  895. <ModelDetailsQuickStats metadata={metadata} />
  896. <ModelSignalsSection
  897. capabilities={metadata.capabilities}
  898. input={metadata.input_modalities}
  899. output={metadata.output_modalities}
  900. />
  901. <ModelDetailsProviderInfo model={props.model} />
  902. </TabsContent>
  903. <TabsContent value='performance' className='outline-none'>
  904. <ModelDetailsPerformance model={props.model} />
  905. </TabsContent>
  906. <TabsContent value='api' className='outline-none'>
  907. <ModelDetailsApi
  908. model={props.model}
  909. endpointMap={props.endpointMap}
  910. />
  911. </TabsContent>
  912. </Tabs>
  913. </div>
  914. )
  915. }
  916. // ----------------------------------------------------------------------------
  917. // Drawer & page wrappers
  918. // ----------------------------------------------------------------------------
  919. export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
  920. open: boolean
  921. onOpenChange: (open: boolean) => void
  922. }
  923. export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
  924. const { t } = useTranslation()
  925. const { open, onOpenChange, ...contentProps } = props
  926. return (
  927. <Sheet open={open} onOpenChange={onOpenChange}>
  928. <SheetContent
  929. side='right'
  930. 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'
  931. >
  932. <SheetHeader className='sr-only'>
  933. <SheetTitle>{props.model.model_name}</SheetTitle>
  934. <SheetDescription>{t('Model details')}</SheetDescription>
  935. </SheetHeader>
  936. <div className='flex-1 overflow-y-auto px-4 pt-11 pb-5 sm:px-6 sm:pt-12 sm:pb-6'>
  937. <ModelDetailsContent {...contentProps} />
  938. </div>
  939. </SheetContent>
  940. </Sheet>
  941. )
  942. }
  943. export function ModelDetails() {
  944. const { t } = useTranslation()
  945. const { modelId } = useParams({ from: '/pricing/$modelId/' })
  946. const search = useSearch({ from: '/pricing/$modelId/' })
  947. const navigate = useNavigate()
  948. const {
  949. models,
  950. groupRatio,
  951. usableGroup,
  952. endpointMap,
  953. autoGroups,
  954. isLoading,
  955. priceRate,
  956. usdExchangeRate,
  957. } = usePricingData()
  958. const tokenUnit: TokenUnit =
  959. search.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT
  960. const model = useMemo(() => {
  961. if (!models || !modelId) return null
  962. return models.find((m) => m.model_name === modelId) || null
  963. }, [models, modelId])
  964. const handleBack = () => {
  965. navigate({ to: '/pricing', search })
  966. }
  967. if (isLoading) {
  968. return (
  969. <PublicLayout>
  970. <div className='mx-auto max-w-5xl px-4 sm:px-6'>
  971. <Skeleton className='mb-4 h-5 w-16' />
  972. <div className='space-y-2'>
  973. <Skeleton className='h-7 w-64' />
  974. <Skeleton className='h-4 w-40' />
  975. <Skeleton className='h-4 w-full max-w-md' />
  976. </div>
  977. <div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
  978. {Array.from({ length: 4 }).map((_, i) => (
  979. <Skeleton key={i} className='h-16 w-full' />
  980. ))}
  981. </div>
  982. <div className='mt-6 space-y-3'>
  983. {Array.from({ length: 4 }).map((_, i) => (
  984. <Skeleton key={i} className='h-24 w-full' />
  985. ))}
  986. </div>
  987. </div>
  988. </PublicLayout>
  989. )
  990. }
  991. if (!model) {
  992. return (
  993. <PublicLayout>
  994. <div className='mx-auto max-w-2xl px-4 text-center sm:px-6'>
  995. <h2 className='mb-1 text-base font-semibold'>
  996. {t('Model not found')}
  997. </h2>
  998. <p className='text-muted-foreground mb-4 text-sm'>
  999. {t("The model you're looking for doesn't exist.")}
  1000. </p>
  1001. <Button onClick={handleBack} variant='outline' size='sm'>
  1002. {t('Back to Models')}
  1003. </Button>
  1004. </div>
  1005. </PublicLayout>
  1006. )
  1007. }
  1008. return (
  1009. <PublicLayout>
  1010. <div className='mx-auto max-w-5xl px-4 sm:px-6'>
  1011. <Button
  1012. variant='ghost'
  1013. size='sm'
  1014. onClick={handleBack}
  1015. className='text-muted-foreground hover:text-foreground mb-4 h-auto gap-1 px-0 py-1 text-xs'
  1016. >
  1017. <ArrowLeft className='size-3.5' />
  1018. {t('Back')}
  1019. </Button>
  1020. <ModelDetailsContent
  1021. model={model}
  1022. groupRatio={groupRatio || {}}
  1023. usableGroup={usableGroup || {}}
  1024. autoGroups={autoGroups || []}
  1025. priceRate={priceRate ?? 1}
  1026. usdExchangeRate={usdExchangeRate ?? 1}
  1027. tokenUnit={tokenUnit}
  1028. showRechargePrice={search.rechargePrice ?? false}
  1029. endpointMap={
  1030. (endpointMap as Record<
  1031. string,
  1032. { path?: string; method?: string }
  1033. >) || {}
  1034. }
  1035. />
  1036. </div>
  1037. </PublicLayout>
  1038. )
  1039. }