feat(ui): redesign model square pricing page
This commit is contained in:
+397
-125
@@ -1,51 +1,77 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ModelConfig {
|
||||
interface ApiDemoConfig {
|
||||
id: string
|
||||
name: string
|
||||
label: string
|
||||
endpoint: string
|
||||
requestBodyLines: string[]
|
||||
responseKind: 'chat' | 'responses' | 'claude' | 'gemini'
|
||||
response: string
|
||||
tokens: number
|
||||
latency: number
|
||||
badgeClass: string
|
||||
}
|
||||
|
||||
const MODELS: ModelConfig[] = [
|
||||
const API_DEMOS: ApiDemoConfig[] = [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
name: 'gpt-4o',
|
||||
response:
|
||||
'Artificial intelligence models can be seamlessly accessed through a unified API gateway, enabling developers to switch between providers effortlessly.',
|
||||
id: 'gpt-chat',
|
||||
label: 'GPT Chat',
|
||||
endpoint: '/v1/chat/completions',
|
||||
requestBodyLines: [
|
||||
'"model": "your-model",',
|
||||
'"messages": [',
|
||||
' { "role": "user", "content": "..." }',
|
||||
']',
|
||||
],
|
||||
responseKind: 'chat',
|
||||
response: 'Route chat requests through configured upstreams.',
|
||||
tokens: 27,
|
||||
latency: 142,
|
||||
badgeClass:
|
||||
'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:bg-emerald-500/15 dark:text-emerald-400 dark:ring-emerald-500/25',
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet',
|
||||
name: 'claude-sonnet-4-20250514',
|
||||
response:
|
||||
'A unified gateway abstracts away provider differences, letting you focus on building great products while we handle routing, failover, and cost optimization.',
|
||||
id: 'responses',
|
||||
label: 'Responses',
|
||||
endpoint: '/v1/responses',
|
||||
requestBodyLines: ['"model": "your-model",', '"input": "..."'],
|
||||
responseKind: 'responses',
|
||||
response: 'Run response workflows behind one gateway.',
|
||||
tokens: 31,
|
||||
latency: 168,
|
||||
badgeClass:
|
||||
'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:bg-amber-500/15 dark:text-amber-400 dark:ring-amber-500/25',
|
||||
},
|
||||
{
|
||||
id: 'gemini-pro',
|
||||
name: 'gemini-2.5-pro',
|
||||
response:
|
||||
'By consolidating multiple AI providers behind one endpoint, teams can reduce integration complexity and gain unified observability across all model usage.',
|
||||
id: 'claude',
|
||||
label: 'Claude',
|
||||
endpoint: '/v1/messages',
|
||||
requestBodyLines: [
|
||||
'"model": "your-model",',
|
||||
'"max_tokens": 1024,',
|
||||
'"messages": [',
|
||||
' { "role": "user", "content": "..." }',
|
||||
']',
|
||||
],
|
||||
responseKind: 'claude',
|
||||
response: 'Send Claude-style messages through your gateway.',
|
||||
tokens: 29,
|
||||
latency: 156,
|
||||
badgeClass:
|
||||
'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:bg-blue-500/15 dark:text-blue-400 dark:ring-blue-500/25',
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'deepseek-chat',
|
||||
response:
|
||||
'An API gateway provides automatic load balancing, rate limiting, and cost tracking — essential infrastructure for production AI applications at scale.',
|
||||
id: 'gemini',
|
||||
label: 'Gemini',
|
||||
endpoint: '/v1beta/models/{model}:generateContent',
|
||||
requestBodyLines: [
|
||||
'"contents": [',
|
||||
' { "parts": [{ "text": "..." }] }',
|
||||
']',
|
||||
],
|
||||
responseKind: 'gemini',
|
||||
response: 'Serve Gemini-compatible generation requests.',
|
||||
tokens: 25,
|
||||
latency: 93,
|
||||
badgeClass:
|
||||
@@ -67,7 +93,7 @@ export function HeroTerminalDemo() {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % MODELS.length)
|
||||
setActiveIndex((prev) => (prev + 1) % API_DEMOS.length)
|
||||
setTransitioning(false)
|
||||
}, 300)
|
||||
}, CYCLE_INTERVAL)
|
||||
@@ -75,7 +101,7 @@ export function HeroTerminalDemo() {
|
||||
return () => clearInterval(intervalRef.current)
|
||||
}, [])
|
||||
|
||||
const model = MODELS[activeIndex]
|
||||
const demo = API_DEMOS[activeIndex]
|
||||
|
||||
return (
|
||||
<div className='mx-auto mt-16 w-full max-w-2xl'>
|
||||
@@ -101,7 +127,7 @@ export function HeroTerminalDemo() {
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ModelSelector
|
||||
models={MODELS}
|
||||
demos={API_DEMOS}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={(i) => {
|
||||
clearInterval(intervalRef.current)
|
||||
@@ -134,40 +160,7 @@ export function HeroTerminalDemo() {
|
||||
Request
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-foreground/80'>
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>
|
||||
curl
|
||||
</span>{' '}
|
||||
<span className='text-blue-600 dark:text-blue-400'>-X POST</span>{' '}
|
||||
<span className='text-amber-600 dark:text-amber-400'>
|
||||
"/v1/chat/completions"
|
||||
</span>
|
||||
{' \\\n'}
|
||||
<span className='text-foreground/15'>{' '}</span>
|
||||
<span className='text-blue-600 dark:text-blue-400'>-H</span>{' '}
|
||||
<span className='text-amber-600 dark:text-amber-400'>
|
||||
"Authorization: Bearer sk-••••"
|
||||
</span>
|
||||
{' \\\n'}
|
||||
<span className='text-foreground/15'>{' '}</span>
|
||||
<span className='text-blue-600 dark:text-blue-400'>-d</span>{' '}
|
||||
<span className='text-amber-600 dark:text-amber-400'>
|
||||
{'\'{"model": "'}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
transitioning
|
||||
? 'text-foreground/20'
|
||||
: 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{model.name}
|
||||
</span>
|
||||
<span className='text-amber-600 dark:text-amber-400'>
|
||||
{'", "messages": [...]}\''}
|
||||
</span>
|
||||
</div>
|
||||
<RequestPreview demo={demo} transitioning={transitioning} />
|
||||
</div>
|
||||
|
||||
{/* Response */}
|
||||
@@ -183,7 +176,7 @@ export function HeroTerminalDemo() {
|
||||
transitioning ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{model.latency}ms
|
||||
{demo.latency}ms
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -192,70 +185,11 @@ export function HeroTerminalDemo() {
|
||||
transitioning ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<span>{model.tokens} tokens</span>
|
||||
<span>${(model.tokens * 0.00003).toFixed(5)}</span>
|
||||
<span>{demo.tokens} tokens</span>
|
||||
<span>${(demo.tokens * 0.00003).toFixed(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border px-3.5 py-3',
|
||||
'border-border/40 bg-muted/30',
|
||||
'dark:border-white/[0.06] dark:bg-white/[0.02]'
|
||||
)}
|
||||
>
|
||||
<div className='text-foreground/35'>{'{'}</div>
|
||||
<div className='pl-4'>
|
||||
<span className='text-blue-600 dark:text-blue-400'>
|
||||
"model"
|
||||
</span>
|
||||
<span className='text-foreground/25'>: </span>
|
||||
<span
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
transitioning
|
||||
? 'text-foreground/15'
|
||||
: 'text-amber-600 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
"{model.name}"
|
||||
</span>
|
||||
<span className='text-foreground/25'>,</span>
|
||||
</div>
|
||||
<div className='pl-4'>
|
||||
<span className='text-blue-600 dark:text-blue-400'>
|
||||
"content"
|
||||
</span>
|
||||
<span className='text-foreground/25'>: </span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-emerald-600 transition-all duration-300 dark:text-emerald-400',
|
||||
transitioning ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
"{model.response}"
|
||||
</span>
|
||||
</div>
|
||||
<div className='pl-4'>
|
||||
<span className='text-blue-600 dark:text-blue-400'>
|
||||
"usage"
|
||||
</span>
|
||||
<span className='text-foreground/25'>: {'{'} </span>
|
||||
<span className='text-blue-600 dark:text-blue-400'>
|
||||
"total_tokens"
|
||||
</span>
|
||||
<span className='text-foreground/25'>: </span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-violet-600 transition-all duration-300 dark:text-violet-400',
|
||||
transitioning ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{model.tokens}
|
||||
</span>
|
||||
<span className='text-foreground/25'> {'}'}</span>
|
||||
</div>
|
||||
<div className='text-foreground/35'>{'}'}</div>
|
||||
</div>
|
||||
<ResponsePreview demo={demo} transitioning={transitioning} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,25 +197,363 @@ export function HeroTerminalDemo() {
|
||||
)
|
||||
}
|
||||
|
||||
function RequestPreview(props: {
|
||||
demo: ApiDemoConfig
|
||||
transitioning: boolean
|
||||
}) {
|
||||
const { demo, transitioning } = props
|
||||
|
||||
return (
|
||||
<div className='space-y-0.5 text-foreground/80'>
|
||||
<CodeLine>
|
||||
<Command>curl</Command> <Flag>-X POST</Flag>{' '}
|
||||
<AnimatedString transitioning={transitioning}>
|
||||
"{demo.endpoint}"
|
||||
</AnimatedString>{' '}
|
||||
<Muted>{'\\'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Flag>-H</Flag>{' '}
|
||||
<StringText>"Authorization: Bearer sk-••••"</StringText>{' '}
|
||||
<Muted>{'\\'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Flag>-d</Flag> <StringText>'{'{'}</StringText>
|
||||
</CodeLine>
|
||||
{demo.requestBodyLines.map((line) => (
|
||||
<CodeLine key={line} indent={4}>
|
||||
<AnimatedString transitioning={transitioning}>
|
||||
{line}
|
||||
</AnimatedString>
|
||||
</CodeLine>
|
||||
))}
|
||||
<CodeLine indent={2}>
|
||||
<StringText>{'}'}'</StringText>
|
||||
</CodeLine>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResponsePreview(props: {
|
||||
demo: ApiDemoConfig
|
||||
transitioning: boolean
|
||||
}) {
|
||||
const { demo, transitioning } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border px-3.5 py-3',
|
||||
'border-border/40 bg-muted/30',
|
||||
'dark:border-white/[0.06] dark:bg-white/[0.02]'
|
||||
)}
|
||||
>
|
||||
{demo.responseKind === 'chat' && (
|
||||
<>
|
||||
<CodeLine>
|
||||
<Muted>{'{'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Key>"choices"</Key>
|
||||
<Muted>: [</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={4}>
|
||||
<Muted>{'{'} </Muted>
|
||||
<Key>"message"</Key>
|
||||
<Muted>: {'{'} </Muted>
|
||||
<Key>"content"</Key>
|
||||
<Muted>: </Muted>
|
||||
<ResponseText demo={demo} transitioning={transitioning} />
|
||||
<Muted> {'}'} {'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Muted>],</Muted>
|
||||
</CodeLine>
|
||||
<UsageLine
|
||||
container='usage'
|
||||
name='total_tokens'
|
||||
value={demo.tokens}
|
||||
indent={2}
|
||||
/>
|
||||
<CodeLine>
|
||||
<Muted>{'}'}</Muted>
|
||||
</CodeLine>
|
||||
</>
|
||||
)}
|
||||
|
||||
{demo.responseKind === 'responses' && (
|
||||
<>
|
||||
<CodeLine>
|
||||
<Muted>{'{'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Key>"output"</Key>
|
||||
<Muted>: [</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={4}>
|
||||
<Muted>{'{'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={6}>
|
||||
<Key>"type"</Key>
|
||||
<Muted>: </Muted>
|
||||
<StringText>"message"</StringText>
|
||||
<Muted>,</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={6}>
|
||||
<Key>"content"</Key>
|
||||
<Muted>: [</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={8}>
|
||||
<Muted>{'{'} </Muted>
|
||||
<Key>"type"</Key>
|
||||
<Muted>: </Muted>
|
||||
<StringText>"output_text"</StringText>
|
||||
<Muted>, </Muted>
|
||||
<Key>"text"</Key>
|
||||
<Muted>: </Muted>
|
||||
<ResponseText demo={demo} transitioning={transitioning} />
|
||||
<Muted> {'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={6}>
|
||||
<Muted>]</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={4}>
|
||||
<Muted>{'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Muted>],</Muted>
|
||||
</CodeLine>
|
||||
<UsageLine
|
||||
container='usage'
|
||||
name='total_tokens'
|
||||
value={demo.tokens}
|
||||
indent={2}
|
||||
/>
|
||||
<CodeLine>
|
||||
<Muted>{'}'}</Muted>
|
||||
</CodeLine>
|
||||
</>
|
||||
)}
|
||||
|
||||
{demo.responseKind === 'claude' && (
|
||||
<>
|
||||
<CodeLine>
|
||||
<Muted>{'{'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Key>"content"</Key>
|
||||
<Muted>: [</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={4}>
|
||||
<Muted>{'{'} </Muted>
|
||||
<Key>"type"</Key>
|
||||
<Muted>: </Muted>
|
||||
<StringText>"text"</StringText>
|
||||
<Muted>, </Muted>
|
||||
<Key>"text"</Key>
|
||||
<Muted>: </Muted>
|
||||
<ResponseText demo={demo} transitioning={transitioning} />
|
||||
<Muted> {'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Muted>],</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Key>"usage"</Key>
|
||||
<Muted>: {'{'} </Muted>
|
||||
<Key>"input_tokens"</Key>
|
||||
<Muted>: </Muted>
|
||||
<NumberText>{Math.floor(demo.tokens * 0.4)}</NumberText>
|
||||
<Muted>, </Muted>
|
||||
<Key>"output_tokens"</Key>
|
||||
<Muted>: </Muted>
|
||||
<NumberText>{Math.ceil(demo.tokens * 0.6)}</NumberText>
|
||||
<Muted> {'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine>
|
||||
<Muted>{'}'}</Muted>
|
||||
</CodeLine>
|
||||
</>
|
||||
)}
|
||||
|
||||
{demo.responseKind === 'gemini' && (
|
||||
<>
|
||||
<CodeLine>
|
||||
<Muted>{'{'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Key>"candidates"</Key>
|
||||
<Muted>: [</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={4}>
|
||||
<Muted>{'{'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={6}>
|
||||
<Key>"content"</Key>
|
||||
<Muted>: {'{'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={8}>
|
||||
<Key>"parts"</Key>
|
||||
<Muted>: [</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={10}>
|
||||
<Muted>{'{'} </Muted>
|
||||
<Key>"text"</Key>
|
||||
<Muted>: </Muted>
|
||||
<ResponseText demo={demo} transitioning={transitioning} />
|
||||
<Muted> {'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={8}>
|
||||
<Muted>]</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={6}>
|
||||
<Muted>{'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={4}>
|
||||
<Muted>{'}'}</Muted>
|
||||
</CodeLine>
|
||||
<CodeLine indent={2}>
|
||||
<Muted>],</Muted>
|
||||
</CodeLine>
|
||||
<UsageLine
|
||||
container='usageMetadata'
|
||||
name='totalTokenCount'
|
||||
value={demo.tokens}
|
||||
indent={2}
|
||||
/>
|
||||
<CodeLine>
|
||||
<Muted>{'}'}</Muted>
|
||||
</CodeLine>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageLine(props: {
|
||||
container: string
|
||||
name: string
|
||||
value: number
|
||||
indent: number
|
||||
}) {
|
||||
return (
|
||||
<CodeLine indent={props.indent}>
|
||||
<Key>"{props.container}"</Key>
|
||||
<Muted>: {'{'} </Muted>
|
||||
<Key>"{props.name}"</Key>
|
||||
<Muted>: </Muted>
|
||||
<NumberText>{props.value}</NumberText>
|
||||
<Muted> {'}'}</Muted>
|
||||
</CodeLine>
|
||||
)
|
||||
}
|
||||
|
||||
function ResponseText(props: {
|
||||
demo: ApiDemoConfig
|
||||
transitioning: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-emerald-600 transition-all duration-300 dark:text-emerald-400',
|
||||
props.transitioning ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
"{props.demo.response}"
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeLine(props: { children: ReactNode; indent?: number }) {
|
||||
return (
|
||||
<div className='whitespace-pre-wrap break-words'>
|
||||
{props.indent ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className='inline-block'
|
||||
style={{ width: `${props.indent}ch` }}
|
||||
/>
|
||||
) : null}
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimatedString(props: {
|
||||
children: ReactNode
|
||||
transitioning: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
props.transitioning
|
||||
? 'text-foreground/20'
|
||||
: 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Command(props: { children: ReactNode }) {
|
||||
return (
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Flag(props: { children: ReactNode }) {
|
||||
return (
|
||||
<span className='text-blue-600 dark:text-blue-400'>{props.children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Key(props: { children: ReactNode }) {
|
||||
return (
|
||||
<span className='text-blue-600 dark:text-blue-400'>{props.children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StringText(props: { children: ReactNode }) {
|
||||
return (
|
||||
<span className='text-amber-600 dark:text-amber-400'>{props.children}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberText(props: { children: ReactNode }) {
|
||||
return (
|
||||
<span className='text-violet-600 dark:text-violet-400'>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Muted(props: { children: ReactNode }) {
|
||||
return <span className='text-foreground/35'>{props.children}</span>
|
||||
}
|
||||
|
||||
function ModelSelector(props: {
|
||||
models: ModelConfig[]
|
||||
demos: ApiDemoConfig[]
|
||||
activeIndex: number
|
||||
onSelect: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
{props.models.map((m, i) => (
|
||||
{props.demos.map((demo, i) => (
|
||||
<button
|
||||
key={m.id}
|
||||
key={demo.id}
|
||||
onClick={() => props.onSelect(i)}
|
||||
className={cn(
|
||||
'rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 transition-all duration-300 ring-inset',
|
||||
i === props.activeIndex
|
||||
? m.badgeClass
|
||||
? demo.badgeClass
|
||||
: 'text-foreground/20 ring-border/30 hover:text-foreground/40 hover:ring-border/50 dark:ring-white/[0.06] dark:hover:ring-white/10'
|
||||
)}
|
||||
>
|
||||
{m.id}
|
||||
{demo.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,9 @@ export function CTA(props: CTAProps) {
|
||||
</span>
|
||||
</h2>
|
||||
<p className='text-muted-foreground/80 mx-auto mt-5 max-w-md text-sm leading-relaxed md:text-base'>
|
||||
{t('Start for free with generous limits. No credit card required.')}
|
||||
{t(
|
||||
'Deploy your own gateway and start routing requests through your configured upstream services.'
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-8 flex items-center justify-center gap-3'>
|
||||
<Button className='group rounded-lg' asChild>
|
||||
|
||||
@@ -113,7 +113,7 @@ export function Features(_props: FeaturesProps) {
|
||||
id: 'developer',
|
||||
num: '04',
|
||||
title: t('Developer Friendly'),
|
||||
desc: t('Complete API documentation with multi-language SDK support'),
|
||||
desc: t('Compatible API routes for common AI application workflows'),
|
||||
span: 'md:col-span-2',
|
||||
icon: <Code className='size-4 text-amber-400' />,
|
||||
visual: (
|
||||
@@ -130,7 +130,7 @@ export function Features(_props: FeaturesProps) {
|
||||
</div>
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs'>
|
||||
<Code className='size-3.5 text-blue-500' />
|
||||
{t('OpenAI Compatible')}
|
||||
{t('Multi-protocol Compatible')}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -155,8 +155,8 @@ export function Features(_props: FeaturesProps) {
|
||||
},
|
||||
{
|
||||
icon: <HeartHandshake className='size-5' strokeWidth={1.5} />,
|
||||
title: t('Technical Support'),
|
||||
desc: t('Professional team providing 24/7 technical support'),
|
||||
title: t('Open Source'),
|
||||
desc: t('Community driven, self-hosted, and extensible'),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Hero(props: HeroProps) {
|
||||
>
|
||||
{systemName}{' '}
|
||||
{t(
|
||||
'aggregates 50+ AI providers behind one unified API. Manage access, track costs, and scale effortlessly.'
|
||||
'is an open-source AI API gateway for self-hosted deployments. Connect multiple upstream services, manage models, keys, quotas, logs, and routing policies in one place.'
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,7 @@ export function HowItWorks() {
|
||||
num: '2',
|
||||
title: t('Connect'),
|
||||
desc: t(
|
||||
'Use our unified OpenAI-compatible endpoint in your applications'
|
||||
'Connect through OpenAI, Claude, Gemini, and other compatible API routes'
|
||||
),
|
||||
icon: <Zap className='size-6' strokeWidth={1.5} />,
|
||||
},
|
||||
|
||||
+12
-5
@@ -69,14 +69,21 @@ interface StatsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface StatItem {
|
||||
end: number
|
||||
suffix: string
|
||||
label: string
|
||||
decimals?: number
|
||||
}
|
||||
|
||||
export function Stats(_props: StatsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const stats = [
|
||||
{ end: 100, suffix: 'M+', label: t('requests served') },
|
||||
{ end: 50, suffix: '+', label: t('AI models supported') },
|
||||
{ end: 99.9, suffix: '%', label: t('uptime'), decimals: 1 },
|
||||
{ end: 10, suffix: 'K+', label: t('active users') },
|
||||
const stats: StatItem[] = [
|
||||
{ end: 50, suffix: '+', label: t('upstream services integrated') },
|
||||
{ end: 100, suffix: '+', label: t('model billing support') },
|
||||
{ end: 50, suffix: '+', label: t('compatible API routes') },
|
||||
{ end: 10, suffix: '+', label: t('scheduling controls') },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
+13
-13
@@ -41,25 +41,25 @@ export const GATEWAY_FEATURES = [
|
||||
|
||||
// Stats section - Default statistics
|
||||
export const DEFAULT_STATS = [
|
||||
{
|
||||
value: '50',
|
||||
suffix: '+',
|
||||
description: 'upstream services integrated',
|
||||
},
|
||||
{
|
||||
value: '100',
|
||||
suffix: 'M+',
|
||||
description: 'requests served',
|
||||
suffix: '+',
|
||||
description: 'model billing support',
|
||||
},
|
||||
{
|
||||
value: '50',
|
||||
suffix: '+',
|
||||
description: 'AI models supported',
|
||||
},
|
||||
{
|
||||
value: '99.9',
|
||||
suffix: '%',
|
||||
description: 'uptime',
|
||||
description: 'compatible API routes',
|
||||
},
|
||||
{
|
||||
value: '10',
|
||||
suffix: 'K+',
|
||||
description: 'active users',
|
||||
suffix: '+',
|
||||
description: 'scheduling controls',
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -84,7 +84,7 @@ export const DEFAULT_FEATURES = [
|
||||
},
|
||||
{
|
||||
title: 'Developer Friendly',
|
||||
description: 'Complete API documentation with multi-language SDK support',
|
||||
description: 'Compatible API routes for common AI application workflows',
|
||||
iconName: 'Code',
|
||||
},
|
||||
{
|
||||
@@ -103,8 +103,8 @@ export const DEFAULT_FEATURES = [
|
||||
iconName: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Technical Support',
|
||||
description: 'Professional team providing 24/7 technical support',
|
||||
title: 'Open Source',
|
||||
description: 'Community driven, self-hosted, and extensible',
|
||||
iconName: 'HeartHandshake',
|
||||
},
|
||||
] as const
|
||||
|
||||
+15
-10
@@ -156,14 +156,11 @@ export function DynamicPricingBreakdown({
|
||||
return { symbol: '$', rate: 1 }
|
||||
}, [currency])
|
||||
|
||||
const priceSuffix = `${symbol}/1M tokens`
|
||||
|
||||
const { tiers, ruleGroups, baseExpr } = useMemo(() => {
|
||||
const { tiers, ruleGroups } = useMemo(() => {
|
||||
const split = splitBillingExprAndRequestRules(expr)
|
||||
const parsedTiers = parseTiersFromExpr(split.billingExpr)
|
||||
const parsedRules = tryParseRequestRuleExpr(split.requestRuleExpr || '')
|
||||
return {
|
||||
baseExpr: split.billingExpr,
|
||||
tiers: parsedTiers,
|
||||
ruleGroups: parsedRules || [],
|
||||
}
|
||||
@@ -174,19 +171,27 @@ export function DynamicPricingBreakdown({
|
||||
|
||||
if (!expr) return null
|
||||
|
||||
if (!hasTiers && !hasRules) {
|
||||
if (!hasTiers) {
|
||||
return (
|
||||
<section className='min-w-0 py-4'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<span className='inline-flex size-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
<TagIcon className='size-3.5' />
|
||||
</span>
|
||||
<span className='text-foreground text-base font-medium'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
<div>
|
||||
<div className='text-foreground text-base font-medium'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Unable to parse structured pricing')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block text-xs break-all'>
|
||||
{baseExpr || expr}
|
||||
{expr}
|
||||
</code>
|
||||
</section>
|
||||
)
|
||||
@@ -233,7 +238,7 @@ export function DynamicPricingBreakdown({
|
||||
key={v.field}
|
||||
className='text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{`${t(v.shortLabel)} (${priceSuffix})`}
|
||||
{t(v.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Filter,
|
||||
List,
|
||||
RotateCcw,
|
||||
Table2,
|
||||
X,
|
||||
@@ -572,7 +571,6 @@ export function FilterBar(props: FilterBarProps) {
|
||||
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: VIEW_MODES.LIST, icon: List, tooltip: t('List view') },
|
||||
{
|
||||
value: VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
|
||||
+5
-3
@@ -1,8 +1,10 @@
|
||||
export { FilterBar } from './filter-bar'
|
||||
export { ModelRow } from './model-row'
|
||||
export { PricingSidebar } from './pricing-sidebar'
|
||||
export { PricingToolbar } from './pricing-toolbar'
|
||||
export { ModelCard } from './model-card'
|
||||
export { ModelCardGrid } from './model-card-grid'
|
||||
export { LoadingSkeleton } from './loading-skeleton'
|
||||
export { EmptyState } from './empty-state'
|
||||
export { SearchBar } from './search-bar'
|
||||
export { ModelDetails } from './model-details'
|
||||
export { VirtualModelList } from './virtual-model-list'
|
||||
export { ModelDetails, ModelDetailsDrawer } from './model-details'
|
||||
export { PricingTable } from './pricing-table'
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface LoadingSkeletonProps {
|
||||
}
|
||||
|
||||
export function LoadingSkeleton(props: LoadingSkeletonProps) {
|
||||
const viewMode = props.viewMode ?? VIEW_MODES.LIST
|
||||
const viewMode = props.viewMode ?? VIEW_MODES.CARD
|
||||
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
@@ -19,12 +19,46 @@ export function LoadingSkeleton(props: LoadingSkeletonProps) {
|
||||
{viewMode === VIEW_MODES.TABLE ? (
|
||||
<TableContentSkeleton />
|
||||
) : (
|
||||
<ListContentSkeleton />
|
||||
<CardContentSkeleton />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContentSkeleton() {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className='rounded-xl border p-5'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex min-w-0 items-start gap-3'>
|
||||
<Skeleton className='size-10 shrink-0 rounded-xl' />
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
<Skeleton className='h-5 w-36' />
|
||||
<Skeleton className='h-3.5 w-48' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-8 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='mt-4 space-y-2'>
|
||||
<Skeleton className='h-3.5 w-full' />
|
||||
<Skeleton className='h-3.5 w-4/5' />
|
||||
</div>
|
||||
<div className='mt-4 flex items-center gap-2'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='h-4 w-16' />
|
||||
</div>
|
||||
<div className='mt-2 flex items-center gap-3'>
|
||||
<Skeleton className='h-3.5 w-14' />
|
||||
<Skeleton className='h-3.5 w-14' />
|
||||
<Skeleton className='h-3.5 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterBarSkeleton() {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
@@ -50,34 +84,6 @@ function FilterBarSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function ListContentSkeleton() {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='flex items-start gap-4 border-b px-4 py-3.5 last:border-b-0 sm:px-5 sm:py-4'
|
||||
>
|
||||
<Skeleton className='hidden size-5 shrink-0 rounded sm:block' />
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
<Skeleton className='h-5 w-48' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-3.5 w-20' />
|
||||
<Skeleton className='h-3.5 w-24' />
|
||||
</div>
|
||||
<Skeleton className='h-3.5 w-full max-w-md' />
|
||||
</div>
|
||||
<div className='shrink-0 space-y-1 text-right'>
|
||||
<Skeleton className='ml-auto h-4 w-20' />
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
<Skeleton className='ml-auto h-4 w-20' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableContentSkeleton() {
|
||||
const columns = [
|
||||
{ width: 200 },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { ModelCard } from './model-card'
|
||||
|
||||
export interface ModelCardGridProps {
|
||||
models: PricingModel[]
|
||||
onModelClick: (modelName: string) => void
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
}
|
||||
|
||||
export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
const { t } = useTranslation()
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = DEFAULT_PRICING_PAGE_SIZE
|
||||
const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
|
||||
const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize))
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [props.models])
|
||||
|
||||
const pagedModels = useMemo(() => {
|
||||
const start = (page - 1) * pageSize
|
||||
return props.models.slice(start, start + pageSize)
|
||||
}, [page, pageSize, props.models])
|
||||
|
||||
if (props.models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-5'>
|
||||
<div className='grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{pagedModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id ?? model.model_name}
|
||||
model={model}
|
||||
tokenUnit={tokenUnit}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
showRechargePrice={props.showRechargePrice}
|
||||
onClick={() => props.onModelClick(model.model_name || '')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className='text-muted-foreground flex flex-col items-center justify-between gap-3 border-t px-4 py-3 text-sm sm:flex-row'>
|
||||
<p className='text-muted-foreground'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={page <= 1}
|
||||
className='gap-1.5'
|
||||
>
|
||||
<ChevronLeft className='size-4' />
|
||||
{t('Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
disabled={page >= totalPages}
|
||||
className='gap-1.5'
|
||||
>
|
||||
{t('Next')}
|
||||
<ChevronRight className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { memo } from 'react'
|
||||
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 { 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 type { PricingModel, TokenUnit } from '../types'
|
||||
|
||||
export interface ModelCardProps {
|
||||
model: PricingModel
|
||||
onClick: () => void
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
}
|
||||
|
||||
export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
|
||||
const priceRate = props.priceRate ?? 1
|
||||
const usdExchangeRate = props.usdExchangeRate ?? 1
|
||||
const showRechargePrice = props.showRechargePrice ?? false
|
||||
const isTokenBased = isTokenBasedModel(props.model)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const tags = parseTags(props.model.tags)
|
||||
const groups = props.model.enable_groups || []
|
||||
const endpoints = props.model.supported_endpoint_types || []
|
||||
const vendorIcon = props.model.vendor_icon
|
||||
? getLobeIcon(props.model.vendor_icon, 28)
|
||||
: null
|
||||
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
|
||||
const isDynamicPricing =
|
||||
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, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: getDynamicDisplayGroupRatio(props.model),
|
||||
})
|
||||
: null
|
||||
|
||||
const primaryGroup = groups[0]
|
||||
const bottomTags = [...endpoints.slice(0, 2), ...tags.slice(0, 2)]
|
||||
const hiddenCount =
|
||||
Math.max(groups.length - 1, 0) +
|
||||
Math.max(endpoints.length - 2, 0) +
|
||||
Math.max(tags.length - 2, 0)
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
copyToClipboard(props.model.model_name || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex flex-col rounded-xl border p-4 transition-colors sm:p-5',
|
||||
'hover:bg-muted/20'
|
||||
)}
|
||||
>
|
||||
{/* Header: icon + name + price + actions */}
|
||||
<div className='flex items-start justify-between gap-2.5 sm:gap-3'>
|
||||
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
|
||||
<div className='bg-muted/40 flex size-9 shrink-0 items-center justify-center rounded-lg sm:size-10 sm:rounded-xl'>
|
||||
{vendorIcon || (
|
||||
<span className='text-muted-foreground text-sm font-bold'>
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='text-foreground truncate font-mono text-[15px] font-bold leading-tight'>
|
||||
{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'>
|
||||
{dynamicSummary ? (
|
||||
dynamicSummary.isSpecialExpression ? (
|
||||
<span className='min-w-0'>
|
||||
<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]'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</span>
|
||||
) : dynamicSummary.primaryEntries.length > 0 ? (
|
||||
<>
|
||||
{dynamicSummary.primaryEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.key}
|
||||
className='text-muted-foreground whitespace-nowrap'
|
||||
>
|
||||
{t(entry.shortLabel)}{' '}
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{entry.formatted}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
)
|
||||
) : isTokenBased ? (
|
||||
<>
|
||||
<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)}
|
||||
</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)}
|
||||
</span>
|
||||
/{tokenUnitLabel}
|
||||
</span>
|
||||
{hasCachedPrice && (
|
||||
<span className='text-muted-foreground/60 whitespace-nowrap'>
|
||||
{t('Cached')}{' '}
|
||||
<span className='font-mono'>
|
||||
{formatPrice(props.model, 'cache', tokenUnit, showRechargePrice, priceRate, usdExchangeRate)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{formatRequestPrice(props.model, showRechargePrice, priceRate, usdExchangeRate)}
|
||||
</span>
|
||||
{' '}/ {t('request')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={props.onClick}
|
||||
className='text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors sm:px-2.5 sm:py-1.5'
|
||||
>
|
||||
{t('Details')}
|
||||
<ChevronRight className='size-3.5' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleCopy}
|
||||
className='text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border p-1.5 transition-colors'
|
||||
title={t('Copy')}
|
||||
>
|
||||
<Copy className='size-3.5' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className='text-muted-foreground mt-3 line-clamp-1 flex-1 text-[13px] leading-relaxed sm:mt-4 sm:line-clamp-2 sm:min-h-[2.5rem]'>
|
||||
{props.model.description || t('No description available.')}
|
||||
</p>
|
||||
|
||||
{/* Footer row 1: group + billing type */}
|
||||
<div className='mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 sm:mt-4'>
|
||||
{primaryGroup && (
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{primaryGroup} {t('Groups')}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{isTokenBased ? t('Token-based') : t('Per Request')}
|
||||
</span>
|
||||
{isDynamicPricing && (
|
||||
<StatusBadge
|
||||
label={t('Dynamic Pricing')}
|
||||
variant='warning'
|
||||
copyable={false}
|
||||
size='sm'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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'
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
<span className='text-muted-foreground/50 text-xs'>{tokenUnitLabel}</span>
|
||||
{hiddenCount > 0 && (
|
||||
<span className='text-muted-foreground/40 text-xs'>
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
+377
-65
@@ -13,6 +13,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
@@ -24,6 +31,12 @@ import {
|
||||
replaceModelInPath,
|
||||
isTokenBasedModel,
|
||||
} from '../lib/model-helpers'
|
||||
import {
|
||||
getDynamicPriceEntries,
|
||||
getDynamicPricingSummary,
|
||||
getDynamicPricingTiers,
|
||||
isDynamicPricingModel,
|
||||
} from '../lib/dynamic-price'
|
||||
import { formatGroupPrice, formatFixedPrice } from '../lib/price'
|
||||
import type { PricingModel, TokenUnit, PriceType } from '../types'
|
||||
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
|
||||
@@ -44,6 +57,10 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
: null
|
||||
const description = model.description || model.vendor_description || null
|
||||
const tags = parseTags(model.tags)
|
||||
const isSpecialExpression =
|
||||
model.billing_mode === 'tiered_expr' &&
|
||||
Boolean(model.billing_expr) &&
|
||||
getDynamicPricingTiers(model).length === 0
|
||||
|
||||
return (
|
||||
<header className='pb-5'>
|
||||
@@ -75,7 +92,9 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
{t('Dynamic Pricing')}
|
||||
{isSpecialExpression
|
||||
? t('Special billing expression')
|
||||
: t('Dynamic Pricing')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -107,7 +126,6 @@ function PriceSection(props: {
|
||||
usdExchangeRate: number
|
||||
tokenUnit: TokenUnit
|
||||
showRechargePrice: boolean
|
||||
groupRatio: Record<string, number>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -116,23 +134,33 @@ function PriceSection(props: {
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
groupRatio,
|
||||
} = props
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const defaultGroup = model.enable_groups?.[0] || ''
|
||||
const ratio = defaultGroup ? groupRatio[defaultGroup] || 1 : 1
|
||||
const groupKey = defaultGroup || '_default'
|
||||
const groupRatioMap = { [groupKey]: ratio }
|
||||
const baseGroupKey = '_base'
|
||||
const baseGroupRatioMap = { [baseGroupKey]: 1 }
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
|
||||
const priceTypes: { label: string; type: PriceType; available: boolean }[] = [
|
||||
{ label: t('Input'), type: 'input', available: true },
|
||||
const primaryPriceTypes: { label: string; type: PriceType }[] = [
|
||||
{ label: t('Input'), type: 'input' },
|
||||
{ label: t('Output'), type: 'output' },
|
||||
]
|
||||
const secondaryPriceTypes: {
|
||||
label: string
|
||||
type: PriceType
|
||||
available: boolean
|
||||
}[] = [
|
||||
{
|
||||
label: t('Cached input'),
|
||||
type: 'cache',
|
||||
available: model.cache_ratio != null,
|
||||
},
|
||||
{ label: t('Output'), type: 'output', available: true },
|
||||
{
|
||||
label: t('Cache write'),
|
||||
type: 'create_cache',
|
||||
@@ -156,24 +184,97 @@ function PriceSection(props: {
|
||||
},
|
||||
]
|
||||
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Unable to parse structured pricing')}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
{dynamicSummary.primaryEntries.length > 0 ? (
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{dynamicSummary.primaryEntries.map((entry) => (
|
||||
<div key={entry.key} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t(entry.shortLabel)}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
|
||||
{entry.formatted}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Dynamic Pricing')}
|
||||
</p>
|
||||
)}
|
||||
{dynamicSummary.secondaryEntries.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{dynamicSummary.secondaryEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{t(entry.shortLabel)}
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono text-sm tabular-nums'>
|
||||
{entry.formatted}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isTokenBased) {
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Price')}</SectionTitle>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Per request')}
|
||||
</span>
|
||||
<span className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatGroupPrice(
|
||||
{formatFixedPrice(
|
||||
model,
|
||||
groupKey,
|
||||
'input',
|
||||
tokenUnit,
|
||||
baseGroupKey,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMap
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -181,33 +282,57 @@ function PriceSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const items = priceTypes.filter((p) => p.available)
|
||||
const secondaryItems = secondaryPriceTypes.filter((p) => p.available)
|
||||
const renderPrice = (type: PriceType) => (
|
||||
<>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
baseGroupKey,
|
||||
type,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='border-b py-4'>
|
||||
<SectionTitle>{t('Price')}</SectionTitle>
|
||||
<div className='space-y-1.5'>
|
||||
{items.map((item) => (
|
||||
<div key={item.type} className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>{item.label}</span>
|
||||
<span className='text-foreground font-mono text-sm tabular-nums'>
|
||||
{formatGroupPrice(
|
||||
model,
|
||||
groupKey,
|
||||
item.type,
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMap
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</span>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{primaryPriceTypes.map((item) => (
|
||||
<div key={item.type} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<div className='text-muted-foreground text-xs'>{item.label}</div>
|
||||
<div className='text-foreground mt-1 font-mono text-base font-semibold tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{secondaryItems.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{secondaryItems.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono text-sm tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -348,6 +473,128 @@ function GroupPricingSection(props: {
|
||||
const thClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
if (isDynamicPricingModel(model)) {
|
||||
const dynamicTiers = getDynamicPricingTiers(model)
|
||||
|
||||
if (dynamicTiers.length === 0) {
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<div className='rounded-lg border border-amber-200/70 bg-amber-50/70 p-3 dark:border-amber-500/20 dark:bg-amber-500/10'>
|
||||
<div className='text-amber-800 text-sm font-medium dark:text-amber-200'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t(
|
||||
'Group prices cannot be expanded because this expression is not a standard tiered pricing expression.'
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground block max-h-28 overflow-auto rounded-md border bg-background/80 px-2 py-1.5 font-mono text-xs break-all'>
|
||||
{model.billing_expr}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const priceFields = Array.from(
|
||||
new Map(
|
||||
dynamicTiers
|
||||
.flatMap((tier) =>
|
||||
getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
)
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values()
|
||||
)
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={model} autoGroups={autoGroups} />
|
||||
<div className='space-y-3'>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = groupRatio[group] || 1
|
||||
return (
|
||||
<div key={group} className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
|
||||
<GroupBadge group={group} size='sm' />
|
||||
<span className='text-muted-foreground font-mono text-xs'>
|
||||
{ratio}x
|
||||
</span>
|
||||
</div>
|
||||
<div className='overflow-x-auto'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Tier')}</TableHead>
|
||||
{priceFields.map((entry) => (
|
||||
<TableHead
|
||||
key={entry.field}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{t(entry.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
entries.map((entry) => [entry.field, entry])
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow key={`${group}-${tier.label || tierIndex}`}>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-xs'>
|
||||
{tier.label || t('Default')}
|
||||
</TableCell>
|
||||
{priceFields.map((fieldEntry) => {
|
||||
const entry = entryMap.get(fieldEntry.field)
|
||||
return (
|
||||
<TableCell
|
||||
key={fieldEntry.field}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{entry?.formatted ?? '-'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='py-4'>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
@@ -464,6 +711,92 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export interface ModelDetailsContentProps {
|
||||
model: PricingModel
|
||||
groupRatio: Record<string, number>
|
||||
usableGroup: Record<string, { desc: string; ratio: number }>
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
autoGroups: string[]
|
||||
priceRate: number
|
||||
usdExchangeRate: number
|
||||
tokenUnit: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
}
|
||||
|
||||
export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
const {
|
||||
model,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
tokenUnit,
|
||||
showRechargePrice = false,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModelHeader model={model} />
|
||||
|
||||
<PriceSection
|
||||
model={model}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
|
||||
<EndpointsSection model={model} endpointMap={endpointMap} />
|
||||
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<div className='border-b'>
|
||||
<DynamicPricingBreakdown billingExpr={model.billing_expr} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GroupPricingSection
|
||||
model={model}
|
||||
groupRatio={groupRatio}
|
||||
usableGroup={usableGroup}
|
||||
autoGroups={autoGroups}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { open, onOpenChange, ...contentProps } = props
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
|
||||
>
|
||||
<SheetHeader className='sr-only'>
|
||||
<SheetTitle>{props.model.model_name}</SheetTitle>
|
||||
<SheetDescription>{t('Model details')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 overflow-y-auto px-5 pt-12 pb-6 sm:px-6'>
|
||||
<ModelDetailsContent {...contentProps} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetails() {
|
||||
const { t } = useTranslation()
|
||||
const { modelId } = useParams({ from: '/pricing/$modelId/' })
|
||||
@@ -547,34 +880,7 @@ export function ModelDetails() {
|
||||
{t('Back')}
|
||||
</Button>
|
||||
|
||||
<ModelHeader model={model} />
|
||||
|
||||
<PriceSection
|
||||
model={model}
|
||||
priceRate={priceRate ?? 1}
|
||||
usdExchangeRate={usdExchangeRate ?? 1}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={search.rechargePrice ?? false}
|
||||
groupRatio={groupRatio || {}}
|
||||
/>
|
||||
|
||||
<EndpointsSection
|
||||
model={model}
|
||||
endpointMap={
|
||||
(endpointMap as Record<
|
||||
string,
|
||||
{ path?: string; method?: string }
|
||||
>) || {}
|
||||
}
|
||||
/>
|
||||
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<div className='border-b'>
|
||||
<DynamicPricingBreakdown billingExpr={model.billing_expr} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<GroupPricingSection
|
||||
<ModelDetailsContent
|
||||
model={model}
|
||||
groupRatio={groupRatio || {}}
|
||||
usableGroup={usableGroup || {}}
|
||||
@@ -583,6 +889,12 @@ export function ModelDetails() {
|
||||
usdExchangeRate={usdExchangeRate ?? 1}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={search.rechargePrice ?? false}
|
||||
endpointMap={
|
||||
(endpointMap as Record<
|
||||
string,
|
||||
{ path?: string; method?: string }
|
||||
>) || {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import {
|
||||
parseTiersFromExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
tryParseRequestRuleExpr,
|
||||
SOURCE_TIME,
|
||||
} from '../lib/billing-expr'
|
||||
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 ModelRowProps {
|
||||
model: PricingModel
|
||||
onClick: () => void
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
}
|
||||
|
||||
interface DynamicPricingHints {
|
||||
tierCount: number
|
||||
hasTimeCondition: boolean
|
||||
hasRequestCondition: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract at-a-glance hints from a tiered billing expression.
|
||||
*
|
||||
* The full breakdown lives in `DynamicPricingBreakdown`; here we only need a
|
||||
* minimal summary (tier count + condition presence) so that users scanning
|
||||
* the list can tell *what kind* of dynamic pricing applies before clicking
|
||||
* through to the model details page.
|
||||
*/
|
||||
function summarizeTieredExpr(
|
||||
expr: string | null | undefined
|
||||
): DynamicPricingHints {
|
||||
if (!expr) {
|
||||
return { tierCount: 0, hasTimeCondition: false, hasRequestCondition: false }
|
||||
}
|
||||
const split = splitBillingExprAndRequestRules(expr)
|
||||
const tiers = parseTiersFromExpr(split.billingExpr)
|
||||
const ruleGroups = tryParseRequestRuleExpr(split.requestRuleExpr || '') || []
|
||||
|
||||
let hasTimeCondition = false
|
||||
let hasRequestCondition = false
|
||||
for (const group of ruleGroups) {
|
||||
for (const condition of group.conditions) {
|
||||
if (condition.source === SOURCE_TIME) {
|
||||
hasTimeCondition = true
|
||||
} else {
|
||||
hasRequestCondition = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tierCount: tiers.length,
|
||||
hasTimeCondition,
|
||||
hasRequestCondition,
|
||||
}
|
||||
}
|
||||
|
||||
function PriceLabel(props: { label: string; value: string; muted?: boolean }) {
|
||||
return (
|
||||
<div className='flex items-baseline justify-end gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px]',
|
||||
props.muted ? 'text-muted-foreground/40' : 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-sm tabular-nums',
|
||||
props.muted
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground font-semibold'
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ModelRow = memo(function ModelRow(props: ModelRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const model = props.model
|
||||
const priceRate = props.priceRate ?? 1
|
||||
const usdExchangeRate = props.usdExchangeRate ?? 1
|
||||
const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
|
||||
const showRechargePrice = props.showRechargePrice ?? false
|
||||
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
const vendorIcon = model.vendor_icon
|
||||
? getLobeIcon(model.vendor_icon, 20)
|
||||
: null
|
||||
const tags = parseTags(model.tags)
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
const hasCachedPrice = isTokenBased && model.cache_ratio != null
|
||||
|
||||
const isDynamicPricing =
|
||||
model.billing_mode === 'tiered_expr' && Boolean(model.billing_expr)
|
||||
const dynamicHints = useMemo(
|
||||
() => (isDynamicPricing ? summarizeTieredExpr(model.billing_expr) : null),
|
||||
[isDynamicPricing, model.billing_expr]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={props.onClick}
|
||||
className='group hover:bg-muted/40 w-full border-b text-left transition-colors last:border-b-0'
|
||||
>
|
||||
<div className='flex items-start gap-3.5 px-4 py-3.5 sm:gap-4 sm:px-5 sm:py-4'>
|
||||
<div className='hidden shrink-0 pt-0.5 sm:block'>
|
||||
{vendorIcon || (
|
||||
<div className='bg-muted text-muted-foreground flex size-5 items-center justify-center rounded text-[10px] font-bold'>
|
||||
{model.model_name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='shrink-0 sm:hidden'>{vendorIcon}</span>
|
||||
<h3 className='text-foreground truncate font-mono text-sm font-semibold'>
|
||||
{model.model_name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className='text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs'>
|
||||
{model.vendor_name && <span>{model.vendor_name}</span>}
|
||||
{model.vendor_name && (
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
)}
|
||||
<span className='text-muted-foreground/60'>
|
||||
{isTokenBased ? t('Token-based') : t('Per Request')}
|
||||
</span>
|
||||
{model.supported_endpoint_types &&
|
||||
model.supported_endpoint_types.length > 0 && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground/50'>
|
||||
{model.supported_endpoint_types.slice(0, 2).join(', ')}
|
||||
{model.supported_endpoint_types.length > 2 &&
|
||||
` +${model.supported_endpoint_types.length - 2}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{isDynamicPricing && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
{dynamicHints && dynamicHints.tierCount > 1 && (
|
||||
<span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'>
|
||||
{t('{{count}} tiers', { count: dynamicHints.tierCount })}
|
||||
</span>
|
||||
)}
|
||||
{dynamicHints?.hasTimeCondition && (
|
||||
<span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'>
|
||||
{t('Time-based')}
|
||||
</span>
|
||||
)}
|
||||
{dynamicHints?.hasRequestCondition && (
|
||||
<span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'>
|
||||
{t('Request-based')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{model.description && (
|
||||
<p className='text-muted-foreground/60 mt-1 line-clamp-1 text-xs leading-relaxed'>
|
||||
{model.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className='mt-1.5 flex flex-wrap gap-1'>
|
||||
{tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium'
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{tags.length > 4 && (
|
||||
<span className='text-muted-foreground/40 self-center text-[10px]'>
|
||||
+{tags.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='shrink-0 text-right'>
|
||||
{isTokenBased ? (
|
||||
<div className='grid gap-0.5'>
|
||||
<PriceLabel
|
||||
label={t('Input')}
|
||||
value={formatPrice(
|
||||
model,
|
||||
'input',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
/>
|
||||
{hasCachedPrice && (
|
||||
<PriceLabel
|
||||
label={t('Cached')}
|
||||
value={formatPrice(
|
||||
model,
|
||||
'cache',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
<PriceLabel
|
||||
label={t('Output')}
|
||||
value={formatPrice(
|
||||
model,
|
||||
'output',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
/>
|
||||
<span className='text-muted-foreground/40 text-[10px]'>
|
||||
/ {tokenUnitLabel} tokens
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className='text-foreground text-sm font-semibold tabular-nums'>
|
||||
{formatRequestPrice(
|
||||
model,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)}
|
||||
</span>
|
||||
<div className='text-muted-foreground/40 text-[10px]'>
|
||||
/ {t('request')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight className='text-muted-foreground/20 group-hover:text-muted-foreground/50 mt-1.5 hidden size-4 shrink-0 transition-colors sm:block' />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@@ -12,6 +12,10 @@ 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 {
|
||||
formatPrice,
|
||||
formatRequestPrice,
|
||||
@@ -137,6 +141,63 @@ export function usePricingColumns(
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
|
||||
})
|
||||
|
||||
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'>
|
||||
{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'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const primaryEntries = dynamicSummary.primaryEntries.slice(0, 2)
|
||||
if (primaryEntries.length === 0) {
|
||||
return (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-w-[180px]'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{primaryEntries.map((entry, index) => (
|
||||
<span key={entry.key}>
|
||||
{index > 0 && (
|
||||
<span className='text-muted-foreground/40 mx-1'>/</span>
|
||||
)}
|
||||
{stripTrailingZeros(entry.formatted)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel} tokens
|
||||
{dynamicSummary.tierCount > 1 &&
|
||||
` · ${t('{{count}} tiers', {
|
||||
count: dynamicSummary.tierCount,
|
||||
})}`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
|
||||
if (isTokenBased) {
|
||||
@@ -204,6 +265,42 @@ export function usePricingColumns(
|
||||
header: t('Cached'),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: getDynamicDisplayGroupRatio(model),
|
||||
})
|
||||
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<span className='text-muted-foreground/50 text-xs'>
|
||||
{t('Special billing expression')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const cacheEntry = dynamicSummary.entries.find(
|
||||
(entry) => entry.field === 'cacheReadPrice'
|
||||
)
|
||||
if (!cacheEntry) {
|
||||
return <span className='text-muted-foreground/30 text-xs'>—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-w-[80px]'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{stripTrailingZeros(cacheEntry.formatted)}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isTokenBased = isTokenBasedModel(model)
|
||||
|
||||
if (!isTokenBased || model.cache_ratio == null) {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronDown, RotateCcw } 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 {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
ENDPOINT_TYPES,
|
||||
FILTER_ALL,
|
||||
QUOTA_TYPES,
|
||||
getEndpointTypeLabels,
|
||||
getQuotaTypeLabels,
|
||||
} from '../constants'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import type { PricingModel, PricingVendor } from '../types'
|
||||
|
||||
type FilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
suffix?: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
type FilterSectionProps = {
|
||||
title: string
|
||||
value: string
|
||||
options: FilterOption[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export interface PricingSidebarProps {
|
||||
quotaTypeFilter: string
|
||||
endpointTypeFilter: string
|
||||
vendorFilter: string
|
||||
groupFilter: string
|
||||
tagFilter: string
|
||||
onQuotaTypeChange: (value: string) => void
|
||||
onEndpointTypeChange: (value: string) => void
|
||||
onVendorChange: (value: string) => void
|
||||
onGroupChange: (value: string) => void
|
||||
onTagChange: (value: string) => void
|
||||
vendors: PricingVendor[]
|
||||
groups: string[]
|
||||
groupRatios?: Record<string, number>
|
||||
tags: string[]
|
||||
models: PricingModel[]
|
||||
hasActiveFilters: boolean
|
||||
onClearFilters: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function countBy(
|
||||
models: PricingModel[],
|
||||
predicate: (model: PricingModel) => boolean
|
||||
): number {
|
||||
return models.reduce((count, model) => count + (predicate(model) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function formatGroupRatio(ratio: number | undefined): string | undefined {
|
||||
if (ratio == null) return undefined
|
||||
const formatted = Number.isInteger(ratio)
|
||||
? ratio.toString()
|
||||
: ratio.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
|
||||
return `x${formatted}`
|
||||
}
|
||||
|
||||
function FilterChip(props: {
|
||||
option: FilterOption
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={props.onClick}
|
||||
className={cn(
|
||||
'group inline-flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-all',
|
||||
props.active
|
||||
? 'border-foreground/30 bg-foreground/5 text-foreground shadow-sm'
|
||||
: 'border-border/70 bg-background text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground'
|
||||
)}
|
||||
title={props.option.label}
|
||||
>
|
||||
{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
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px]',
|
||||
props.active
|
||||
? 'bg-background text-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{props.option.suffix ?? props.option.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSection(props: FilterSectionProps) {
|
||||
return (
|
||||
<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' />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{props.options.map((option) => (
|
||||
<FilterChip
|
||||
key={option.value}
|
||||
option={option}
|
||||
active={props.value === option.value}
|
||||
onClick={() => props.onChange(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingSidebar(props: PricingSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const quotaTypeLabels = getQuotaTypeLabels(t)
|
||||
const endpointTypeLabels = getEndpointTypeLabels(t)
|
||||
|
||||
const vendorOptions: FilterOption[] = [
|
||||
{
|
||||
value: FILTER_ALL,
|
||||
label: t('All Vendors'),
|
||||
count: props.models.length,
|
||||
},
|
||||
...props.vendors
|
||||
.map((vendor) => ({
|
||||
value: vendor.name,
|
||||
label: vendor.name,
|
||||
count: countBy(
|
||||
props.models,
|
||||
(model) => model.vendor_name === vendor.name
|
||||
),
|
||||
icon: vendor.icon ? getLobeIcon(vendor.icon, 14) : undefined,
|
||||
}))
|
||||
.filter((vendor) => vendor.count > 0),
|
||||
]
|
||||
|
||||
const groupOptions: FilterOption[] = [
|
||||
{
|
||||
value: FILTER_ALL,
|
||||
label: t('All Groups'),
|
||||
},
|
||||
...props.groups.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
suffix: formatGroupRatio(props.groupRatios?.[group]),
|
||||
})),
|
||||
]
|
||||
|
||||
const quotaOptions: FilterOption[] = [
|
||||
{
|
||||
value: QUOTA_TYPES.ALL,
|
||||
label: quotaTypeLabels[QUOTA_TYPES.ALL],
|
||||
count: props.models.length,
|
||||
},
|
||||
{
|
||||
value: QUOTA_TYPES.TOKEN,
|
||||
label: quotaTypeLabels[QUOTA_TYPES.TOKEN],
|
||||
count: countBy(props.models, (model) => model.quota_type === 0),
|
||||
},
|
||||
{
|
||||
value: QUOTA_TYPES.REQUEST,
|
||||
label: quotaTypeLabels[QUOTA_TYPES.REQUEST],
|
||||
count: countBy(props.models, (model) => model.quota_type === 1),
|
||||
},
|
||||
]
|
||||
|
||||
const tagOptions: FilterOption[] = [
|
||||
{
|
||||
value: FILTER_ALL,
|
||||
label: t('All Tags'),
|
||||
count: props.models.length,
|
||||
},
|
||||
...props.tags.map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
count: countBy(props.models, (model) =>
|
||||
parseTags(model.tags)
|
||||
.map((item) => item.toLowerCase())
|
||||
.includes(tag.toLowerCase())
|
||||
),
|
||||
})),
|
||||
]
|
||||
|
||||
const endpointOptions: FilterOption[] = [
|
||||
{
|
||||
value: ENDPOINT_TYPES.ALL,
|
||||
label: endpointTypeLabels[ENDPOINT_TYPES.ALL],
|
||||
count: props.models.length,
|
||||
},
|
||||
...Object.entries(endpointTypeLabels)
|
||||
.filter(([value]) => value !== ENDPOINT_TYPES.ALL)
|
||||
.map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
count: countBy(props.models, (model) =>
|
||||
model.supported_endpoint_types?.includes(value) ?? false
|
||||
),
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<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>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Refine models by provider, group, type, and tags.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={props.onClearFilters}
|
||||
disabled={!props.hasActiveFilters}
|
||||
className='h-7 gap-1.5 px-2 text-xs'
|
||||
>
|
||||
<RotateCcw className='size-3.5' />
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{props.hasActiveFilters && (
|
||||
<Badge variant='secondary' className='mb-3'>
|
||||
{t('Filters active')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className='space-y-1'>
|
||||
<FilterSection
|
||||
title={t('Groups')}
|
||||
value={props.groupFilter}
|
||||
options={groupOptions}
|
||||
onChange={props.onGroupChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('All Vendors')}
|
||||
value={props.vendorFilter}
|
||||
options={vendorOptions}
|
||||
onChange={props.onVendorChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Model Tags')}
|
||||
value={props.tagFilter}
|
||||
options={tagOptions}
|
||||
onChange={props.onTagChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Pricing Type')}
|
||||
value={props.quotaTypeFilter}
|
||||
options={quotaOptions}
|
||||
onChange={props.onQuotaTypeChange}
|
||||
/>
|
||||
<FilterSection
|
||||
title={t('Endpoint Type')}
|
||||
value={props.endpointTypeFilter}
|
||||
options={endpointOptions}
|
||||
onChange={props.onEndpointTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -29,11 +28,11 @@ export interface PricingTableProps {
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
onModelClick?: (modelName: string) => void
|
||||
}
|
||||
|
||||
export function PricingTable(props: PricingTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate({ from: '/pricing/' })
|
||||
const {
|
||||
models,
|
||||
isLoading = false,
|
||||
@@ -41,6 +40,7 @@ export function PricingTable(props: PricingTableProps) {
|
||||
usdExchangeRate = 1,
|
||||
tokenUnit = DEFAULT_TOKEN_UNIT,
|
||||
showRechargePrice = false,
|
||||
onModelClick,
|
||||
} = props
|
||||
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
@@ -68,13 +68,9 @@ export function PricingTable(props: PricingTableProps) {
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(model: PricingModel) => {
|
||||
navigate({
|
||||
to: '/pricing/$modelId',
|
||||
params: { modelId: model.model_name },
|
||||
search: (prev) => prev,
|
||||
})
|
||||
onModelClick?.(model.model_name)
|
||||
},
|
||||
[navigate]
|
||||
[onModelClick]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useState } from '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'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
VIEW_MODES,
|
||||
getSortLabels,
|
||||
type SortOption,
|
||||
type ViewMode,
|
||||
} from '../constants'
|
||||
import type { PricingModel, PricingVendor, TokenUnit } from '../types'
|
||||
import { PricingSidebar } from './pricing-sidebar'
|
||||
|
||||
type SegmentOption = {
|
||||
value: string
|
||||
label?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export interface PricingToolbarProps {
|
||||
filteredCount: number
|
||||
totalCount?: number
|
||||
sortBy: string
|
||||
onSortChange: (value: string) => void
|
||||
tokenUnit: TokenUnit
|
||||
onTokenUnitChange: (value: TokenUnit) => void
|
||||
showRechargePrice: boolean
|
||||
onRechargePriceChange: (value: boolean) => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (value: ViewMode) => void
|
||||
quotaTypeFilter: string
|
||||
endpointTypeFilter: string
|
||||
vendorFilter: string
|
||||
groupFilter: string
|
||||
tagFilter: string
|
||||
onQuotaTypeChange: (value: string) => void
|
||||
onEndpointTypeChange: (value: string) => void
|
||||
onVendorChange: (value: string) => void
|
||||
onGroupChange: (value: string) => void
|
||||
onTagChange: (value: string) => void
|
||||
vendors: PricingVendor[]
|
||||
groups: string[]
|
||||
groupRatios?: Record<string, number>
|
||||
tags: string[]
|
||||
models: PricingModel[]
|
||||
hasActiveFilters: boolean
|
||||
activeFilterCount: number
|
||||
onClearFilters: () => void
|
||||
}
|
||||
|
||||
function SegmentedControl(props: {
|
||||
options: SegmentOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
ariaLabel: string
|
||||
}) {
|
||||
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 Icon = option.icon
|
||||
const isActive = option.value === props.value
|
||||
const button = (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
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',
|
||||
Icon && !option.label ? 'w-7' : 'gap-1.5 px-3',
|
||||
isActive
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className='size-3.5' />}
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!option.tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{option.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingToolbar(props: PricingToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const sortLabels = getSortLabels(t)
|
||||
|
||||
const handleTokenUnitChange = useCallback(
|
||||
(value: string) => props.onTokenUnitChange(value as TokenUnit),
|
||||
[props]
|
||||
)
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
(value: string) => props.onViewModeChange(value as ViewMode),
|
||||
[props]
|
||||
)
|
||||
|
||||
const handleRechargePriceChange = useCallback(
|
||||
(value: string) => props.onRechargePriceChange(value === 'recharge'),
|
||||
[props]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='rounded-xl border p-3'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
className='gap-1.5 xl:hidden'
|
||||
>
|
||||
<Filter className='size-4' />
|
||||
{t('Filter')}
|
||||
{props.activeFilterCount > 0 && (
|
||||
<Badge className='ml-0.5 size-5 justify-center rounded-full p-0 text-[10px]'>
|
||||
{props.activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<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 text-xs'>
|
||||
/ {props.totalCount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='hidden items-center gap-2 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>
|
||||
|
||||
<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>
|
||||
<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.CARD,
|
||||
icon: Grid2X2,
|
||||
tooltip: t('Card view'),
|
||||
},
|
||||
{
|
||||
value: VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
]}
|
||||
value={props.viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
ariaLabel={t('View mode')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex w-full flex-col overflow-hidden p-0 sm:max-w-md'
|
||||
>
|
||||
<SheetHeader className='border-b px-6 py-4'>
|
||||
<SheetTitle>{t('Filter')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('Filter models by provider, group, type, endpoint, and tags.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 overflow-y-auto p-4'>
|
||||
<PricingSidebar
|
||||
quotaTypeFilter={props.quotaTypeFilter}
|
||||
endpointTypeFilter={props.endpointTypeFilter}
|
||||
vendorFilter={props.vendorFilter}
|
||||
groupFilter={props.groupFilter}
|
||||
tagFilter={props.tagFilter}
|
||||
onQuotaTypeChange={props.onQuotaTypeChange}
|
||||
onEndpointTypeChange={props.onEndpointTypeChange}
|
||||
onVendorChange={props.onVendorChange}
|
||||
onGroupChange={props.onGroupChange}
|
||||
onTagChange={props.onTagChange}
|
||||
vendors={props.vendors}
|
||||
groups={props.groups}
|
||||
groupRatios={props.groupRatios}
|
||||
tags={props.tags}
|
||||
models={props.models}
|
||||
hasActiveFilters={props.hasActiveFilters}
|
||||
onClearFilters={props.onClearFilters}
|
||||
className='border-0 bg-transparent p-0 shadow-none'
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,9 +40,9 @@ export function SearchBar(props: SearchBarProps) {
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/30 placeholder:text-muted-foreground/50',
|
||||
'hover:border-border hover:bg-muted/50',
|
||||
'focus:bg-background focus:border-primary/50 focus:ring-primary/20 focus:ring-2',
|
||||
'border-border/60 bg-background placeholder:text-muted-foreground/50',
|
||||
'hover:border-border',
|
||||
'focus:border-primary/50 focus:ring-primary/20 focus:ring-2',
|
||||
'h-10 w-full rounded-lg border pr-16 pl-10 text-sm transition-all outline-none'
|
||||
)}
|
||||
aria-label={t('Search models')}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual'
|
||||
import { DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { ModelRow } from './model-row'
|
||||
|
||||
export interface VirtualModelListProps {
|
||||
models: PricingModel[]
|
||||
onModelClick: (modelName: string) => void
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
}
|
||||
|
||||
export function VirtualModelList(props: VirtualModelListProps) {
|
||||
const estimateSize = props.estimateSize ?? 130
|
||||
const overscan = props.overscan ?? 5
|
||||
const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: props.models.length,
|
||||
estimateSize: () => estimateSize,
|
||||
overscan,
|
||||
measureElement:
|
||||
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
|
||||
? (element) => element?.getBoundingClientRect().height
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const items = virtualizer.getVirtualItems()
|
||||
|
||||
if (props.models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='overflow-hidden rounded-lg border'
|
||||
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
|
||||
>
|
||||
{items.map((virtualItem) => {
|
||||
const model = props.models[virtualItem.index]
|
||||
const key =
|
||||
model.id ??
|
||||
`${model.vendor_name}-${model.model_name}-${virtualItem.index}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className='absolute top-0 left-0 w-full'
|
||||
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||
>
|
||||
<ModelRow
|
||||
model={model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={props.showRechargePrice}
|
||||
onClick={() => props.onModelClick(model.model_name || '')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -116,7 +116,7 @@ export const DEFAULT_TOKEN_UNIT: TokenUnit = 'M'
|
||||
|
||||
/** View mode options */
|
||||
export const VIEW_MODES = {
|
||||
LIST: 'list',
|
||||
CARD: 'card',
|
||||
TABLE: 'table',
|
||||
} as const
|
||||
|
||||
|
||||
+77
-51
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useSearch, useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo, useCallback, useState } from 'react'
|
||||
import { useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
FILTER_ALL,
|
||||
SORT_OPTIONS,
|
||||
@@ -12,88 +12,114 @@ import {
|
||||
import { filterAndSortModels, extractAllTags } from '../lib/filters'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
|
||||
type FilterState = {
|
||||
search?: string
|
||||
sort?: string
|
||||
vendor?: string
|
||||
group?: string
|
||||
quotaType?: string
|
||||
endpointType?: string
|
||||
tag?: string
|
||||
tokenUnit?: TokenUnit
|
||||
view?: ViewMode
|
||||
rechargePrice?: boolean
|
||||
}
|
||||
|
||||
function normalizeViewMode(value: unknown): ViewMode {
|
||||
if (value === VIEW_MODES.TABLE) {
|
||||
return VIEW_MODES.TABLE
|
||||
}
|
||||
return VIEW_MODES.CARD
|
||||
}
|
||||
|
||||
export function useFilters(models: PricingModel[]) {
|
||||
const search = useSearch({ from: '/pricing/' })
|
||||
const navigate = useNavigate({ from: '/pricing/' })
|
||||
const [filterState, setFilterState] = useState<FilterState>(() => ({
|
||||
search: search.search,
|
||||
sort: search.sort,
|
||||
vendor: search.vendor,
|
||||
group: search.group,
|
||||
quotaType: search.quotaType,
|
||||
endpointType: search.endpointType,
|
||||
tag: search.tag,
|
||||
tokenUnit: search.tokenUnit,
|
||||
view: search.view,
|
||||
rechargePrice: search.rechargePrice,
|
||||
}))
|
||||
|
||||
const searchInput = search.search || ''
|
||||
const sortBy = search.sort || SORT_OPTIONS.NAME
|
||||
const vendorFilter = search.vendor || FILTER_ALL
|
||||
const groupFilter = search.group || FILTER_ALL
|
||||
const quotaTypeFilter = search.quotaType || QUOTA_TYPES.ALL
|
||||
const endpointTypeFilter = search.endpointType || ENDPOINT_TYPES.ALL
|
||||
const tagFilter = search.tag || FILTER_ALL
|
||||
const searchInput = filterState.search || ''
|
||||
const sortBy = filterState.sort || SORT_OPTIONS.NAME
|
||||
const vendorFilter = filterState.vendor || FILTER_ALL
|
||||
const groupFilter = filterState.group || FILTER_ALL
|
||||
const quotaTypeFilter = filterState.quotaType || QUOTA_TYPES.ALL
|
||||
const endpointTypeFilter = filterState.endpointType || ENDPOINT_TYPES.ALL
|
||||
const tagFilter = filterState.tag || FILTER_ALL
|
||||
const tokenUnit: TokenUnit =
|
||||
search.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT
|
||||
const viewMode: ViewMode =
|
||||
search.view === 'table' ? VIEW_MODES.TABLE : VIEW_MODES.LIST
|
||||
const showRechargePrice = search.rechargePrice === true
|
||||
filterState.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT
|
||||
const viewMode = normalizeViewMode(filterState.view)
|
||||
const showRechargePrice = filterState.rechargePrice === true
|
||||
|
||||
const updateSearch = useCallback(
|
||||
const updateFilters = useCallback(
|
||||
(updates: Record<string, unknown>) => {
|
||||
navigate({
|
||||
to: '/pricing' as const,
|
||||
search: (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]
|
||||
}
|
||||
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
|
||||
},
|
||||
replace: true,
|
||||
}
|
||||
return next as FilterState
|
||||
})
|
||||
},
|
||||
[navigate]
|
||||
[]
|
||||
)
|
||||
|
||||
const setSearchInput = useCallback(
|
||||
(v: string) => updateSearch({ search: v || undefined }),
|
||||
[updateSearch]
|
||||
(v: string) => updateFilters({ search: v || undefined }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setSortBy = useCallback(
|
||||
(v: string) =>
|
||||
updateSearch({ sort: v === SORT_OPTIONS.NAME ? undefined : v }),
|
||||
[updateSearch]
|
||||
updateFilters({ sort: v === SORT_OPTIONS.NAME ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setVendorFilter = useCallback(
|
||||
(v: string) => updateSearch({ vendor: v === FILTER_ALL ? undefined : v }),
|
||||
[updateSearch]
|
||||
(v: string) => updateFilters({ vendor: v === FILTER_ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setGroupFilter = useCallback(
|
||||
(v: string) => updateSearch({ group: v === FILTER_ALL ? undefined : v }),
|
||||
[updateSearch]
|
||||
(v: string) => updateFilters({ group: v === FILTER_ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setQuotaTypeFilter = useCallback(
|
||||
(v: string) =>
|
||||
updateSearch({ quotaType: v === QUOTA_TYPES.ALL ? undefined : v }),
|
||||
[updateSearch]
|
||||
updateFilters({ quotaType: v === QUOTA_TYPES.ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setEndpointTypeFilter = useCallback(
|
||||
(v: string) =>
|
||||
updateSearch({
|
||||
updateFilters({
|
||||
endpointType: v === ENDPOINT_TYPES.ALL ? undefined : v,
|
||||
}),
|
||||
[updateSearch]
|
||||
[updateFilters]
|
||||
)
|
||||
const setTagFilter = useCallback(
|
||||
(v: string) => updateSearch({ tag: v === FILTER_ALL ? undefined : v }),
|
||||
[updateSearch]
|
||||
(v: string) => updateFilters({ tag: v === FILTER_ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setTokenUnit = useCallback(
|
||||
(v: TokenUnit) =>
|
||||
updateSearch({ tokenUnit: v === DEFAULT_TOKEN_UNIT ? undefined : v }),
|
||||
[updateSearch]
|
||||
updateFilters({ tokenUnit: v === DEFAULT_TOKEN_UNIT ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setViewMode = useCallback(
|
||||
(v: ViewMode) =>
|
||||
updateSearch({ view: v === VIEW_MODES.LIST ? undefined : v }),
|
||||
[updateSearch]
|
||||
updateFilters({ view: v === VIEW_MODES.CARD ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setShowRechargePrice = useCallback(
|
||||
(v: boolean) => updateSearch({ rechargePrice: v || undefined }),
|
||||
[updateSearch]
|
||||
(v: boolean) => updateFilters({ rechargePrice: v || undefined }),
|
||||
[updateFilters]
|
||||
)
|
||||
|
||||
const availableTags = useMemo(() => {
|
||||
@@ -145,18 +171,18 @@ export function useFilters(models: PricingModel[]) {
|
||||
)
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
updateSearch({
|
||||
updateFilters({
|
||||
vendor: undefined,
|
||||
group: undefined,
|
||||
quotaType: undefined,
|
||||
endpointType: undefined,
|
||||
tag: undefined,
|
||||
})
|
||||
}, [updateSearch])
|
||||
}, [updateFilters])
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
updateSearch({ search: undefined })
|
||||
}, [updateSearch])
|
||||
updateFilters({ search: undefined })
|
||||
}, [updateFilters])
|
||||
|
||||
return {
|
||||
searchInput,
|
||||
|
||||
+178
-86
@@ -1,6 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { PageTransition } from '@/components/page-transition'
|
||||
@@ -8,9 +6,11 @@ import {
|
||||
LoadingSkeleton,
|
||||
EmptyState,
|
||||
SearchBar,
|
||||
FilterBar,
|
||||
VirtualModelList,
|
||||
PricingTable,
|
||||
PricingSidebar,
|
||||
PricingToolbar,
|
||||
ModelCardGrid,
|
||||
ModelDetailsDrawer,
|
||||
} from './components'
|
||||
import { EXCLUDED_GROUPS, VIEW_MODES } from './constants'
|
||||
import { useFilters } from './hooks/use-filters'
|
||||
@@ -18,13 +18,15 @@ import { usePricingData } from './hooks/use-pricing-data'
|
||||
|
||||
export function Pricing() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate({ from: '/pricing/' })
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const [selectedModelName, setSelectedModelName] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
models,
|
||||
vendors,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
isLoading,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
@@ -61,13 +63,18 @@ export function Pricing() {
|
||||
|
||||
const handleModelClick = useCallback(
|
||||
(modelName: string) => {
|
||||
navigate({
|
||||
to: '/pricing/$modelId',
|
||||
params: { modelId: modelName },
|
||||
search: (prev) => prev,
|
||||
})
|
||||
setSelectedModelName(modelName)
|
||||
},
|
||||
[navigate]
|
||||
[]
|
||||
)
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() =>
|
||||
selectedModelName
|
||||
? (models || []).find((model) => model.model_name === selectedModelName) ||
|
||||
null
|
||||
: null,
|
||||
[models, selectedModelName]
|
||||
)
|
||||
|
||||
const availableGroups = useMemo(
|
||||
@@ -83,10 +90,46 @@ export function Pricing() {
|
||||
clearSearch()
|
||||
}, [clearFilters, clearSearch])
|
||||
|
||||
const renderPricingContent = () => {
|
||||
if (filteredModels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
searchQuery={searchInput}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onClearFilters={handleClearAll}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === VIEW_MODES.CARD) {
|
||||
return (
|
||||
<ModelCardGrid
|
||||
models={filteredModels}
|
||||
onModelClick={handleModelClick}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingTable
|
||||
models={filteredModels}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
onModelClick={handleModelClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-6xl px-4 sm:px-6'>
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='mx-auto w-full max-w-[1800px] px-4 pt-20 pb-10 sm:px-6 xl:px-8'>
|
||||
<LoadingSkeleton viewMode={viewMode} />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
@@ -94,81 +137,130 @@ export function Pricing() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PageTransition className='mx-auto max-w-6xl px-4 sm:px-6'>
|
||||
<header className='mb-6 sm:mb-8'>
|
||||
<h1 className='text-2xl font-bold tracking-tight sm:text-3xl'>
|
||||
{t('Model Pricing')}
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{t('Browse and compare')} {models?.length || 0} {t('models')}
|
||||
</p>
|
||||
</header>
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='relative'>
|
||||
<div
|
||||
aria-hidden
|
||||
className='pointer-events-none absolute inset-x-0 top-0 h-[600px] opacity-20 dark:opacity-[0.10]'
|
||||
style={{
|
||||
background: [
|
||||
'radial-gradient(ellipse 60% 50% at 20% 20%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)',
|
||||
'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%)',
|
||||
}}
|
||||
/>
|
||||
<PageTransition className='relative mx-auto w-full max-w-[1800px] px-4 pt-20 pb-10 sm:px-6 xl:px-8'>
|
||||
<header className='mx-auto mb-8 max-w-3xl pt-8 text-center sm:mb-10 sm:pt-10'>
|
||||
<p className='text-muted-foreground mb-3 text-xs font-medium tracking-widest uppercase'>
|
||||
{t('Models Directory')}
|
||||
</p>
|
||||
<h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
|
||||
{t('Model Square')}
|
||||
</h1>
|
||||
<p className='text-muted-foreground/80 mt-4 text-sm sm:text-base'>
|
||||
{t('This site currently has {{count}} models enabled', {
|
||||
count: models?.length || 0,
|
||||
})}
|
||||
</p>
|
||||
<p className='text-muted-foreground/60 mx-auto mt-2 max-w-2xl text-xs leading-relaxed sm:text-sm'>
|
||||
{t(
|
||||
'Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.'
|
||||
)}
|
||||
</p>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onClear={clearSearch}
|
||||
placeholder={t('Search model name, provider, endpoint, or tag...')}
|
||||
className='mx-auto mt-6 max-w-2xl'
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={setSearchInput}
|
||||
onClear={clearSearch}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
quotaTypeFilter={quotaTypeFilter}
|
||||
endpointTypeFilter={endpointTypeFilter}
|
||||
vendorFilter={vendorFilter}
|
||||
groupFilter={groupFilter}
|
||||
tagFilter={tagFilter}
|
||||
onQuotaTypeChange={setQuotaTypeFilter}
|
||||
onEndpointTypeChange={setEndpointTypeFilter}
|
||||
onVendorChange={setVendorFilter}
|
||||
onGroupChange={setGroupFilter}
|
||||
onTagChange={setTagFilter}
|
||||
vendors={vendors || []}
|
||||
groups={availableGroups}
|
||||
tags={availableTags}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
tokenUnit={tokenUnit}
|
||||
onTokenUnitChange={setTokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
onRechargePriceChange={setShowRechargePrice}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onClearFilters={clearFilters}
|
||||
filteredCount={filteredModels.length}
|
||||
totalCount={models?.length}
|
||||
/>
|
||||
|
||||
{filteredModels.length > 0 ? (
|
||||
isMobile || viewMode === VIEW_MODES.LIST ? (
|
||||
<VirtualModelList
|
||||
models={filteredModels}
|
||||
onModelClick={handleModelClick}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
) : (
|
||||
<PricingTable
|
||||
models={filteredModels}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
searchQuery={searchInput}
|
||||
<div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)] 2xl:grid-cols-[330px_minmax(0,1fr)]'>
|
||||
<PricingSidebar
|
||||
quotaTypeFilter={quotaTypeFilter}
|
||||
endpointTypeFilter={endpointTypeFilter}
|
||||
vendorFilter={vendorFilter}
|
||||
groupFilter={groupFilter}
|
||||
tagFilter={tagFilter}
|
||||
onQuotaTypeChange={setQuotaTypeFilter}
|
||||
onEndpointTypeChange={setEndpointTypeFilter}
|
||||
onVendorChange={setVendorFilter}
|
||||
onGroupChange={setGroupFilter}
|
||||
onTagChange={setTagFilter}
|
||||
vendors={vendors || []}
|
||||
groups={availableGroups}
|
||||
groupRatios={groupRatio}
|
||||
tags={availableTags}
|
||||
models={models || []}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onClearFilters={handleClearAll}
|
||||
onClearFilters={clearFilters}
|
||||
className='sticky top-20 hidden max-h-[calc(100vh-6rem)] overflow-y-auto xl:block'
|
||||
/>
|
||||
|
||||
<main className='min-w-0 space-y-4'>
|
||||
<PricingToolbar
|
||||
filteredCount={filteredModels.length}
|
||||
totalCount={models?.length}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
tokenUnit={tokenUnit}
|
||||
onTokenUnitChange={setTokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
onRechargePriceChange={setShowRechargePrice}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
quotaTypeFilter={quotaTypeFilter}
|
||||
endpointTypeFilter={endpointTypeFilter}
|
||||
vendorFilter={vendorFilter}
|
||||
groupFilter={groupFilter}
|
||||
tagFilter={tagFilter}
|
||||
onQuotaTypeChange={setQuotaTypeFilter}
|
||||
onEndpointTypeChange={setEndpointTypeFilter}
|
||||
onVendorChange={setVendorFilter}
|
||||
onGroupChange={setGroupFilter}
|
||||
onTagChange={setTagFilter}
|
||||
vendors={vendors || []}
|
||||
groups={availableGroups}
|
||||
groupRatios={groupRatio}
|
||||
tags={availableTags}
|
||||
models={models || []}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
|
||||
{renderPricingContent()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{selectedModel && (
|
||||
<ModelDetailsDrawer
|
||||
open={Boolean(selectedModel)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedModelName(null)
|
||||
}}
|
||||
model={selectedModel}
|
||||
groupRatio={groupRatio || {}}
|
||||
usableGroup={usableGroup || {}}
|
||||
endpointMap={
|
||||
(endpointMap as Record<
|
||||
string,
|
||||
{ path?: string; method?: string }
|
||||
>) || {}
|
||||
}
|
||||
autoGroups={autoGroups || []}
|
||||
priceRate={priceRate ?? 1}
|
||||
usdExchangeRate={usdExchangeRate ?? 1}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageTransition>
|
||||
</PageTransition>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
|
||||
import { TOKEN_UNIT_DIVISORS } from '../constants'
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
parseTiersFromExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
tryParseRequestRuleExpr,
|
||||
type BillingVar,
|
||||
type ParsedTier,
|
||||
} from './billing-expr'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
|
||||
type DynamicPriceOptions = {
|
||||
tokenUnit: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
groupRatioMultiplier?: number
|
||||
}
|
||||
|
||||
export type DynamicPriceEntry = {
|
||||
key: string
|
||||
field: string
|
||||
label: string
|
||||
shortLabel: string
|
||||
value: number
|
||||
formatted: string
|
||||
variable: BillingVar
|
||||
}
|
||||
|
||||
export type DynamicPricingSummary = {
|
||||
tiers: ParsedTier[]
|
||||
tier: ParsedTier | null
|
||||
tierCount: number
|
||||
hasRequestRules: boolean
|
||||
isSpecialExpression: boolean
|
||||
rawExpression: string
|
||||
entries: DynamicPriceEntry[]
|
||||
primaryEntries: DynamicPriceEntry[]
|
||||
secondaryEntries: DynamicPriceEntry[]
|
||||
}
|
||||
|
||||
const PRIMARY_DYNAMIC_FIELDS = new Set(['inputPrice', 'outputPrice'])
|
||||
|
||||
export function isDynamicPricingModel(model: PricingModel): boolean {
|
||||
return model.billing_mode === 'tiered_expr' && Boolean(model.billing_expr)
|
||||
}
|
||||
|
||||
export function getDynamicDisplayGroupRatio(model: PricingModel): number {
|
||||
const groups = Array.isArray(model.enable_groups) ? model.enable_groups : []
|
||||
const ratios = model.group_ratio || {}
|
||||
if (groups.length === 0) return 1
|
||||
|
||||
let minRatio = Number.POSITIVE_INFINITY
|
||||
for (const group of groups) {
|
||||
const ratio = ratios[group]
|
||||
if (ratio !== undefined && ratio < minRatio) {
|
||||
minRatio = ratio
|
||||
}
|
||||
}
|
||||
|
||||
return minRatio === Number.POSITIVE_INFINITY ? 1 : minRatio
|
||||
}
|
||||
|
||||
function applyRechargeRate(
|
||||
price: number,
|
||||
showWithRecharge: boolean,
|
||||
priceRate: number,
|
||||
usdExchangeRate: number
|
||||
): number {
|
||||
if (!showWithRecharge) return price
|
||||
return (price * priceRate) / usdExchangeRate
|
||||
}
|
||||
|
||||
export function formatDynamicUnitPrice(
|
||||
valuePerMillionTokens: number,
|
||||
options: DynamicPriceOptions
|
||||
): string {
|
||||
const groupRatio = options.groupRatioMultiplier ?? 1
|
||||
const priceRate = options.priceRate ?? 1
|
||||
const usdExchangeRate = options.usdExchangeRate ?? 1
|
||||
const priceUSD =
|
||||
(valuePerMillionTokens * groupRatio) /
|
||||
TOKEN_UNIT_DIVISORS[options.tokenUnit]
|
||||
const displayPrice = applyRechargeRate(
|
||||
priceUSD,
|
||||
options.showRechargePrice ?? false,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)
|
||||
|
||||
return formatBillingCurrencyFromUSD(displayPrice, {
|
||||
digitsLarge: 4,
|
||||
digitsSmall: 6,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function getDynamicPricingTiers(model: PricingModel): ParsedTier[] {
|
||||
if (!isDynamicPricingModel(model)) return []
|
||||
const { billingExpr } = splitBillingExprAndRequestRules(model.billing_expr || '')
|
||||
return parseTiersFromExpr(billingExpr)
|
||||
}
|
||||
|
||||
export function hasDynamicRequestRules(model: PricingModel): boolean {
|
||||
if (!isDynamicPricingModel(model)) return false
|
||||
const { requestRuleExpr } = splitBillingExprAndRequestRules(
|
||||
model.billing_expr || ''
|
||||
)
|
||||
return Boolean(tryParseRequestRuleExpr(requestRuleExpr || '')?.length)
|
||||
}
|
||||
|
||||
export function getDynamicPriceEntries(
|
||||
tier: ParsedTier | null,
|
||||
options: DynamicPriceOptions
|
||||
): DynamicPriceEntry[] {
|
||||
if (!tier) return []
|
||||
|
||||
return BILLING_PRICING_VARS.flatMap((variable) => {
|
||||
if (!variable.field) return []
|
||||
const value = Number(tier[variable.field])
|
||||
if (!Number.isFinite(value) || value <= 0) return []
|
||||
|
||||
return [
|
||||
{
|
||||
key: variable.key,
|
||||
field: variable.field,
|
||||
label: variable.label,
|
||||
shortLabel: variable.shortLabel,
|
||||
value,
|
||||
formatted: formatDynamicUnitPrice(value, options),
|
||||
variable,
|
||||
},
|
||||
]
|
||||
}).sort((a, b) => {
|
||||
const aPrimary = PRIMARY_DYNAMIC_FIELDS.has(a.field)
|
||||
const bPrimary = PRIMARY_DYNAMIC_FIELDS.has(b.field)
|
||||
if (aPrimary !== bPrimary) return aPrimary ? -1 : 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function getDynamicPricingSummary(
|
||||
model: PricingModel,
|
||||
options: DynamicPriceOptions
|
||||
): DynamicPricingSummary | null {
|
||||
if (!isDynamicPricingModel(model)) return null
|
||||
|
||||
const tiers = getDynamicPricingTiers(model)
|
||||
const tier = tiers[0] || null
|
||||
const entries = getDynamicPriceEntries(tier, options)
|
||||
const rawExpression = model.billing_expr || ''
|
||||
|
||||
return {
|
||||
tiers,
|
||||
tier,
|
||||
tierCount: tiers.length,
|
||||
hasRequestRules: hasDynamicRequestRules(model),
|
||||
isSpecialExpression: rawExpression.trim().length > 0 && tiers.length === 0,
|
||||
rawExpression,
|
||||
entries,
|
||||
primaryEntries: entries.filter((entry) =>
|
||||
PRIMARY_DYNAMIC_FIELDS.has(entry.field)
|
||||
),
|
||||
secondaryEntries: entries.filter(
|
||||
(entry) => !PRIMARY_DYNAMIC_FIELDS.has(entry.field)
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user