refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import type { PricingData } from './types'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pricing APIs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Get model pricing data
|
||||
export async function getPricing(): Promise<PricingData> {
|
||||
const res = await api.get('/api/pricing')
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Tag as TagIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
MATCH_CONTAINS,
|
||||
MATCH_EQ,
|
||||
MATCH_EXISTS,
|
||||
MATCH_GTE,
|
||||
MATCH_LT,
|
||||
MATCH_RANGE,
|
||||
SOURCE_TIME,
|
||||
normalizeTierLabel,
|
||||
parseTiersFromExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
tryParseRequestRuleExpr,
|
||||
type ParsedTier,
|
||||
type RequestCondition,
|
||||
type RequestRuleGroup,
|
||||
type TierCondition,
|
||||
} from '../lib/billing-expr'
|
||||
|
||||
type DynamicPricingBreakdownProps = {
|
||||
billingExpr: string | null | undefined
|
||||
/**
|
||||
* Label of the tier that fired for the current request. When provided,
|
||||
* the corresponding row is highlighted and tagged as "Matched". Used by
|
||||
* the usage-log details dialog to show which tier the engine selected.
|
||||
*/
|
||||
matchedTierLabel?: string | null
|
||||
/**
|
||||
* Hide cache-pricing columns regardless of the per-tier values. The log
|
||||
* details dialog passes this when the actual request did not consume any
|
||||
* cache tokens, so users only see pricing rows that were relevant to the
|
||||
* call they are inspecting. Defaults to false (show all configured prices).
|
||||
*/
|
||||
hideCacheColumns?: boolean
|
||||
/**
|
||||
* Dense rendering for the usage-log details dialog: drops the colored
|
||||
* icon header and uses the dialog's small text sizes. Defaults to false.
|
||||
*/
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const VAR_LABELS: Record<string, string> = {
|
||||
p: 'Input',
|
||||
c: 'Output',
|
||||
len: 'Length',
|
||||
}
|
||||
const OP_LABELS: Record<string, string> = {
|
||||
'<': '<',
|
||||
'<=': '≤',
|
||||
'>': '>',
|
||||
'>=': '≥',
|
||||
}
|
||||
const TIME_FUNC_LABELS: Record<string, string> = {
|
||||
hour: 'Hour',
|
||||
minute: 'Minute',
|
||||
weekday: 'Weekday',
|
||||
month: 'Month',
|
||||
day: 'Day',
|
||||
}
|
||||
|
||||
function formatTokenHint(value: string | number): string {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n === 0) return ''
|
||||
if (n >= 1_000_000) {
|
||||
return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
|
||||
}
|
||||
if (n >= 1000) {
|
||||
return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}K`
|
||||
}
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatConditionSummary(
|
||||
conditions: TierCondition[],
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
return conditions
|
||||
.map((c) => {
|
||||
const varLabel = t(VAR_LABELS[c.var] || c.var)
|
||||
const hint = formatTokenHint(c.value)
|
||||
return `${varLabel} ${OP_LABELS[c.op] || c.op} ${hint || c.value}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' && ')
|
||||
}
|
||||
|
||||
function describeCondition(
|
||||
cond: RequestCondition,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (cond.source === SOURCE_TIME) {
|
||||
const fn = t(TIME_FUNC_LABELS[cond.timeFunc] || cond.timeFunc)
|
||||
const tz = cond.timezone || 'UTC'
|
||||
if (cond.mode === MATCH_RANGE) {
|
||||
return `${fn} ${cond.rangeStart}:00~${cond.rangeEnd}:00 (${tz})`
|
||||
}
|
||||
const opMap: Record<string, string> = {
|
||||
[MATCH_EQ]: '=',
|
||||
[MATCH_GTE]: '≥',
|
||||
[MATCH_LT]: '<',
|
||||
}
|
||||
return `${fn} ${opMap[cond.mode] || '='} ${cond.value} (${tz})`
|
||||
}
|
||||
const src = cond.source === 'header' ? t('Header') : t('Body param')
|
||||
const path = cond.path || ''
|
||||
if (cond.mode === MATCH_EXISTS) return `${src} ${path} ${t('Exists')}`
|
||||
if (cond.mode === MATCH_CONTAINS) {
|
||||
return `${src} ${path} ${t('Contains')} "${cond.value}"`
|
||||
}
|
||||
const opMap: Record<string, string> = {
|
||||
eq: '=',
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
}
|
||||
return `${src} ${path} ${opMap[cond.mode] || '='} ${cond.value}`
|
||||
}
|
||||
|
||||
function describeGroup(
|
||||
group: RequestRuleGroup,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
return (group.conditions || [])
|
||||
.map((c) => describeCondition(c, t))
|
||||
.join(' && ')
|
||||
}
|
||||
|
||||
export function DynamicPricingBreakdown({
|
||||
billingExpr,
|
||||
matchedTierLabel,
|
||||
hideCacheColumns = false,
|
||||
compact = false,
|
||||
}: DynamicPricingBreakdownProps) {
|
||||
const { t } = useTranslation()
|
||||
const expr = billingExpr || ''
|
||||
const currency = useSystemConfigStore((s) => s.config.currency)
|
||||
|
||||
const { symbol, rate } = useMemo(() => {
|
||||
if (currency.quotaDisplayType === 'CNY') {
|
||||
return { symbol: '¥', rate: currency.usdExchangeRate || 7 }
|
||||
}
|
||||
if (currency.quotaDisplayType === 'CUSTOM') {
|
||||
return {
|
||||
symbol: currency.customCurrencySymbol || '¤',
|
||||
rate: currency.customCurrencyExchangeRate || 1,
|
||||
}
|
||||
}
|
||||
return { symbol: '$', rate: 1 }
|
||||
}, [currency])
|
||||
|
||||
const { tiers, ruleGroups } = useMemo(() => {
|
||||
const split = splitBillingExprAndRequestRules(expr)
|
||||
const parsedTiers = parseTiersFromExpr(split.billingExpr)
|
||||
const parsedRules = tryParseRequestRuleExpr(split.requestRuleExpr || '')
|
||||
return {
|
||||
tiers: parsedTiers,
|
||||
ruleGroups: parsedRules || [],
|
||||
}
|
||||
}, [expr])
|
||||
|
||||
const hasTiers = tiers.length > 0
|
||||
const hasRules = ruleGroups.length > 0
|
||||
const normalizedMatchedTierLabel = normalizeTierLabel(
|
||||
matchedTierLabel ?? undefined
|
||||
)
|
||||
|
||||
if (!expr) return null
|
||||
|
||||
if (!hasTiers) {
|
||||
return (
|
||||
<section className={cn('min-w-0', !compact && 'py-4')}>
|
||||
{!compact && (
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<span className='inline-flex size-6 items-center justify-center rounded-lg bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
<TagIcon className='size-3.5' />
|
||||
</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'>
|
||||
{expr}
|
||||
</code>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const visiblePriceFields = BILLING_PRICING_VARS.filter((v) => {
|
||||
if (!hasTiers) return false
|
||||
if (hideCacheColumns && v.group === 'cache') return false
|
||||
return tiers.some(
|
||||
(tier) => Number(tier[v.field as string as keyof ParsedTier] || 0) > 0
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<section className={cn('min-w-0', !compact && 'py-3 sm:py-4')}>
|
||||
{!compact && (
|
||||
<div className='mb-3 flex items-start gap-2 sm:mb-4'>
|
||||
<span className='mt-0.5 inline-flex size-6 items-center justify-center rounded-lg bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
|
||||
<TagIcon className='size-3.5' />
|
||||
</span>
|
||||
<div>
|
||||
<div className='text-foreground text-base font-medium'>
|
||||
{t('Dynamic Pricing')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Prices vary by usage tier and request conditions')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTiers && (
|
||||
<div className={cn(compact ? cn(hasRules && 'mb-2') : 'mb-3 sm:mb-4')}>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'text-muted-foreground mb-1.5 text-xs font-medium'
|
||||
: 'text-foreground mb-2 text-sm font-semibold'
|
||||
}
|
||||
>
|
||||
{t('Tiered price table')}
|
||||
</div>
|
||||
<div className='space-y-1.5 sm:hidden'>
|
||||
{tiers.map((tier, i) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
const isMatched =
|
||||
matchedTierLabel != null &&
|
||||
matchedTierLabel !== '' &&
|
||||
tier.label === matchedTierLabel
|
||||
return (
|
||||
<div
|
||||
key={`tier-mobile-${i}`}
|
||||
className={cn(
|
||||
'rounded-md border p-2',
|
||||
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{condSummary && (
|
||||
<div className='text-muted-foreground mb-1.5 text-xs'>
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
<div className='grid grid-cols-2 gap-x-3 gap-y-1.5'>
|
||||
{visiblePriceFields.map((v) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return (
|
||||
<div key={v.field} className='min-w-0'>
|
||||
<div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t(v.shortLabel)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate font-mono',
|
||||
compact ? 'text-xs' : 'text-sm font-semibold'
|
||||
)}
|
||||
>
|
||||
{value > 0
|
||||
? `${symbol}${(value * rate).toFixed(4)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 sm:block'
|
||||
tableClassName={
|
||||
compact
|
||||
? '[&_td]:text-xs [&_td_*]:text-xs [&_th]:text-xs [&_th_*]:text-xs'
|
||||
: 'text-sm'
|
||||
}
|
||||
headerRowClassName='hover:bg-transparent'
|
||||
data={tiers}
|
||||
getRowKey={(_tier, index) => `tier-${index}`}
|
||||
getRowClassName={(tier) => {
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
|
||||
return cn(
|
||||
isMatched &&
|
||||
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
|
||||
)
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
id: 'tier',
|
||||
header: t('Tier'),
|
||||
className: cn(
|
||||
'text-muted-foreground py-2 font-medium',
|
||||
compact && 'h-8'
|
||||
),
|
||||
cellClassName: cn('align-top', compact ? 'py-2' : 'py-2.5'),
|
||||
cell: (tier) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) ===
|
||||
normalizedMatchedTierLabel
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{condSummary && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
...visiblePriceFields.map((v, index) => ({
|
||||
id: v.field ?? `price-${index}`,
|
||||
header: t(v.shortLabel),
|
||||
className: cn(
|
||||
'text-muted-foreground py-2 text-right font-medium',
|
||||
compact && 'h-8'
|
||||
),
|
||||
cellClassName: cn(
|
||||
'text-right align-top font-mono',
|
||||
compact ? 'py-2' : 'py-2.5'
|
||||
),
|
||||
cell: (tier: ParsedTier) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return value > 0 ? (
|
||||
<span className={cn(!compact && 'font-semibold')}>
|
||||
{`${symbol}${(value * rate).toFixed(4)}`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRules && (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'text-muted-foreground mb-1.5 text-xs font-medium'
|
||||
: 'text-foreground mb-2 text-sm font-semibold'
|
||||
}
|
||||
>
|
||||
{t('Conditional multipliers')}
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{ruleGroups.map((group, gi) => (
|
||||
<li
|
||||
key={`group-${gi}`}
|
||||
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground break-all',
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{describeGroup(group, t)}
|
||||
</span>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||
>
|
||||
{group.multiplier}x
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export interface EmptyStateProps {
|
||||
searchQuery?: string
|
||||
hasActiveFilters: boolean
|
||||
onClearFilters: () => void
|
||||
}
|
||||
|
||||
export function EmptyState(props: EmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const hasSearch = Boolean(props.searchQuery?.trim())
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed px-6 py-12 text-center'>
|
||||
<Search className='text-muted-foreground/40 mb-3 size-10' />
|
||||
|
||||
<h3 className='text-foreground mb-1 text-base font-semibold'>
|
||||
{t('No models found')}
|
||||
</h3>
|
||||
|
||||
<p className='text-muted-foreground mb-5 max-w-xs text-sm'>
|
||||
{hasSearch
|
||||
? t(
|
||||
'No results for "{{query}}". Try adjusting your search or filters.',
|
||||
{ query: props.searchQuery }
|
||||
)
|
||||
: t('No models match your current filters.')}
|
||||
</p>
|
||||
|
||||
{(props.hasActiveFilters || hasSearch) && (
|
||||
<Button variant='outline' size='sm' onClick={props.onClearFilters}>
|
||||
{t('Clear all filters')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
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,
|
||||
ModelDetailsContent,
|
||||
ModelDetailsDrawer,
|
||||
} from './model-details'
|
||||
export { PricingTable } from './pricing-table'
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
import { VIEW_MODES, type ViewMode } from '../constants'
|
||||
|
||||
export interface LoadingSkeletonProps {
|
||||
viewMode?: ViewMode
|
||||
}
|
||||
|
||||
export function LoadingSkeleton(props: LoadingSkeletonProps) {
|
||||
const viewMode = props.viewMode ?? VIEW_MODES.CARD
|
||||
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
<div className='space-y-1.5'>
|
||||
<Skeleton className='h-8 w-40' />
|
||||
<Skeleton className='h-4 w-52' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-lg' />
|
||||
<FilterBarSkeleton />
|
||||
{viewMode === VIEW_MODES.TABLE ? (
|
||||
<TableContentSkeleton />
|
||||
) : (
|
||||
<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'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex flex-1 flex-wrap items-center gap-2'>
|
||||
{[80, 90, 75, 85, 70].map((width, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='h-8 rounded-lg'
|
||||
style={{ width: `${width}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-8 w-24 rounded-lg' />
|
||||
<Skeleton className='h-8 w-20 rounded-lg' />
|
||||
<Skeleton className='h-8 w-24' />
|
||||
<Skeleton className='h-8 w-20 rounded-lg' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-5 w-24' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableContentSkeleton() {
|
||||
const columns = [
|
||||
{ width: 200 },
|
||||
{ width: 100 },
|
||||
{ width: 100 },
|
||||
{ width: 100 },
|
||||
{ width: 80 },
|
||||
{ width: 100 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/30 border-b px-4 py-3'>
|
||||
<div className='flex items-center gap-4'>
|
||||
{columns.map((col, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className='h-4'
|
||||
style={{ width: `${col.width}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='flex items-center gap-4 border-b px-4 py-3 last:border-b-0'
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<Skeleton
|
||||
key={j}
|
||||
className='h-5'
|
||||
style={{ width: `${col.width}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-5 w-32' />
|
||||
<div className='flex items-center gap-2'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='size-8' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
|
||||
|
||||
import { isDynamicPricingModel } from '../lib/dynamic-price'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
interface ModelBillingModeBadgeProps {
|
||||
model: PricingModel
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ModelBillingModeBadge(props: ModelBillingModeBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
let label = t('Per Request')
|
||||
let variant: StatusVariant = 'purple'
|
||||
|
||||
if (isDynamicPricingModel(props.model)) {
|
||||
label = t('Dynamic Pricing')
|
||||
variant = 'warning'
|
||||
} else if (isTokenBasedModel(props.model)) {
|
||||
label = t('Token-based')
|
||||
variant = 'info'
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={variant}
|
||||
copyable={false}
|
||||
size='sm'
|
||||
className={props.className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
|
||||
|
||||
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { ModelCard } from './model-card'
|
||||
import type { ModelPerfBadgeData } from './model-perf-badge'
|
||||
|
||||
export interface ModelCardGridProps {
|
||||
models: PricingModel[]
|
||||
onModelClick: (modelName: string) => void
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
selectedGroup?: string
|
||||
}
|
||||
|
||||
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))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
|
||||
const perfQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', 24],
|
||||
queryFn: () => getPerfMetricsSummary(24),
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const pagedModels = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize
|
||||
return props.models.slice(start, start + pageSize)
|
||||
}, [currentPage, pageSize, props.models])
|
||||
|
||||
const perfMap = useMemo(() => {
|
||||
const map = new Map<string, ModelPerfBadgeData>()
|
||||
for (const model of perfQuery.data?.data?.models ?? []) {
|
||||
map.set(model.model_name, model)
|
||||
}
|
||||
return map
|
||||
}, [perfQuery.data])
|
||||
|
||||
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}
|
||||
selectedGroup={props.selectedGroup}
|
||||
perf={perfMap.get(model.model_name || '')}
|
||||
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: currentPage,
|
||||
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={currentPage <= 1}
|
||||
className='gap-1.5'
|
||||
>
|
||||
<ChevronLeft className='size-4' />
|
||||
{t('Previous page')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(totalPages, current + 1))
|
||||
}
|
||||
disabled={currentPage >= totalPages}
|
||||
className='gap-1.5'
|
||||
>
|
||||
{t('Next page')}
|
||||
<ChevronRight className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ChevronRight, Copy } from 'lucide-react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import {
|
||||
getDynamicDisplayGroupRatio,
|
||||
getDynamicPricingSummary,
|
||||
} from '../lib/dynamic-price'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import { formatPrice, formatRequestPrice } from '../lib/price'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { ModelBillingModeBadge } from './model-billing-mode-badge'
|
||||
import { ModelPerfBadge, type ModelPerfBadgeData } from './model-perf-badge'
|
||||
|
||||
export interface ModelCardProps {
|
||||
model: PricingModel
|
||||
onClick: () => void
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
selectedGroup?: string
|
||||
perf?: ModelPerfBadgeData
|
||||
}
|
||||
|
||||
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 modelIconKey = props.model.icon || props.model.vendor_icon
|
||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 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,
|
||||
props.selectedGroup
|
||||
),
|
||||
})
|
||||
: 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 || '')
|
||||
}
|
||||
|
||||
let priceSummary: ReactNode
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
priceSummary = (
|
||||
<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 font-mono text-[11px] break-all'>
|
||||
{dynamicSummary.rawExpression}
|
||||
</code>
|
||||
</span>
|
||||
)
|
||||
} else if (dynamicSummary.primaryEntries.length > 0) {
|
||||
priceSummary = (
|
||||
<>
|
||||
{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>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
priceSummary = (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Dynamic Pricing')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
} else if (isTokenBased) {
|
||||
priceSummary = (
|
||||
<>
|
||||
<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,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
</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,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{hasCachedPrice && (
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
{t('Cached')}{' '}
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{formatPrice(
|
||||
props.model,
|
||||
'cache',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
priceSummary = (
|
||||
<span className='text-muted-foreground whitespace-nowrap'>
|
||||
<span className='text-foreground font-mono font-semibold'>
|
||||
{formatRequestPrice(
|
||||
props.model,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
props.selectedGroup
|
||||
)}
|
||||
</span>{' '}
|
||||
/ {t('request')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex flex-col rounded-xl border p-3 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'>
|
||||
{modelIcon || (
|
||||
<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] leading-tight font-bold'>
|
||||
{props.model.model_name}
|
||||
</h3>
|
||||
<div className='mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm sm:mt-1 sm:gap-x-3'>
|
||||
{priceSummary}
|
||||
</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-2 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: left metadata and right performance summary share row alignment */}
|
||||
<div className='mt-2 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-1 sm:mt-4'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1'>
|
||||
{primaryGroup && (
|
||||
<span className='text-muted-foreground text-sm font-medium'>
|
||||
{primaryGroup}
|
||||
</span>
|
||||
)}
|
||||
<ModelBillingModeBadge model={props.model} />
|
||||
</div>
|
||||
<ModelPerfBadge perf={props.perf} className='row-span-2 self-start' />
|
||||
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-0.5 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>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,793 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
ChevronRight,
|
||||
Gauge,
|
||||
KeyRound,
|
||||
ScrollText,
|
||||
Sigma,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BundledLanguage } from 'shiki/bundle/web'
|
||||
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
import {
|
||||
buildRateLimits,
|
||||
buildSupportedParameters,
|
||||
formatRateLimit,
|
||||
type SupportedParameter,
|
||||
} from '../lib/mock-stats'
|
||||
import { replaceModelInPath } from '../lib/model-helpers'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code-sample registry
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Each sample is keyed by language and endpoint type. The endpoint type comes
|
||||
// from the model's `supported_endpoint_types`; we render samples only for the
|
||||
// types the model actually supports. This keeps copy-pasted code accurate and
|
||||
// provider-shaped (OpenAI vs Anthropic vs Gemini, etc.).
|
||||
|
||||
type Lang = 'curl' | 'python' | 'typescript' | 'javascript'
|
||||
|
||||
const LANG_LABELS: Record<Lang, string> = {
|
||||
curl: 'cURL',
|
||||
python: 'Python',
|
||||
typescript: 'TypeScript',
|
||||
javascript: 'JavaScript',
|
||||
}
|
||||
|
||||
const LANG_HIGHLIGHT: Record<Lang, BundledLanguage> = {
|
||||
curl: 'bash',
|
||||
python: 'python',
|
||||
typescript: 'typescript',
|
||||
javascript: 'javascript',
|
||||
}
|
||||
|
||||
type SampleContext = {
|
||||
baseUrl: string
|
||||
apiKeyEnv: string
|
||||
modelName: string
|
||||
endpointType: string
|
||||
endpointPath: string
|
||||
}
|
||||
|
||||
function buildChatSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const isResponses = ctx.endpointType === 'openai-response'
|
||||
const isReasoning = /^o[1-4]|reasoning|thinking|deepseek-r/i.test(
|
||||
ctx.modelName
|
||||
)
|
||||
const userMessage = 'Explain quantum entanglement in one paragraph.'
|
||||
|
||||
const bodyJson = isResponses
|
||||
? JSON.stringify({ model: ctx.modelName, input: userMessage }, null, 2)
|
||||
: JSON.stringify(
|
||||
{
|
||||
model: ctx.modelName,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
...(isReasoning ? {} : { temperature: 0.7 }),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
|
||||
const fnCall = isResponses ? 'responses.create' : 'chat.completions.create'
|
||||
|
||||
if (lang === 'curl') {
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${bodyJson.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'from openai import OpenAI',
|
||||
'',
|
||||
'client = OpenAI(',
|
||||
` base_url="${ctx.baseUrl}/v1",`,
|
||||
` api_key="<YOUR_API_KEY>",`,
|
||||
')',
|
||||
'',
|
||||
isResponses
|
||||
? `response = client.${fnCall}(\n model="${ctx.modelName}",\n input="${userMessage}",\n)\n\nprint(response.output_text)`
|
||||
: `completion = client.${fnCall}(\n model="${ctx.modelName}",\n messages=[\n {"role": "user", "content": "${userMessage}"}\n ],\n)\n\nprint(completion.choices[0].message.content)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import OpenAI from 'openai'`,
|
||||
'',
|
||||
`const client = new OpenAI({`,
|
||||
` baseURL: '${ctx.baseUrl}/v1',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
isResponses
|
||||
? `const response = await client.${fnCall}({\n model: '${ctx.modelName}',\n input: '${userMessage}',\n})\n\nconsole.log(response.output_text)`
|
||||
: `const completion = await client.${fnCall}({\n model: '${ctx.modelName}',\n messages: [{ role: 'user', content: '${userMessage}' }],\n})\n\nconsole.log(completion.choices[0].message.content)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify(${bodyJson}),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildAnthropicSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const userMessage = 'Explain quantum entanglement in one paragraph.'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify(
|
||||
{
|
||||
model: ctx.modelName,
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "x-api-key: $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "anthropic-version: 2023-06-01" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'import anthropic',
|
||||
'',
|
||||
'client = anthropic.Anthropic(',
|
||||
` base_url="${ctx.baseUrl}",`,
|
||||
` api_key="<YOUR_API_KEY>",`,
|
||||
')',
|
||||
'',
|
||||
`message = client.messages.create(`,
|
||||
` model="${ctx.modelName}",`,
|
||||
` max_tokens=1024,`,
|
||||
` messages=[{"role": "user", "content": "${userMessage}"}],`,
|
||||
')',
|
||||
'',
|
||||
'print(message.content[0].text)',
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import Anthropic from '@anthropic-ai/sdk'`,
|
||||
'',
|
||||
`const client = new Anthropic({`,
|
||||
` baseURL: '${ctx.baseUrl}',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
`const message = await client.messages.create({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` max_tokens: 1024,`,
|
||||
` messages: [{ role: 'user', content: '${userMessage}' }],`,
|
||||
`})`,
|
||||
'',
|
||||
`console.log(message.content[0].text)`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` 'x-api-key': process.env.${ctx.apiKeyEnv},`,
|
||||
` 'anthropic-version': '2023-06-01',`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` max_tokens: 1024,`,
|
||||
` messages: [{ role: 'user', content: '${userMessage}' }],`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.content[0].text)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildGeminiSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}?key=$${ctx.apiKeyEnv}`
|
||||
const userMessage = 'Explain quantum entanglement in one paragraph.'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify(
|
||||
{ contents: [{ parts: [{ text: userMessage }] }] },
|
||||
null,
|
||||
2
|
||||
)
|
||||
return [
|
||||
`curl '${url}' \\`,
|
||||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'import google.generativeai as genai',
|
||||
'',
|
||||
`genai.configure(api_key="<YOUR_API_KEY>")`,
|
||||
'',
|
||||
`model = genai.GenerativeModel("${ctx.modelName}")`,
|
||||
`response = model.generate_content("${userMessage}")`,
|
||||
'',
|
||||
`print(response.text)`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import { GoogleGenerativeAI } from '@google/generative-ai'`,
|
||||
'',
|
||||
`const genAI = new GoogleGenerativeAI(process.env.${ctx.apiKeyEnv}!)`,
|
||||
`const model = genAI.getGenerativeModel({ model: '${ctx.modelName}' })`,
|
||||
'',
|
||||
`const result = await model.generateContent('${userMessage}')`,
|
||||
`console.log(result.response.text())`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: { 'Content-Type': 'application/json' },`,
|
||||
` body: JSON.stringify({`,
|
||||
` contents: [{ parts: [{ text: '${userMessage}' }] }],`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.candidates[0].content.parts[0].text)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildEmbeddingSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const text = 'The food was delicious and the waiter…'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify({ model: ctx.modelName, input: text }, null, 2)
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'from openai import OpenAI',
|
||||
'',
|
||||
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
|
||||
'',
|
||||
'response = client.embeddings.create(',
|
||||
` model="${ctx.modelName}",`,
|
||||
` input="${text}",`,
|
||||
')',
|
||||
'',
|
||||
'print(response.data[0].embedding[:8])',
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import OpenAI from 'openai'`,
|
||||
'',
|
||||
`const client = new OpenAI({`,
|
||||
` baseURL: '${ctx.baseUrl}/v1',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
`const response = await client.embeddings.create({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` input: '${text}',`,
|
||||
`})`,
|
||||
'',
|
||||
`console.log(response.data[0].embedding.slice(0, 8))`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` input: '${text}',`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.data[0].embedding.slice(0, 8))`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildImageSample(lang: Lang, ctx: SampleContext): string {
|
||||
const url = `${ctx.baseUrl}${ctx.endpointPath}`
|
||||
const prompt = 'A serene koi pond at sunset, ukiyo-e style.'
|
||||
|
||||
if (lang === 'curl') {
|
||||
const body = JSON.stringify(
|
||||
{ model: ctx.modelName, prompt, size: '1024x1024', n: 1 },
|
||||
null,
|
||||
2
|
||||
)
|
||||
return [
|
||||
`curl ${url} \\`,
|
||||
` -H "Authorization: Bearer $${ctx.apiKeyEnv}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${body.replace(/\n/g, '\n ')}'`,
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'python') {
|
||||
return [
|
||||
'from openai import OpenAI',
|
||||
'',
|
||||
`client = OpenAI(base_url="${ctx.baseUrl}/v1", api_key="<YOUR_API_KEY>")`,
|
||||
'',
|
||||
'response = client.images.generate(',
|
||||
` model="${ctx.modelName}",`,
|
||||
` prompt="${prompt}",`,
|
||||
` size="1024x1024",`,
|
||||
` n=1,`,
|
||||
')',
|
||||
'',
|
||||
'print(response.data[0].url)',
|
||||
].join('\n')
|
||||
}
|
||||
if (lang === 'typescript') {
|
||||
return [
|
||||
`import OpenAI from 'openai'`,
|
||||
'',
|
||||
`const client = new OpenAI({`,
|
||||
` baseURL: '${ctx.baseUrl}/v1',`,
|
||||
` apiKey: process.env.${ctx.apiKeyEnv},`,
|
||||
`})`,
|
||||
'',
|
||||
`const response = await client.images.generate({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` prompt: '${prompt}',`,
|
||||
` size: '1024x1024',`,
|
||||
` n: 1,`,
|
||||
`})`,
|
||||
'',
|
||||
`console.log(response.data[0].url)`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`const response = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: \`Bearer \${process.env.${ctx.apiKeyEnv}}\`,`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify({`,
|
||||
` model: '${ctx.modelName}',`,
|
||||
` prompt: '${prompt}',`,
|
||||
` size: '1024x1024',`,
|
||||
` n: 1,`,
|
||||
` }),`,
|
||||
`})`,
|
||||
'',
|
||||
`const data = await response.json()`,
|
||||
`console.log(data.data[0].url)`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildSample(
|
||||
lang: Lang,
|
||||
endpointType: string,
|
||||
ctx: SampleContext
|
||||
): string {
|
||||
if (endpointType === 'anthropic') return buildAnthropicSample(lang, ctx)
|
||||
if (endpointType === 'gemini') return buildGeminiSample(lang, ctx)
|
||||
if (endpointType === 'embeddings' || endpointType === 'jina-rerank')
|
||||
return buildEmbeddingSample(lang, ctx)
|
||||
if (endpointType === 'image-generation') return buildImageSample(lang, ctx)
|
||||
return buildChatSample(lang, ctx)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code samples section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CodeSamplesSection(props: {
|
||||
model: PricingModel
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
|
||||
const baseUrl = useMemo(() => {
|
||||
const candidate =
|
||||
(status as Record<string, unknown> | null)?.server_address ??
|
||||
(status as Record<string, unknown> | null)?.serverAddress ??
|
||||
(status?.data as Record<string, unknown> | undefined)?.server_address ??
|
||||
(status?.data as Record<string, unknown> | undefined)?.serverAddress
|
||||
if (candidate && typeof candidate === 'string') {
|
||||
return candidate.replace(/\/$/, '')
|
||||
}
|
||||
if (typeof window !== 'undefined') return window.location.origin
|
||||
return 'https://api.example.com'
|
||||
}, [status])
|
||||
|
||||
const endpoints = useMemo(() => {
|
||||
const types = props.model.supported_endpoint_types || []
|
||||
return types
|
||||
.map((type) => {
|
||||
const info = props.endpointMap[type] || {}
|
||||
let path = info.path || ''
|
||||
if (path && path.includes('{model}')) {
|
||||
path = replaceModelInPath(path, props.model.model_name || '')
|
||||
}
|
||||
return { type, path, method: info.method || 'POST' }
|
||||
})
|
||||
.filter((e) => Boolean(e.path))
|
||||
}, [props.model, props.endpointMap])
|
||||
|
||||
const [endpointType, setEndpointType] = useState<string>(
|
||||
endpoints[0]?.type ?? ''
|
||||
)
|
||||
const [lang, setLang] = useState<Lang>('curl')
|
||||
|
||||
const activeEndpoint = useMemo(() => {
|
||||
return endpoints.find((e) => e.type === endpointType) ?? endpoints[0]
|
||||
}, [endpointType, endpoints])
|
||||
|
||||
if (endpoints.length === 0 || !activeEndpoint) {
|
||||
return null
|
||||
}
|
||||
|
||||
const code = buildSample(lang, activeEndpoint.type, {
|
||||
baseUrl,
|
||||
apiKeyEnv: 'NEW_API_KEY',
|
||||
modelName: props.model.model_name || '',
|
||||
endpointType: activeEndpoint.type,
|
||||
endpointPath: activeEndpoint.path,
|
||||
})
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={ScrollText}>{t('Code samples')}</SectionTitle>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{endpoints.length > 1 && (
|
||||
<Tabs value={endpointType} onValueChange={setEndpointType}>
|
||||
<TabsList className='bg-muted/40 h-8 p-0.5'>
|
||||
{endpoints.map((ep) => (
|
||||
<TabsTrigger
|
||||
key={ep.type}
|
||||
value={ep.type}
|
||||
className='h-7 px-2.5 text-xs'
|
||||
>
|
||||
{ep.type}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={lang}
|
||||
onValueChange={(v) => setLang(v as Lang)}
|
||||
className='ml-auto'
|
||||
>
|
||||
<TabsList className='bg-muted/40 h-8 p-0.5'>
|
||||
{(Object.keys(LANG_LABELS) as Lang[]).map((l) => (
|
||||
<TabsTrigger key={l} value={l} className='h-7 px-2.5 text-xs'>
|
||||
{LANG_LABELS[l]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='mt-3'>
|
||||
<CodeBlock code={code} language={LANG_HIGHLIGHT[lang]}>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Replace')}{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
||||
{'<YOUR_API_KEY>'}
|
||||
</code>{' '}
|
||||
{t('with the API key from your token settings.')}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supported parameters table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SupportedParametersSection(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const params = useMemo(
|
||||
() => buildSupportedParameters(props.model),
|
||||
[props.model]
|
||||
)
|
||||
|
||||
if (params.length === 0) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
|
||||
<StaticDataTable
|
||||
className={tableStyles.sectionContainer}
|
||||
headerRowClassName={tableStyles.mutedHeaderRow}
|
||||
data={params}
|
||||
getRowKey={(param) => param.name}
|
||||
getRowClassName={() => 'hover:bg-muted/20'}
|
||||
columns={[
|
||||
{
|
||||
id: 'parameter',
|
||||
header: t('Parameter'),
|
||||
className: 'h-9 w-44',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-sm font-medium'>{p.name}</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
className: 'h-9 w-24',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'range',
|
||||
header: t('Default / range'),
|
||||
className: 'h-9 w-32',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => <ParamRangeCell param={p} />,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: t('Description'),
|
||||
className: 'h-9',
|
||||
cellClassName: tableStyles.topMutedCell,
|
||||
cell: (p) => t(p.descriptionKey),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ParamRangeCell(props: { param: SupportedParameter }) {
|
||||
const { defaultValue, range, enumValues } = props.param
|
||||
if (defaultValue !== undefined) {
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-1'>
|
||||
<span className='text-muted-foreground text-sm'>=</span>
|
||||
<code className='bg-muted rounded px-1.5 py-0.5 font-mono text-sm'>
|
||||
{String(defaultValue)}
|
||||
</code>
|
||||
{range && (
|
||||
<span className='text-muted-foreground text-sm'>{range}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (range) {
|
||||
return (
|
||||
<span className='text-muted-foreground font-mono text-sm'>{range}</span>
|
||||
)
|
||||
}
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
return (
|
||||
<div className='flex flex-wrap gap-0.5'>
|
||||
{enumValues.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-sm'
|
||||
>
|
||||
{v}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span className='text-muted-foreground/60 text-sm'>—</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate-limits table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RateLimitsSection(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const limits = useMemo(() => buildRateLimits(props.model), [props.model])
|
||||
|
||||
if (limits.length === 0) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
|
||||
<StaticDataTable
|
||||
className={tableStyles.sectionContainer}
|
||||
headerRowClassName={tableStyles.mutedHeaderRow}
|
||||
data={limits}
|
||||
getRowKey={(limit) => limit.group}
|
||||
getRowClassName={() => 'hover:bg-muted/20'}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: 'h-9',
|
||||
cellClassName: 'py-2 font-mono',
|
||||
cell: (limit) => limit.group,
|
||||
},
|
||||
{
|
||||
id: 'rpm',
|
||||
header: 'RPM',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.rpm),
|
||||
},
|
||||
{
|
||||
id: 'tpm',
|
||||
header: 'TPM',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.tpm),
|
||||
},
|
||||
{
|
||||
id: 'rpd',
|
||||
header: 'RPD',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.rpd),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AuthSection() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={KeyRound}>{t('Authentication')}</SectionTitle>
|
||||
<div className='border-border/60 bg-muted/20 flex items-start gap-2 rounded-lg border p-3'>
|
||||
<ChevronRight className='text-muted-foreground mt-0.5 size-3.5 shrink-0' />
|
||||
<div className='space-y-1.5 text-xs leading-relaxed'>
|
||||
<p>
|
||||
{t('All requests must include')}{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
||||
Authorization: Bearer <TOKEN>
|
||||
</code>{' '}
|
||||
{t('header. Anthropic-formatted endpoints accept the')}{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
||||
x-api-key
|
||||
</code>{' '}
|
||||
{t('header instead.')}
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{t(
|
||||
'Generate tokens from the Tokens page; you can scope them to specific models, groups, IPs, and rate-limits.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite API tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ModelDetailsApi(props: {
|
||||
model: PricingModel
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<CodeSamplesSection model={props.model} endpointMap={props.endpointMap} />
|
||||
<AuthSection />
|
||||
<SupportedParametersSection model={props.model} />
|
||||
<RateLimitsSection model={props.model} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local UI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionTitle(props: {
|
||||
children: React.ReactNode
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<h3 className='text-foreground mb-3 flex items-center gap-1.5 text-sm font-semibold'>
|
||||
<Icon className='text-muted-foreground/70 size-3.5' />
|
||||
{props.children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
// Re-export so the parent can keep its own SectionTitle if it wants:
|
||||
export { Zap as ApiTabIcon }
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
ExternalLink,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
buildAppRankings,
|
||||
formatTokenVolume,
|
||||
type AppRanking,
|
||||
} from '../lib/mock-stats'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function RankBadge(props: { rank: number }) {
|
||||
const rank = props.rank
|
||||
const isPodium = rank <= 3
|
||||
const palette =
|
||||
rank === 1
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
|
||||
: rank === 2
|
||||
? 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300'
|
||||
: rank === 3
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-7 shrink-0 items-center justify-center rounded-md font-mono text-xs font-bold tabular-nums',
|
||||
palette
|
||||
)}
|
||||
>
|
||||
{isPodium ? <Trophy className='size-3.5' /> : rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function GrowthChip(props: { value: number }) {
|
||||
const value = props.value
|
||||
const isUp = value > 0
|
||||
const isDown = value < 0
|
||||
const palette = isUp
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
: isDown
|
||||
? 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-300'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
const Icon = isUp ? ArrowUpRight : isDown ? ArrowDownRight : null
|
||||
const formatted = `${value > 0 ? '+' : ''}${value.toFixed(1)}%`
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold tabular-nums',
|
||||
palette
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className='size-3' />}
|
||||
{formatted}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AppLink(props: { app: AppRanking }) {
|
||||
if (!props.app.url) {
|
||||
return <span className='text-foreground'>{props.app.name}</span>
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={props.app.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-foreground hover:text-primary inline-flex items-center gap-1 transition-colors'
|
||||
>
|
||||
{props.app.name}
|
||||
<ExternalLink className='text-muted-foreground/40 size-3' />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const apps = useMemo(() => buildAppRankings(props.model, 12), [props.model])
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className='text-muted-foreground rounded-lg border p-6 text-center text-sm'>
|
||||
{t('No app usage data available for this model.')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
|
||||
const top = apps[0]
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<div className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Tracked apps')}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 font-mono text-lg font-semibold tabular-nums'>
|
||||
{apps.length}
|
||||
</div>
|
||||
<p className='text-muted-foreground/70 text-[11px]'>
|
||||
{t('Top integrations using this model')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('Monthly tokens')}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 font-mono text-lg font-semibold tabular-nums'>
|
||||
{COMPACT_NUMBER.format(totalMonthlyTokens)}
|
||||
</div>
|
||||
<p className='text-muted-foreground/70 text-[11px]'>
|
||||
{t('Aggregated across the apps below')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||
{t('#1 by usage')}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 truncate text-base font-semibold'>
|
||||
{top.name}
|
||||
</div>
|
||||
<p className='text-muted-foreground/70 truncate text-[11px]'>
|
||||
{top.category} · {formatTokenVolume(top.monthly_tokens)}{' '}
|
||||
{t('tokens / mo')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName={tableStyles.compactHeaderRow}
|
||||
data={apps}
|
||||
getRowKey={(app) => `${app.rank}-${app.name}`}
|
||||
columns={[
|
||||
{
|
||||
id: 'rank',
|
||||
header: '#',
|
||||
className: cn(tableStyles.compactHeaderCell, 'w-12'),
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (app) => <RankBadge rank={app.rank} />,
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
header: t('App'),
|
||||
className: tableStyles.compactHeaderCell,
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (app) => (
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
|
||||
{app.initial}
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-medium'>
|
||||
<AppLink app={app} />
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: t('Category'),
|
||||
className: cn(
|
||||
tableStyles.compactHeaderCell,
|
||||
'hidden md:table-cell'
|
||||
),
|
||||
cellClassName: cn(
|
||||
tableStyles.compactMutedCell,
|
||||
'hidden md:table-cell'
|
||||
),
|
||||
cell: (app) => app.category,
|
||||
},
|
||||
{
|
||||
id: 'monthly-tokens',
|
||||
header: t('Monthly tokens'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: cn(tableStyles.compactNumericCell, 'tabular-nums'),
|
||||
cell: (app) => formatTokenVolume(app.monthly_tokens),
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
header: t('30d change'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: cn(tableStyles.compactCell, 'text-right'),
|
||||
cell: (app) => <GrowthChip value={app.growth_pct} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'App rankings shown here are simulated for preview purposes and will be replaced with live usage data once the backend integration is complete.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useThemeCustomization } from '@/context/theme-customization-provider'
|
||||
import { getSuccessRateColor } from '@/features/performance-metrics/lib/format'
|
||||
import { useThemeRadiusPx } from '@/lib/theme-radius'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
|
||||
import type { LatencyTimePoint, UptimeDayPoint } from '../lib/mock-stats'
|
||||
|
||||
function formatHourLabel(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const hours = date.getHours()
|
||||
return `${String(hours).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
function formatDayLabel(date: string): string {
|
||||
const parsed = new Date(date)
|
||||
if (date.includes('T')) {
|
||||
return parsed.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
})
|
||||
}
|
||||
return parsed.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getChartThemeTokens(resolvedTheme: string) {
|
||||
return {
|
||||
textColor:
|
||||
resolvedTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.68)'
|
||||
: 'rgba(15, 23, 42, 0.58)',
|
||||
gridColor:
|
||||
resolvedTheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.12)'
|
||||
: 'rgba(15, 23, 42, 0.12)',
|
||||
}
|
||||
}
|
||||
|
||||
const UPTIME_AXIS_MAX = 100
|
||||
const UPTIME_FOCUSED_AXIS_MIN = 95
|
||||
const UPTIME_MINOR_OUTAGE_AXIS_MIN = 90
|
||||
|
||||
function toUptimeChartValue(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(UPTIME_AXIS_MAX, Math.max(0, value))
|
||||
}
|
||||
|
||||
function getUptimeAxisMin(values: number[]): number {
|
||||
const finiteValues = values.filter((value) => Number.isFinite(value))
|
||||
if (finiteValues.length === 0) return UPTIME_FOCUSED_AXIS_MIN
|
||||
|
||||
const minValue = Math.max(0, Math.min(...finiteValues))
|
||||
if (minValue >= UPTIME_FOCUSED_AXIS_MIN) return UPTIME_FOCUSED_AXIS_MIN
|
||||
if (minValue >= UPTIME_MINOR_OUTAGE_AXIS_MIN) {
|
||||
return UPTIME_MINOR_OUTAGE_AXIS_MIN
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor((minValue - 5) / 10) * 10)
|
||||
}
|
||||
|
||||
function stripUptimePointSuffix(value: string): string {
|
||||
return value.replace(/__(start|end)$/, '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Latency trend chart (24h, multi-group point-line chart)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LatencyTrendChart(props: {
|
||||
series: LatencyTimePoint[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
const { textColor, gridColor } = getChartThemeTokens(resolvedTheme)
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (props.series.length === 0) return null
|
||||
const data = props.series.map((point) => ({
|
||||
time: formatHourLabel(point.timestamp),
|
||||
group: point.group,
|
||||
ttft: point.ttft_ms,
|
||||
}))
|
||||
return {
|
||||
type: 'line' as const,
|
||||
data: [{ id: 'latency', values: data }],
|
||||
xField: 'time',
|
||||
yField: 'ttft',
|
||||
seriesField: 'group',
|
||||
smooth: true,
|
||||
point: {
|
||||
visible: true,
|
||||
style: { size: 5, stroke: '#ffffff', lineWidth: 1.5 },
|
||||
},
|
||||
line: {
|
||||
style: { lineWidth: 2 },
|
||||
},
|
||||
legends: { visible: false },
|
||||
tooltip: {
|
||||
mark: {
|
||||
title: { value: (d: { time: string }) => d.time },
|
||||
content: [
|
||||
{
|
||||
key: t('Average TTFT'),
|
||||
value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fill: textColor, fontSize: 10 },
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (val: number | string) => `${val} ms`,
|
||||
style: { fill: textColor, fontSize: 10 },
|
||||
},
|
||||
grid: {
|
||||
visible: true,
|
||||
style: { lineDash: [3, 3], stroke: gridColor },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [gridColor, props.series, t, textColor])
|
||||
|
||||
if (props.series.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground flex h-48 items-center justify-center rounded-lg border text-xs',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{t('No latency data available')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('h-64 sm:h-72', props.className)}>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`latency-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime trend chart (24h, point-line chart)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UptimeTrendChart(props: {
|
||||
series: UptimeDayPoint[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
const { textColor, gridColor } = getChartThemeTokens(resolvedTheme)
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (props.series.length === 0) return null
|
||||
|
||||
const rawData = props.series.map((point) => ({
|
||||
date: formatDayLabel(point.date),
|
||||
uptime: toUptimeChartValue(point.uptime_pct),
|
||||
incidents: point.incidents,
|
||||
outage: point.outage_minutes,
|
||||
}))
|
||||
const data =
|
||||
rawData.length === 1
|
||||
? [
|
||||
{ ...rawData[0], date: `${rawData[0].date}__start` },
|
||||
{ ...rawData[0], date: `${rawData[0].date}__end` },
|
||||
]
|
||||
: rawData
|
||||
const axisMin = getUptimeAxisMin(rawData.map((point) => point.uptime))
|
||||
|
||||
return {
|
||||
type: 'line' as const,
|
||||
data: [{ id: 'uptime', values: data }],
|
||||
xField: 'date',
|
||||
yField: 'uptime',
|
||||
smooth: true,
|
||||
line: {
|
||||
style: { stroke: '#10b981', lineWidth: 2 },
|
||||
},
|
||||
point: {
|
||||
visible: true,
|
||||
style: {
|
||||
size: 5,
|
||||
stroke: '#ffffff',
|
||||
lineWidth: 1.5,
|
||||
fill: (datum: { uptime: number }) =>
|
||||
getSuccessRateColor(datum.uptime),
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
title: {
|
||||
value: (d: { date: string }) => stripUptimePointSuffix(d.date),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
key: t('Uptime'),
|
||||
value: (d: { uptime: number }) => `${d.uptime.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
key: t('Incidents'),
|
||||
value: (d: { incidents: number }) => `${d.incidents}`,
|
||||
},
|
||||
{
|
||||
key: t('Outage'),
|
||||
value: (d: { outage: number }) => `${d.outage} ${t('minutes')}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
formatMethod: (val: number | string) =>
|
||||
stripUptimePointSuffix(String(val)),
|
||||
style: { fill: textColor, fontSize: 10 },
|
||||
autoLimit: true,
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
min: axisMin,
|
||||
max: UPTIME_AXIS_MAX,
|
||||
label: {
|
||||
formatMethod: (val: number | string) => `${val}%`,
|
||||
style: { fill: textColor, fontSize: 10 },
|
||||
},
|
||||
grid: {
|
||||
visible: true,
|
||||
style: { lineDash: [3, 3], stroke: gridColor },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [gridColor, props.series, t, textColor])
|
||||
|
||||
if (props.series.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground flex h-48 items-center justify-center rounded-lg border text-xs',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{t('No uptime data available')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('h-56 sm:h-64', props.className)}>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`uptime-trend-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Throughput by group (horizontal bar)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ThroughputBarChart(props: {
|
||||
rows: { group: string; throughput_tps: number }[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
const { textColor, gridColor } = getChartThemeTokens(resolvedTheme)
|
||||
const { customization } = useThemeCustomization()
|
||||
const barRadius = useThemeRadiusPx(
|
||||
'--radius-sm',
|
||||
`${customization.preset}:${customization.radius}`
|
||||
)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => props.rows.filter((r) => r.throughput_tps > 0),
|
||||
[props.rows]
|
||||
)
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (filtered.length === 0) return null
|
||||
return {
|
||||
type: 'bar' as const,
|
||||
direction: 'horizontal' as const,
|
||||
data: [{ id: 'tput', values: filtered.map((r) => ({ ...r })) }],
|
||||
xField: 'throughput_tps',
|
||||
yField: 'group',
|
||||
bar: {
|
||||
style: {
|
||||
fill: '#6366f1',
|
||||
...(barRadius == null ? {} : { cornerRadius: barRadius }),
|
||||
},
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
position: 'right',
|
||||
style: { fontSize: 11, fill: textColor },
|
||||
formatMethod: (text: string) => `${text} t/s`,
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'left',
|
||||
label: { style: { fill: textColor, fontSize: 10 } },
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: { style: { fill: textColor, fontSize: 10 } },
|
||||
grid: {
|
||||
visible: true,
|
||||
style: { lineDash: [3, 3], stroke: gridColor },
|
||||
},
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
mark: {
|
||||
title: { value: (d: { group: string }) => d.group },
|
||||
content: [
|
||||
{
|
||||
key: t('Throughput'),
|
||||
value: (d: { throughput_tps: number }) =>
|
||||
`${d.throughput_tps.toFixed(1)} t/s`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}, [barRadius, filtered, gridColor, t, textColor])
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('h-48 sm:h-56', props.className)}>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`tput-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
formatLatency,
|
||||
formatThroughput,
|
||||
formatUptimePct,
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import type { PerformanceGroup } from '@/features/performance-metrics/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { type UptimeDayPoint } from '../lib/mock-stats'
|
||||
import type { PricingModel } from '../types'
|
||||
import { LatencyTrendChart, UptimeTrendChart } from './model-details-charts'
|
||||
import { UptimeSparkline } from './model-details-uptime-sparkline'
|
||||
|
||||
function StatCard(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
hint?: string
|
||||
valueClassName?: string
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div className='bg-background flex flex-col gap-1 rounded-lg border p-3'>
|
||||
<span className='text-muted-foreground inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wider uppercase'>
|
||||
<Icon className='size-3' />
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground font-mono text-lg font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
{props.hint && (
|
||||
<span className='text-muted-foreground/70 text-[11px]'>
|
||||
{props.hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PerformanceRow = {
|
||||
group: string
|
||||
avg_ttft_ms: number
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
avg_tps: number
|
||||
}
|
||||
|
||||
function toUptimePct(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
const clamped = Math.min(100, Math.max(0, value))
|
||||
return Math.round(clamped * 100) / 100
|
||||
}
|
||||
|
||||
function toLatencySeries(groups: PerformanceGroup[]) {
|
||||
const byTs = new Map<number, number[]>()
|
||||
for (const group of groups) {
|
||||
for (const point of group.series) {
|
||||
if (point.avg_ttft_ms <= 0) continue
|
||||
const current = byTs.get(point.ts) ?? []
|
||||
current.push(point.avg_ttft_ms)
|
||||
byTs.set(point.ts, current)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byTs.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([ts, values]) => ({
|
||||
timestamp: new Date(ts * 1000).toISOString(),
|
||||
group: 'latency',
|
||||
ttft_ms: Math.round(
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function toUptimeSeries(groups: PerformanceGroup[]): UptimeDayPoint[] {
|
||||
const byTs = new Map<number, { rates: number[]; incidents: number }>()
|
||||
for (const group of groups) {
|
||||
for (const point of group.series) {
|
||||
const current = byTs.get(point.ts) ?? { rates: [], incidents: 0 }
|
||||
if (Number.isFinite(point.success_rate)) {
|
||||
const successRate = toUptimePct(point.success_rate)
|
||||
current.rates.push(successRate)
|
||||
if (successRate < 100) current.incidents += 1
|
||||
}
|
||||
byTs.set(point.ts, current)
|
||||
}
|
||||
}
|
||||
return Array.from(byTs.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([ts, value]) => {
|
||||
const uptime =
|
||||
value.rates.length > 0
|
||||
? value.rates.reduce((sum, rate) => sum + rate, 0) /
|
||||
value.rates.length
|
||||
: 0
|
||||
return {
|
||||
date: new Date(ts * 1000).toISOString(),
|
||||
uptime_pct: toUptimePct(uptime),
|
||||
incidents: value.incidents,
|
||||
outage_minutes: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toGroupUptimeSeries(group: PerformanceGroup): UptimeDayPoint[] {
|
||||
return group.series.map((point) => {
|
||||
const successRate = toUptimePct(point.success_rate)
|
||||
return {
|
||||
date: new Date(point.ts * 1000).toISOString(),
|
||||
uptime_pct: successRate,
|
||||
incidents: successRate < 100 ? 1 : 0,
|
||||
outage_minutes: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function average(
|
||||
rows: PerformanceRow[],
|
||||
field: 'avg_ttft_ms' | 'avg_latency_ms'
|
||||
) {
|
||||
const values = rows.map((row) => row[field]).filter((value) => value > 0)
|
||||
if (values.length === 0) return 0
|
||||
return Math.round(
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['perf-metrics', props.model.model_name],
|
||||
queryFn: () => getPerfMetrics(props.model.model_name, 24),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
const groups = useMemo(
|
||||
() => metricsQuery.data?.data.groups ?? [],
|
||||
[metricsQuery.data]
|
||||
)
|
||||
const performances = useMemo<PerformanceRow[]>(
|
||||
() =>
|
||||
groups.map((group) => ({
|
||||
group: group.group,
|
||||
avg_ttft_ms: group.avg_ttft_ms,
|
||||
avg_latency_ms: group.avg_latency_ms,
|
||||
success_rate: group.success_rate,
|
||||
avg_tps: group.avg_tps,
|
||||
})),
|
||||
[groups]
|
||||
)
|
||||
const latencySeries = useMemo(() => toLatencySeries(groups), [groups])
|
||||
const uptimeSeries = useMemo(() => toUptimeSeries(groups), [groups])
|
||||
const uptimeByGroup = useMemo<Record<string, UptimeDayPoint[]>>(() => {
|
||||
const map: Record<string, UptimeDayPoint[]> = {}
|
||||
for (const group of groups) {
|
||||
map[group.group] = toGroupUptimeSeries(group)
|
||||
}
|
||||
return map
|
||||
}, [groups])
|
||||
|
||||
if (metricsQuery.isLoading || performances.length === 0) {
|
||||
return (
|
||||
<div className='text-muted-foreground rounded-lg border p-6 text-center text-sm'>
|
||||
{t('Performance data is not yet available for this model.')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tpsValues = performances
|
||||
.map((p) => p.avg_tps)
|
||||
.filter((value) => value > 0)
|
||||
const avgTps =
|
||||
tpsValues.length > 0
|
||||
? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
|
||||
: 0
|
||||
const avgLatency = average(performances, 'avg_latency_ms')
|
||||
const successRates = performances
|
||||
.map((perf) => perf.success_rate)
|
||||
.filter((value) => Number.isFinite(value))
|
||||
const successRate =
|
||||
successRates.length > 0
|
||||
? successRates.reduce((sum, value) => sum + value, 0) /
|
||||
successRates.length
|
||||
: 0
|
||||
const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<StatCard
|
||||
icon={Timer}
|
||||
label='TPS'
|
||||
value={formatThroughput(avgTps)}
|
||||
hint={t('Sustained tokens per second')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(avgLatency)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={HeartPulse}
|
||||
label={t('Success rate')}
|
||||
value={formatUptimePct(successRate)}
|
||||
hint={
|
||||
incidentCount > 0
|
||||
? t('{{count}} incidents in the last 24 hours', {
|
||||
count: incidentCount,
|
||||
})
|
||||
: t('No incidents in the last 24 hours')
|
||||
}
|
||||
valueClassName={getSuccessRateTextClass(successRate)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={HeartPulse}
|
||||
title={t('Per-group performance')}
|
||||
description={t('Average latency, TTFT, TPS, and success rate')}
|
||||
/>
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName={tableStyles.compactHeaderRow}
|
||||
data={performances}
|
||||
getRowKey={(perf) => perf.group}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: tableStyles.compactHeaderCell,
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (perf) => <GroupBadge group={perf.group} size='sm' />,
|
||||
},
|
||||
{
|
||||
id: 'tps',
|
||||
header: 'TPS',
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactNumericCell,
|
||||
cell: (perf) => formatThroughput(perf.avg_tps),
|
||||
},
|
||||
{
|
||||
id: 'ttft',
|
||||
header: t('Average TTFT'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactNumericCell,
|
||||
cell: (perf) => formatLatency(perf.avg_ttft_ms),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
header: t('Average latency'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactMutedNumericCell,
|
||||
cell: (perf) => formatLatency(perf.avg_latency_ms),
|
||||
},
|
||||
{
|
||||
id: 'success',
|
||||
header: t('Success rate'),
|
||||
className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (perf) => (
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={Timer}
|
||||
title={t('Latency trend (last 24h)')}
|
||||
description={t('Average TTFT')}
|
||||
/>
|
||||
<LatencyTrendChart series={latencySeries} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={HeartPulse}
|
||||
title={t('Availability (last 24h)')}
|
||||
description={
|
||||
incidentCount > 0
|
||||
? t(
|
||||
'Request success rate; {{incidents}} incident buckets in the last 24 hours',
|
||||
{
|
||||
incidents: incidentCount,
|
||||
}
|
||||
)
|
||||
: t('Request success rate sampled over the last 24 hours')
|
||||
}
|
||||
accent={
|
||||
incidentCount > 0 ? (
|
||||
<span className='inline-flex items-center gap-1 text-amber-600 dark:text-amber-400'>
|
||||
<AlertTriangle className='size-3.5' />
|
||||
{t('{{count}} incidents', {
|
||||
count: incidentCount,
|
||||
})}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<UptimeTrendChart series={uptimeSeries} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description?: string
|
||||
accent?: React.ReactNode
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div className='mb-2 flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
|
||||
<div className='min-w-0'>
|
||||
<div className='text-foreground text-sm font-semibold'>
|
||||
{props.title}
|
||||
</div>
|
||||
{props.description && (
|
||||
<p className='text-muted-foreground/80 text-xs'>
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.accent && (
|
||||
<div className='shrink-0 text-xs font-medium'>{props.accent}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Activity, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
formatUptimePct,
|
||||
getSuccessRateDotClass,
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { aggregateUptime, type UptimeDayPoint } from '../lib/mock-stats'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime sparkline
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Compact 30-day uptime visualisation: a row of small coloured bars where:
|
||||
// - Bar colour reflects per-day uptime (green / amber / red)
|
||||
// - Bar height reflects severity (the worse the day, the shorter the bar)
|
||||
// - Hovering a bar reveals the exact date and uptime
|
||||
//
|
||||
// Useful as a header strip ("at-a-glance" status) and as a per-row visual
|
||||
// inside the per-group performance table.
|
||||
|
||||
type SparklineSize = 'sm' | 'md'
|
||||
|
||||
type UptimeSparklineProps = {
|
||||
series: UptimeDayPoint[]
|
||||
size?: SparklineSize
|
||||
showOverall?: boolean
|
||||
emptyLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function heightFor(uptime: number): string {
|
||||
if (uptime >= 99.9) return 'h-full'
|
||||
if (uptime >= 99.0) return 'h-[88%]'
|
||||
if (uptime >= 95.0) return 'h-[72%]'
|
||||
if (uptime >= 90.0) return 'h-[55%]'
|
||||
return 'h-[40%]'
|
||||
}
|
||||
|
||||
export function UptimeSparkline(props: UptimeSparklineProps) {
|
||||
const size = props.size ?? 'md'
|
||||
const showOverall = props.showOverall ?? true
|
||||
|
||||
if (props.series.length === 0) {
|
||||
return (
|
||||
<span className={cn('text-muted-foreground text-xs', props.className)}>
|
||||
{props.emptyLabel ?? '—'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const overall =
|
||||
props.series.reduce((s, p) => s + p.uptime_pct, 0) / props.series.length
|
||||
|
||||
const containerHeight = size === 'sm' ? 'h-3.5' : 'h-5'
|
||||
const barWidth = size === 'sm' ? 'w-[3px]' : 'w-1'
|
||||
const gap = size === 'sm' ? 'gap-px' : 'gap-[2px]'
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', props.className)}>
|
||||
<div
|
||||
className={cn('flex items-end', containerHeight, gap)}
|
||||
role='img'
|
||||
aria-label={`30 day uptime ${overall.toFixed(2)}%`}
|
||||
>
|
||||
{props.series.map((day) => (
|
||||
<Tooltip key={day.date}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-sm transition-opacity hover:opacity-80',
|
||||
barWidth,
|
||||
containerHeight,
|
||||
'flex items-end'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-sm',
|
||||
getSuccessRateDotClass(day.uptime_pct),
|
||||
heightFor(day.uptime_pct)
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='font-mono text-xs'>
|
||||
<div className='font-medium'>{day.date}</div>
|
||||
<div>{day.uptime_pct.toFixed(2)}%</div>
|
||||
{day.outage_minutes > 0 && (
|
||||
<div className='text-muted-foreground'>
|
||||
{day.outage_minutes} min outage
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
{showOverall && (
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-sm font-semibold tabular-nums',
|
||||
getSuccessRateTextClass(overall)
|
||||
)}
|
||||
>
|
||||
{overall.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime status row — sparkline + summary text + status icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UptimeStatusRow(props: {
|
||||
series: UptimeDayPoint[]
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const summary = useMemo(() => aggregateUptime(props.series), [props.series])
|
||||
const status = useMemo(() => {
|
||||
if (summary.uptime_pct >= 99.9) return 'operational'
|
||||
if (summary.uptime_pct >= 99.0) return 'minor'
|
||||
if (summary.uptime_pct >= 95.0) return 'degraded'
|
||||
return 'major'
|
||||
}, [summary.uptime_pct])
|
||||
|
||||
const StatusIcon =
|
||||
status === 'operational'
|
||||
? CheckCircle2
|
||||
: status === 'minor'
|
||||
? Activity
|
||||
: AlertCircle
|
||||
|
||||
const statusColour =
|
||||
status === 'operational'
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: status === 'minor'
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: status === 'degraded'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-rose-600 dark:text-rose-400'
|
||||
|
||||
const statusLabel =
|
||||
status === 'operational'
|
||||
? t('All systems operational')
|
||||
: status === 'minor'
|
||||
? t('Minor blips in the last 30 days')
|
||||
: status === 'degraded'
|
||||
? t('Degraded performance recently')
|
||||
: t('Significant outages detected')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/30 flex flex-wrap items-center gap-3 rounded-lg border px-3 py-2 sm:gap-4 sm:px-4',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<StatusIcon className={cn('size-4 shrink-0', statusColour)} />
|
||||
<span className='text-sm font-medium'>{t('Last 30 days uptime')}</span>
|
||||
</div>
|
||||
|
||||
<UptimeSparkline series={props.series} className='ml-auto' />
|
||||
|
||||
<div className='flex items-center gap-3 text-xs'>
|
||||
<span className={cn('font-medium', statusColour)}>{statusLabel}</span>
|
||||
{summary.incidents > 0 && (
|
||||
<span className='text-muted-foreground'>
|
||||
{summary.incidents}{' '}
|
||||
{summary.incidents === 1 ? t('incident') : t('incidents')}
|
||||
</span>
|
||||
)}
|
||||
{summary.outage_minutes > 0 && (
|
||||
<span className='text-muted-foreground'>
|
||||
{summary.outage_minutes} {t('min downtime')}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-muted-foreground hidden sm:inline'>
|
||||
{formatUptimePct(summary.uptime_pct)} {t('overall')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1351
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getSuccessRateDotClass } from '@/features/performance-metrics/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ModelPerfBadgeData = {
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
avg_tps: number
|
||||
recent_success_rates?: number[]
|
||||
}
|
||||
|
||||
export interface ModelPerfBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
perf: ModelPerfBadgeData | undefined
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '—'
|
||||
return value > 1 ? String(Math.round(value)) : value.toFixed(1)
|
||||
}
|
||||
|
||||
function formatCompactLatency(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
if (ms >= 1_000) return `${formatCompactNumber(ms / 1_000)}s`
|
||||
return `${formatCompactNumber(ms)}ms`
|
||||
}
|
||||
|
||||
function formatCompactThroughput(tps: number): string {
|
||||
if (!Number.isFinite(tps) || tps <= 0) return '—'
|
||||
if (tps >= 1_000) return `${formatCompactNumber(tps / 1_000)}Kt`
|
||||
return `${formatCompactNumber(tps)}t`
|
||||
}
|
||||
|
||||
export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
props: ModelPerfBadgeProps
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!props.perf) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { avg_latency_ms, avg_tps, success_rate } = props.perf
|
||||
|
||||
const recentRates =
|
||||
props.perf.recent_success_rates?.filter((rate) => Number.isFinite(rate)) ??
|
||||
[]
|
||||
const statusRates =
|
||||
recentRates.length > 0 ? recentRates.slice(-3) : [success_rate]
|
||||
const statusBars = [
|
||||
...Array(Math.max(0, 3 - statusRates.length)).fill(null),
|
||||
...statusRates,
|
||||
].slice(-3)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'hidden w-[132px] grid-cols-[38px_48px_30px] gap-x-2 text-right tabular-nums min-[460px]:grid',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div title={t('Average latency')} className='min-w-0'>
|
||||
<div className='text-muted-foreground/55 text-[10px] leading-4'>
|
||||
{t('Latency short')}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 font-mono text-xs leading-4 whitespace-nowrap'>
|
||||
{formatCompactLatency(avg_latency_ms)}
|
||||
</div>
|
||||
</div>
|
||||
<div title={t('Throughput')} className='min-w-0'>
|
||||
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
|
||||
{t('Throughput short')}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 font-mono text-xs leading-4 whitespace-nowrap'>
|
||||
{formatCompactThroughput(avg_tps)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
title={`${t('Success rate')}: ${success_rate.toFixed(1)}%`}
|
||||
className='min-w-0'
|
||||
>
|
||||
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
|
||||
{t('Status short')}
|
||||
</div>
|
||||
<div className='flex h-4 items-center justify-end gap-0.5'>
|
||||
{statusBars.map((rate, index) => (
|
||||
<span
|
||||
key={`${index}-${rate ?? 'empty'}`}
|
||||
className={cn(
|
||||
'w-1 rounded-full',
|
||||
index === 0 && 'h-2',
|
||||
index === 1 && 'h-2.5',
|
||||
index === 2 && 'h-3',
|
||||
rate == null
|
||||
? index === 0
|
||||
? 'bg-muted-foreground/10'
|
||||
: 'bg-muted-foreground/15'
|
||||
: getSuccessRateDotClass(rate)
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BadgeCell,
|
||||
BadgeListCell,
|
||||
DataTableColumnHeader,
|
||||
} from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
|
||||
import { DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import {
|
||||
getDynamicDisplayGroupRatio,
|
||||
getDynamicPricingSummary,
|
||||
} from '../lib/dynamic-price'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import {
|
||||
formatPrice,
|
||||
formatRequestPrice,
|
||||
stripTrailingZeros,
|
||||
} from '../lib/price'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { ModelBillingModeBadge } from './model-billing-mode-badge'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pricing Table Columns
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface PricingColumnsOptions {
|
||||
tokenUnit?: TokenUnit
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
showRechargePrice?: boolean
|
||||
selectedGroup?: string
|
||||
}
|
||||
|
||||
export function usePricingColumns(
|
||||
options: PricingColumnsOptions = {}
|
||||
): ColumnDef<PricingModel>[] {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
tokenUnit = DEFAULT_TOKEN_UNIT,
|
||||
priceRate = 1,
|
||||
usdExchangeRate = 1,
|
||||
showRechargePrice = false,
|
||||
selectedGroup,
|
||||
} = options
|
||||
|
||||
const tokenUnitLabel = tokenUnit === 'K' ? '1K' : '1M'
|
||||
|
||||
return [
|
||||
// Model column
|
||||
{
|
||||
accessorKey: 'model_name',
|
||||
meta: { label: t('Model') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Model')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const modelIconKey = model.icon || model.vendor_icon
|
||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 14) : null
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full min-w-0 items-center gap-2'>
|
||||
{modelIcon}
|
||||
<span className='truncate font-mono text-sm font-medium'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
minSize: 200,
|
||||
},
|
||||
|
||||
// Type column
|
||||
{
|
||||
accessorKey: 'quota_type',
|
||||
header: t('Type'),
|
||||
cell: ({ row }) => (
|
||||
<ModelBillingModeBadge model={row.original} className='-ml-1.5' />
|
||||
),
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Price column
|
||||
{
|
||||
accessorKey: 'price',
|
||||
meta: { label: t('Price') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Price')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: getDynamicDisplayGroupRatio(
|
||||
model,
|
||||
selectedGroup
|
||||
),
|
||||
})
|
||||
|
||||
if (dynamicSummary) {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<div className='text-xs font-medium text-amber-700 dark:text-amber-300'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
{t('Unable to parse structured pricing')}
|
||||
</div>
|
||||
<code className='text-muted-foreground/70 mt-1 line-clamp-2 block font-mono text-[10px] leading-relaxed break-all'>
|
||||
{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='max-w-full min-w-0'>
|
||||
<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) {
|
||||
const inputPrice = stripTrailingZeros(
|
||||
formatPrice(
|
||||
model,
|
||||
'input',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
selectedGroup
|
||||
)
|
||||
)
|
||||
const outputPrice = stripTrailingZeros(
|
||||
formatPrice(
|
||||
model,
|
||||
'output',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
selectedGroup
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{inputPrice}
|
||||
<span className='text-muted-foreground/40 mx-1'>/</span>
|
||||
{outputPrice}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel} tokens
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const price = stripTrailingZeros(
|
||||
formatRequestPrice(
|
||||
model,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
selectedGroup
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>{price}</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {t('request')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Cached price column (Vercel AI Gateway style)
|
||||
{
|
||||
id: 'cached_price',
|
||||
header: t('Cached'),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const dynamicSummary = getDynamicPricingSummary(model, {
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
groupRatioMultiplier: getDynamicDisplayGroupRatio(
|
||||
model,
|
||||
selectedGroup
|
||||
),
|
||||
})
|
||||
|
||||
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='max-w-full min-w-0'>
|
||||
<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) {
|
||||
return <span className='text-muted-foreground/30 text-xs'>—</span>
|
||||
}
|
||||
|
||||
const cachedPrice = stripTrailingZeros(
|
||||
formatPrice(
|
||||
model,
|
||||
'cache',
|
||||
tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
selectedGroup
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='max-w-full min-w-0'>
|
||||
<span className='font-mono text-sm tabular-nums'>
|
||||
{cachedPrice}
|
||||
</span>
|
||||
<div className='text-muted-foreground/50 text-[10px]'>
|
||||
/ {tokenUnitLabel}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Vendor column
|
||||
{
|
||||
accessorKey: 'vendor_name',
|
||||
header: t('Vendor'),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
if (!model.vendor_name) {
|
||||
return <span className='text-muted-foreground/50 text-xs'>—</span>
|
||||
}
|
||||
const vendorIcon = model.vendor_icon
|
||||
? getLobeIcon(model.vendor_icon, 12)
|
||||
: null
|
||||
return (
|
||||
<BadgeCell className='gap-1.5'>
|
||||
{vendorIcon}
|
||||
<StatusBadge
|
||||
label={model.vendor_name}
|
||||
autoColor={model.vendor_name}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</BadgeCell>
|
||||
)
|
||||
},
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Tags column
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
header: t('Tags'),
|
||||
cell: ({ row }) => {
|
||||
const tags = parseTags(row.original.tags)
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={tags.map((tag) => (
|
||||
<StatusBadge
|
||||
key={tag}
|
||||
label={tag}
|
||||
autoColor={tag}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Endpoints column
|
||||
{
|
||||
accessorKey: 'supported_endpoint_types',
|
||||
header: t('Endpoints'),
|
||||
cell: ({ row }) => {
|
||||
const endpoints = row.original.supported_endpoint_types || []
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={endpoints.map((ep) => (
|
||||
<StatusBadge
|
||||
key={ep}
|
||||
label={ep}
|
||||
autoColor={ep}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Enable Groups column
|
||||
{
|
||||
accessorKey: 'enable_groups',
|
||||
header: t('Groups'),
|
||||
cell: ({ row }) => {
|
||||
const groups = row.original.enable_groups || []
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={groups.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='sm' />
|
||||
))}
|
||||
tooltipClassName='max-w-[280px] p-2'
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ChevronDown, RotateCcw } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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-md px-1.5 py-0.5 text-[12px]',
|
||||
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-[panel-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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Row, PaginationState } from '@tanstack/react-table'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DataTablePagination,
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
|
||||
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { usePricingColumns } from './pricing-columns'
|
||||
|
||||
export interface PricingTableProps {
|
||||
models: PricingModel[]
|
||||
isLoading?: boolean
|
||||
priceRate?: number
|
||||
usdExchangeRate?: number
|
||||
tokenUnit?: TokenUnit
|
||||
showRechargePrice?: boolean
|
||||
selectedGroup?: string
|
||||
onModelClick?: (modelName: string) => void
|
||||
}
|
||||
|
||||
export function PricingTable(props: PricingTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
models,
|
||||
isLoading = false,
|
||||
priceRate = 1,
|
||||
usdExchangeRate = 1,
|
||||
tokenUnit = DEFAULT_TOKEN_UNIT,
|
||||
showRechargePrice = false,
|
||||
selectedGroup,
|
||||
onModelClick,
|
||||
} = props
|
||||
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: DEFAULT_PRICING_PAGE_SIZE,
|
||||
})
|
||||
|
||||
const columns = usePricingColumns({
|
||||
tokenUnit,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
showRechargePrice,
|
||||
selectedGroup,
|
||||
})
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: models,
|
||||
columns,
|
||||
pageCount: Math.ceil(models.length / pagination.pageSize),
|
||||
pagination,
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: false,
|
||||
withFilteredRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(model: PricingModel) => {
|
||||
onModelClick?.(model.model_name)
|
||||
},
|
||||
[onModelClick]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<DataTableView
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={t('No Models Found')}
|
||||
emptyDescription={t('No models match your current filters.')}
|
||||
skeletonKeyPrefix='pricing-skeleton'
|
||||
applyHeaderSize
|
||||
getColumnClassName={(_columnId, kind) =>
|
||||
kind === 'header' ? 'text-muted-foreground font-medium' : undefined
|
||||
}
|
||||
renderRow={(row: Row<PricingModel>) => (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className='hover:bg-muted/30 cursor-pointer transition-colors'
|
||||
onClick={() => handleRowClick(row.original)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isLoading && models.length > 0 && <DataTablePagination table={table} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ArrowUpDown, Check, Filter, Grid2X2, Table2 } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFormClassName,
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
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 { cn } from '@/lib/utils'
|
||||
|
||||
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-lg 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-md text-xs font-medium transition-all',
|
||||
Icon && !option.label ? 'w-7' : 'gap-1.5 px-3',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground 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 render={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 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
|
||||
render={
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-3 text-xs'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ArrowUpDown className='size-3.5' />
|
||||
<span>{sortLabels[props.sortBy as SortOption] || t('Sort')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-44'>
|
||||
{Object.entries(sortLabels).map(([value, label]) => (
|
||||
<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={sideDrawerContentClassName('sm:max-w-md')}
|
||||
>
|
||||
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||
<SheetTitle>{t('Filter')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('Filter models by provider, group, type, endpoint, and tags.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className={sideDrawerFormClassName('gap-0')}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SearchBarProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onClear: () => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SearchBar(props: SearchBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn('relative', props.className)}>
|
||||
<Search className='text-muted-foreground/60 pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2' />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
placeholder={props.placeholder || t('Search models...')}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
className={cn(
|
||||
'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')}
|
||||
/>
|
||||
<div className='absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1'>
|
||||
{props.value ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={props.onClear}
|
||||
className='text-muted-foreground/60 hover:text-foreground size-7'
|
||||
aria-label={t('Clear search')}
|
||||
>
|
||||
<X className='size-4' />
|
||||
</Button>
|
||||
) : (
|
||||
<kbd className='bg-muted text-muted-foreground pointer-events-none hidden rounded border px-1.5 py-0.5 font-mono text-[10px] sm:inline-block'>
|
||||
⌘K
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type TFunction } from 'i18next'
|
||||
|
||||
import type { TokenUnit } from './types'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pricing Constants
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** Sort options for pricing models */
|
||||
export const SORT_OPTIONS = {
|
||||
NAME: 'name',
|
||||
PRICE_LOW: 'price-low',
|
||||
PRICE_HIGH: 'price-high',
|
||||
} as const
|
||||
|
||||
export type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS]
|
||||
|
||||
export function getSortLabels(t: TFunction): Record<SortOption, string> {
|
||||
return {
|
||||
[SORT_OPTIONS.NAME]: t('Name'),
|
||||
[SORT_OPTIONS.PRICE_LOW]: t('Price: Low to High'),
|
||||
[SORT_OPTIONS.PRICE_HIGH]: t('Price: High to Low'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Filter values */
|
||||
export const FILTER_ALL = 'all'
|
||||
|
||||
/** Quota type options */
|
||||
export const QUOTA_TYPES = {
|
||||
ALL: 'all',
|
||||
TOKEN: 'token',
|
||||
REQUEST: 'request',
|
||||
} as const
|
||||
|
||||
export type QuotaTypeOption = (typeof QUOTA_TYPES)[keyof typeof QUOTA_TYPES]
|
||||
|
||||
/** Quota type labels */
|
||||
export function getQuotaTypeLabels(
|
||||
t: TFunction
|
||||
): Record<QuotaTypeOption, string> {
|
||||
return {
|
||||
[QUOTA_TYPES.ALL]: t('All Models'),
|
||||
[QUOTA_TYPES.TOKEN]: t('Token-based'),
|
||||
[QUOTA_TYPES.REQUEST]: t('Per Request'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Endpoint type options */
|
||||
export const ENDPOINT_TYPES = {
|
||||
ALL: 'all',
|
||||
OPENAI: 'openai',
|
||||
OPENAI_RESPONSE: 'openai-response',
|
||||
ANTHROPIC: 'anthropic',
|
||||
GEMINI: 'gemini',
|
||||
JINA_RERANK: 'jina-rerank',
|
||||
IMAGE_GENERATION: 'image-generation',
|
||||
EMBEDDINGS: 'embeddings',
|
||||
OPENAI_VIDEO: 'openai-video',
|
||||
} as const
|
||||
|
||||
export type EndpointTypeOption =
|
||||
(typeof ENDPOINT_TYPES)[keyof typeof ENDPOINT_TYPES]
|
||||
|
||||
/** Endpoint type labels */
|
||||
export function getEndpointTypeLabels(
|
||||
t: TFunction
|
||||
): Record<EndpointTypeOption, string> {
|
||||
return {
|
||||
[ENDPOINT_TYPES.ALL]: t('All Types'),
|
||||
[ENDPOINT_TYPES.OPENAI]: 'Chat',
|
||||
[ENDPOINT_TYPES.OPENAI_RESPONSE]: 'Response',
|
||||
[ENDPOINT_TYPES.ANTHROPIC]: 'Anthropic',
|
||||
[ENDPOINT_TYPES.GEMINI]: 'Gemini',
|
||||
[ENDPOINT_TYPES.JINA_RERANK]: 'Rerank',
|
||||
[ENDPOINT_TYPES.IMAGE_GENERATION]: t('Image'),
|
||||
[ENDPOINT_TYPES.EMBEDDINGS]: t('Embeddings'),
|
||||
[ENDPOINT_TYPES.OPENAI_VIDEO]: t('Video'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Filter section keys */
|
||||
export const FILTER_SECTIONS = {
|
||||
PRICING_TYPE: 'pricingType',
|
||||
ENDPOINT_TYPE: 'endpointType',
|
||||
VENDOR: 'vendor',
|
||||
GROUP: 'group',
|
||||
TAG: 'tag',
|
||||
} as const
|
||||
|
||||
/** Maximum number of tags to display in model row */
|
||||
export const MAX_TAGS_DISPLAY = 5
|
||||
|
||||
/** Maximum number of filter items to display before showing "More..." */
|
||||
export const MAX_FILTER_ITEMS = 5
|
||||
|
||||
/** Sidebar width */
|
||||
export const SIDEBAR_WIDTH = 'w-64'
|
||||
|
||||
/** Excluded groups */
|
||||
export const EXCLUDED_GROUPS = ['', 'auto']
|
||||
|
||||
/** Quota type values */
|
||||
export const QUOTA_TYPE_VALUES = {
|
||||
TOKEN: 0,
|
||||
REQUEST: 1,
|
||||
} as const
|
||||
|
||||
/** Token unit divisors */
|
||||
export const TOKEN_UNIT_DIVISORS = {
|
||||
M: 1,
|
||||
K: 1000,
|
||||
} as const
|
||||
|
||||
/** Default token unit for pricing display */
|
||||
export const DEFAULT_TOKEN_UNIT: TokenUnit = 'M'
|
||||
|
||||
/** View mode options */
|
||||
export const VIEW_MODES = {
|
||||
CARD: 'card',
|
||||
TABLE: 'table',
|
||||
} as const
|
||||
|
||||
export type ViewMode = (typeof VIEW_MODES)[keyof typeof VIEW_MODES]
|
||||
|
||||
/** Default page size for pricing table */
|
||||
export const DEFAULT_PRICING_PAGE_SIZE = 20
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export { useFilters } from './use-filters'
|
||||
export { usePricingData } from './use-pricing-data'
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useSearch } from '@tanstack/react-router'
|
||||
import { useMemo, useCallback, useState } from 'react'
|
||||
|
||||
import {
|
||||
FILTER_ALL,
|
||||
SORT_OPTIONS,
|
||||
QUOTA_TYPES,
|
||||
ENDPOINT_TYPES,
|
||||
DEFAULT_TOKEN_UNIT,
|
||||
VIEW_MODES,
|
||||
type ViewMode,
|
||||
} from '../constants'
|
||||
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 [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 = 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 =
|
||||
filterState.tokenUnit === 'K' ? 'K' : DEFAULT_TOKEN_UNIT
|
||||
const viewMode = normalizeViewMode(filterState.view)
|
||||
const showRechargePrice = filterState.rechargePrice === true
|
||||
|
||||
const updateFilters = useCallback((updates: Record<string, unknown>) => {
|
||||
setFilterState((prev) => {
|
||||
const next: Record<string, unknown> = { ...prev, ...updates }
|
||||
for (const key of Object.keys(next)) {
|
||||
if (next[key] === undefined || next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
}
|
||||
return next as FilterState
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setSearchInput = useCallback(
|
||||
(v: string) => updateFilters({ search: v || undefined }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setSortBy = useCallback(
|
||||
(v: string) =>
|
||||
updateFilters({ sort: v === SORT_OPTIONS.NAME ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setVendorFilter = useCallback(
|
||||
(v: string) => updateFilters({ vendor: v === FILTER_ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setGroupFilter = useCallback(
|
||||
(v: string) => updateFilters({ group: v === FILTER_ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setQuotaTypeFilter = useCallback(
|
||||
(v: string) =>
|
||||
updateFilters({ quotaType: v === QUOTA_TYPES.ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setEndpointTypeFilter = useCallback(
|
||||
(v: string) =>
|
||||
updateFilters({
|
||||
endpointType: v === ENDPOINT_TYPES.ALL ? undefined : v,
|
||||
}),
|
||||
[updateFilters]
|
||||
)
|
||||
const setTagFilter = useCallback(
|
||||
(v: string) => updateFilters({ tag: v === FILTER_ALL ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setTokenUnit = useCallback(
|
||||
(v: TokenUnit) =>
|
||||
updateFilters({ tokenUnit: v === DEFAULT_TOKEN_UNIT ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setViewMode = useCallback(
|
||||
(v: ViewMode) =>
|
||||
updateFilters({ view: v === VIEW_MODES.CARD ? undefined : v }),
|
||||
[updateFilters]
|
||||
)
|
||||
const setShowRechargePrice = useCallback(
|
||||
(v: boolean) => updateFilters({ rechargePrice: v || undefined }),
|
||||
[updateFilters]
|
||||
)
|
||||
|
||||
const availableTags = useMemo(() => {
|
||||
if (!models || models.length === 0) return []
|
||||
return extractAllTags(models)
|
||||
}, [models])
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!models || models.length === 0) return []
|
||||
|
||||
return filterAndSortModels(models, {
|
||||
search: searchInput,
|
||||
vendor: vendorFilter,
|
||||
group: groupFilter,
|
||||
quotaType: quotaTypeFilter,
|
||||
endpointType: endpointTypeFilter,
|
||||
tag: tagFilter,
|
||||
sortBy,
|
||||
})
|
||||
}, [
|
||||
models,
|
||||
searchInput,
|
||||
vendorFilter,
|
||||
groupFilter,
|
||||
quotaTypeFilter,
|
||||
endpointTypeFilter,
|
||||
tagFilter,
|
||||
sortBy,
|
||||
])
|
||||
|
||||
const hasActiveFilters = useMemo(
|
||||
() =>
|
||||
vendorFilter !== FILTER_ALL ||
|
||||
groupFilter !== FILTER_ALL ||
|
||||
quotaTypeFilter !== QUOTA_TYPES.ALL ||
|
||||
endpointTypeFilter !== ENDPOINT_TYPES.ALL ||
|
||||
tagFilter !== FILTER_ALL,
|
||||
[vendorFilter, groupFilter, quotaTypeFilter, endpointTypeFilter, tagFilter]
|
||||
)
|
||||
|
||||
const activeFilterCount = useMemo(
|
||||
() =>
|
||||
(vendorFilter !== FILTER_ALL ? 1 : 0) +
|
||||
(groupFilter !== FILTER_ALL ? 1 : 0) +
|
||||
(quotaTypeFilter !== QUOTA_TYPES.ALL ? 1 : 0) +
|
||||
(endpointTypeFilter !== ENDPOINT_TYPES.ALL ? 1 : 0) +
|
||||
(tagFilter !== FILTER_ALL ? 1 : 0),
|
||||
[vendorFilter, groupFilter, quotaTypeFilter, endpointTypeFilter, tagFilter]
|
||||
)
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
updateFilters({
|
||||
vendor: undefined,
|
||||
group: undefined,
|
||||
quotaType: undefined,
|
||||
endpointType: undefined,
|
||||
tag: undefined,
|
||||
})
|
||||
}, [updateFilters])
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
updateFilters({ search: undefined })
|
||||
}, [updateFilters])
|
||||
|
||||
return {
|
||||
searchInput,
|
||||
sortBy,
|
||||
vendorFilter,
|
||||
groupFilter,
|
||||
quotaTypeFilter,
|
||||
endpointTypeFilter,
|
||||
tagFilter,
|
||||
tokenUnit,
|
||||
viewMode,
|
||||
showRechargePrice,
|
||||
setSearchInput,
|
||||
setSortBy,
|
||||
setVendorFilter,
|
||||
setGroupFilter,
|
||||
setQuotaTypeFilter,
|
||||
setEndpointTypeFilter,
|
||||
setTagFilter,
|
||||
setTokenUnit,
|
||||
setViewMode,
|
||||
setShowRechargePrice,
|
||||
filteredModels,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
availableTags,
|
||||
clearFilters,
|
||||
clearSearch,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
import { getPricing } from '../api'
|
||||
|
||||
export function usePricingData() {
|
||||
const { status } = useStatus()
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['pricing'],
|
||||
queryFn: getPricing,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
// Ensure rates never reach zero to prevent division errors
|
||||
const priceRate = useMemo(
|
||||
() => Math.max((status?.price as number) ?? 1, 0.001),
|
||||
[status?.price]
|
||||
)
|
||||
const usdExchangeRate = useMemo(
|
||||
() => Math.max((status?.usd_exchange_rate as number) ?? priceRate, 0.001),
|
||||
[status?.usd_exchange_rate, priceRate]
|
||||
)
|
||||
|
||||
const models = useMemo(() => {
|
||||
if (!data?.data || !data?.vendors) return []
|
||||
|
||||
const vendorMap = new Map(data.vendors.map((v) => [v.id, v]))
|
||||
|
||||
return data.data.map((model) => {
|
||||
const vendor = model.vendor_id
|
||||
? vendorMap.get(model.vendor_id)
|
||||
: undefined
|
||||
return {
|
||||
...model,
|
||||
key: model.model_name,
|
||||
vendor_name: vendor?.name,
|
||||
vendor_icon: vendor?.icon,
|
||||
vendor_description: vendor?.description,
|
||||
group_ratio: data.group_ratio,
|
||||
}
|
||||
})
|
||||
}, [data])
|
||||
|
||||
return {
|
||||
models,
|
||||
vendors: data?.vendors ?? [],
|
||||
groupRatio: data?.group_ratio ?? {},
|
||||
usableGroup: data?.usable_group ?? {},
|
||||
endpointMap: data?.supported_endpoint ?? {},
|
||||
autoGroups: data?.auto_groups ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
}
|
||||
}
|
||||
Vendored
+289
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { PageTransition } from '@/components/page-transition'
|
||||
|
||||
import {
|
||||
LoadingSkeleton,
|
||||
EmptyState,
|
||||
SearchBar,
|
||||
PricingTable,
|
||||
PricingSidebar,
|
||||
PricingToolbar,
|
||||
ModelCardGrid,
|
||||
ModelDetailsDrawer,
|
||||
} from './components'
|
||||
import { EXCLUDED_GROUPS, VIEW_MODES } from './constants'
|
||||
import { useFilters } from './hooks/use-filters'
|
||||
import { usePricingData } from './hooks/use-pricing-data'
|
||||
|
||||
export function Pricing() {
|
||||
const { t } = useTranslation()
|
||||
const [selectedModelName, setSelectedModelName] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const {
|
||||
models,
|
||||
vendors,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
autoGroups,
|
||||
isLoading,
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
} = usePricingData()
|
||||
|
||||
const {
|
||||
searchInput,
|
||||
sortBy,
|
||||
vendorFilter,
|
||||
groupFilter,
|
||||
quotaTypeFilter,
|
||||
endpointTypeFilter,
|
||||
tagFilter,
|
||||
tokenUnit,
|
||||
viewMode,
|
||||
showRechargePrice,
|
||||
setSearchInput,
|
||||
setSortBy,
|
||||
setVendorFilter,
|
||||
setGroupFilter,
|
||||
setQuotaTypeFilter,
|
||||
setEndpointTypeFilter,
|
||||
setTagFilter,
|
||||
setTokenUnit,
|
||||
setViewMode,
|
||||
setShowRechargePrice,
|
||||
filteredModels,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
availableTags,
|
||||
clearFilters,
|
||||
clearSearch,
|
||||
} = useFilters(models || [])
|
||||
|
||||
const handleModelClick = useCallback((modelName: string) => {
|
||||
setSelectedModelName(modelName)
|
||||
}, [])
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() =>
|
||||
selectedModelName
|
||||
? (models || []).find(
|
||||
(model) => model.model_name === selectedModelName
|
||||
) || null
|
||||
: null,
|
||||
[models, selectedModelName]
|
||||
)
|
||||
|
||||
const availableGroups = useMemo(
|
||||
() =>
|
||||
Object.keys(usableGroup || {}).filter(
|
||||
(g) => !EXCLUDED_GROUPS.includes(g)
|
||||
),
|
||||
[usableGroup]
|
||||
)
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
clearFilters()
|
||||
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}
|
||||
selectedGroup={groupFilter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingTable
|
||||
models={filteredModels}
|
||||
priceRate={priceRate}
|
||||
usdExchangeRate={usdExchangeRate}
|
||||
tokenUnit={tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
selectedGroup={groupFilter}
|
||||
onModelClick={handleModelClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
|
||||
<LoadingSkeleton viewMode={viewMode} />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
|
||||
<header className='mx-auto mb-5 max-w-3xl pt-5 text-center sm:mb-10 sm:pt-10'>
|
||||
<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-3 text-sm sm:mt-4 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-4 max-w-2xl sm:mt-6'
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className='grid gap-4 xl: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={clearFilters}
|
||||
className='hover-scrollbar sticky top-4 hidden max-h-[calc(100dvh-2rem)] self-start overflow-y-auto xl:block'
|
||||
/>
|
||||
|
||||
<main className='min-w-0 space-y-4'>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</PageTransition>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
+779
@@ -0,0 +1,779 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/**
|
||||
* Billing expression parsing utilities.
|
||||
*
|
||||
* Parses the dynamic billing expression format so that the pricing breakdown
|
||||
* UI can be rendered from the same backend expressions.
|
||||
*
|
||||
* The grammar is intentionally narrow: we only support the shapes that the
|
||||
* server emits (tiered pricing + request-rule conditional multipliers), so
|
||||
* the regular expressions are exact rather than tolerant of arbitrary
|
||||
* expression syntax.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BillingVar = {
|
||||
key: string
|
||||
field: string | null
|
||||
tierField: string | null
|
||||
label: string
|
||||
shortLabel: string
|
||||
side: 'input' | 'output' | 'condition'
|
||||
isBase?: boolean
|
||||
isConditionOnly?: boolean
|
||||
group?: string
|
||||
}
|
||||
|
||||
export const BILLING_VARS: BillingVar[] = [
|
||||
{
|
||||
key: 'p',
|
||||
field: 'inputPrice',
|
||||
tierField: 'input_unit_cost',
|
||||
label: 'Input price',
|
||||
shortLabel: 'Input',
|
||||
side: 'input',
|
||||
isBase: true,
|
||||
},
|
||||
{
|
||||
key: 'c',
|
||||
field: 'outputPrice',
|
||||
tierField: 'output_unit_cost',
|
||||
label: 'Completion price',
|
||||
shortLabel: 'Output',
|
||||
side: 'output',
|
||||
isBase: true,
|
||||
},
|
||||
{
|
||||
key: 'len',
|
||||
field: null,
|
||||
tierField: null,
|
||||
label: 'Input length',
|
||||
shortLabel: 'Length',
|
||||
side: 'condition',
|
||||
isConditionOnly: true,
|
||||
},
|
||||
{
|
||||
key: 'cr',
|
||||
field: 'cacheReadPrice',
|
||||
tierField: 'cache_read_unit_cost',
|
||||
label: 'Cache read price',
|
||||
shortLabel: 'Cache Read',
|
||||
side: 'input',
|
||||
group: 'cache',
|
||||
},
|
||||
{
|
||||
key: 'cc',
|
||||
field: 'cacheCreatePrice',
|
||||
tierField: 'cache_create_unit_cost',
|
||||
label: 'Cache create price',
|
||||
shortLabel: 'Cache Write',
|
||||
side: 'input',
|
||||
group: 'cache',
|
||||
},
|
||||
{
|
||||
key: 'cc1h',
|
||||
field: 'cacheCreate1hPrice',
|
||||
tierField: 'cache_create_1h_unit_cost',
|
||||
label: 'Cache create (1h) price',
|
||||
shortLabel: 'Cache Write (1h)',
|
||||
side: 'input',
|
||||
group: 'cache',
|
||||
},
|
||||
{
|
||||
key: 'img',
|
||||
field: 'imagePrice',
|
||||
tierField: 'image_unit_cost',
|
||||
label: 'Image input price',
|
||||
shortLabel: 'Image In',
|
||||
side: 'input',
|
||||
group: 'media',
|
||||
},
|
||||
{
|
||||
key: 'img_o',
|
||||
field: 'imageOutputPrice',
|
||||
tierField: 'image_output_unit_cost',
|
||||
label: 'Image output price',
|
||||
shortLabel: 'Image Out',
|
||||
side: 'output',
|
||||
group: 'media',
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
field: 'audioInputPrice',
|
||||
tierField: 'audio_input_unit_cost',
|
||||
label: 'Audio input price',
|
||||
shortLabel: 'Audio In',
|
||||
side: 'input',
|
||||
group: 'media',
|
||||
},
|
||||
{
|
||||
key: 'ao',
|
||||
field: 'audioOutputPrice',
|
||||
tierField: 'audio_output_unit_cost',
|
||||
label: 'Audio output price',
|
||||
shortLabel: 'Audio Out',
|
||||
side: 'output',
|
||||
group: 'media',
|
||||
},
|
||||
]
|
||||
|
||||
/** Vars that have real price fields (excludes condition-only vars like `len`) */
|
||||
export const BILLING_PRICING_VARS: BillingVar[] = BILLING_VARS.filter(
|
||||
(v) => !v.isConditionOnly
|
||||
)
|
||||
|
||||
/** Vars valid in tier conditions (`p`, `c`, `len`) */
|
||||
export const BILLING_CONDITION_VARS: string[] = BILLING_VARS.filter(
|
||||
(v) => v.isBase || v.isConditionOnly
|
||||
).map((v) => v.key)
|
||||
|
||||
const BILLING_VAR_KEY_TO_FIELD = Object.fromEntries(
|
||||
BILLING_PRICING_VARS.map((v) => [v.key, v.field as string])
|
||||
) as Record<string, string>
|
||||
|
||||
export const BILLING_EXTRA_VARS: BillingVar[] = BILLING_VARS.filter(
|
||||
(v) => !v.isBase && !v.isConditionOnly
|
||||
)
|
||||
|
||||
export const BILLING_CACHE_VAR_MAP = BILLING_EXTRA_VARS.map((v) => ({
|
||||
field: v.tierField as string,
|
||||
exprVar: v.key,
|
||||
}))
|
||||
|
||||
const BILLING_VAR_REGEX = new RegExp(
|
||||
`\\b(${BILLING_PRICING_VARS.map((v) => v.key).join('|')})\\s*\\*\\s*([\\d.eE+-]+)`,
|
||||
'g'
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request rule constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SOURCE_PARAM = 'param'
|
||||
export const SOURCE_HEADER = 'header'
|
||||
export const SOURCE_TIME = 'time'
|
||||
|
||||
export const MATCH_EQ = 'eq'
|
||||
export const MATCH_CONTAINS = 'contains'
|
||||
export const MATCH_GT = 'gt'
|
||||
export const MATCH_GTE = 'gte'
|
||||
export const MATCH_LT = 'lt'
|
||||
export const MATCH_LTE = 'lte'
|
||||
export const MATCH_EXISTS = 'exists'
|
||||
export const MATCH_RANGE = 'range'
|
||||
|
||||
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'] as const
|
||||
export type TimeFunc = (typeof TIME_FUNCS)[number]
|
||||
|
||||
export const COMMON_TIMEZONES: { value: string; label: string }[] = [
|
||||
{ value: 'Asia/Shanghai', label: 'UTC+8 Shanghai (Asia/Shanghai)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
{ value: 'America/New_York', label: 'UTC-5 New York (America/New_York)' },
|
||||
{
|
||||
value: 'America/Los_Angeles',
|
||||
label: 'UTC-8 Los Angeles (America/Los_Angeles)',
|
||||
},
|
||||
{ value: 'America/Chicago', label: 'UTC-6 Chicago (America/Chicago)' },
|
||||
{ value: 'Europe/London', label: 'UTC+0 London (Europe/London)' },
|
||||
{ value: 'Europe/Berlin', label: 'UTC+1 Berlin (Europe/Berlin)' },
|
||||
{ value: 'Asia/Tokyo', label: 'UTC+9 Tokyo (Asia/Tokyo)' },
|
||||
{ value: 'Asia/Singapore', label: 'UTC+8 Singapore (Asia/Singapore)' },
|
||||
{ value: 'Asia/Seoul', label: 'UTC+9 Seoul (Asia/Seoul)' },
|
||||
{ value: 'Australia/Sydney', label: 'UTC+10 Sydney (Australia/Sydney)' },
|
||||
]
|
||||
|
||||
const NUMERIC_LITERAL_REGEX = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/
|
||||
|
||||
export type ParamHeaderCondition = {
|
||||
source: 'param' | 'header'
|
||||
path: string
|
||||
mode: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type TimeCondition = {
|
||||
source: 'time'
|
||||
timeFunc: TimeFunc
|
||||
timezone: string
|
||||
mode: string
|
||||
value: string
|
||||
rangeStart: string
|
||||
rangeEnd: string
|
||||
}
|
||||
|
||||
export type RequestCondition = TimeCondition | ParamHeaderCondition
|
||||
|
||||
export type RequestRuleGroup = {
|
||||
conditions: RequestCondition[]
|
||||
multiplier: string
|
||||
}
|
||||
|
||||
export type TierCondition = {
|
||||
var: 'p' | 'c' | 'len'
|
||||
op: '<' | '<=' | '>' | '>='
|
||||
value: number
|
||||
}
|
||||
|
||||
export type ParsedTier = {
|
||||
label: string
|
||||
conditions: TierCondition[]
|
||||
[field: string]: unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripExprVersion(exprStr: string): { version: number; body: string } {
|
||||
if (!exprStr) return { version: 1, body: '' }
|
||||
const m = exprStr.match(/^v(\d+):([\s\S]*)$/)
|
||||
if (m) return { version: Number(m[1]), body: m[2] }
|
||||
return { version: 1, body: exprStr }
|
||||
}
|
||||
|
||||
function parseTierBody(bodyStr: string): Record<string, number> {
|
||||
const coeffs: Record<string, number> = {}
|
||||
const re = new RegExp(BILLING_VAR_REGEX.source, 'g')
|
||||
let m
|
||||
while ((m = re.exec(bodyStr)) !== null) {
|
||||
if (!(m[1] in coeffs)) coeffs[m[1]] = Number(m[2])
|
||||
}
|
||||
const tier: Record<string, number> = {}
|
||||
for (const [varName, field] of Object.entries(BILLING_VAR_KEY_TO_FIELD)) {
|
||||
tier[field] = coeffs[varName] || 0
|
||||
}
|
||||
return tier
|
||||
}
|
||||
|
||||
export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
|
||||
if (!exprStr) return []
|
||||
try {
|
||||
const { body } = stripExprVersion(exprStr)
|
||||
const condGroup =
|
||||
`((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` +
|
||||
`(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`
|
||||
const tierRe = new RegExp(
|
||||
`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*([^)]+)\\)`,
|
||||
'g'
|
||||
)
|
||||
const tiers: ParsedTier[] = []
|
||||
let m
|
||||
while ((m = tierRe.exec(body)) !== null) {
|
||||
const condStr = m[1] || ''
|
||||
const conditions: TierCondition[] = []
|
||||
if (condStr) {
|
||||
for (const cp of condStr.split(/\s*&&\s*/)) {
|
||||
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/)
|
||||
if (cm) {
|
||||
conditions.push({
|
||||
var: cm[1] as TierCondition['var'],
|
||||
op: cm[2] as TierCondition['op'],
|
||||
value: Number(cm[3]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const tier = parseTierBody(m[3]) as ParsedTier
|
||||
tier.label = m[2]
|
||||
tier.conditions = conditions
|
||||
tiers.push(tier)
|
||||
}
|
||||
return tiers
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTierLabel(label: string | undefined): string {
|
||||
if (!label) return ''
|
||||
return label
|
||||
.replace(/<[==]?|≤|<[==]?/g, '<')
|
||||
.replace(/>[==]?|≥|>[==]?/g, '>')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request rule parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function splitTopLevelMultiply(expr: string): string[] {
|
||||
const parts: string[] = []
|
||||
let start = 0
|
||||
let depth = 0
|
||||
for (let index = 0; index < expr.length; index += 1) {
|
||||
const char = expr[index]
|
||||
if (char === '(') depth += 1
|
||||
if (char === ')') depth -= 1
|
||||
if (depth === 0 && expr.slice(index, index + 3) === ' * ') {
|
||||
parts.push(expr.slice(start, index).trim())
|
||||
start = index + 3
|
||||
index += 2
|
||||
}
|
||||
}
|
||||
parts.push(expr.slice(start).trim())
|
||||
return parts.filter(Boolean)
|
||||
}
|
||||
|
||||
function splitTopLevelAnd(expr: string): string[] {
|
||||
const parts: string[] = []
|
||||
let start = 0
|
||||
let depth = 0
|
||||
for (let i = 0; i < expr.length; i += 1) {
|
||||
const c = expr[i]
|
||||
if (c === '(') depth += 1
|
||||
if (c === ')') depth -= 1
|
||||
if (depth === 0 && expr.slice(i, i + 4) === ' && ') {
|
||||
parts.push(expr.slice(start, i).trim())
|
||||
start = i + 4
|
||||
i += 3
|
||||
}
|
||||
}
|
||||
parts.push(expr.slice(start).trim())
|
||||
return parts.filter(Boolean)
|
||||
}
|
||||
|
||||
function parseExprLiteral(raw: string): string | null {
|
||||
const text = raw.trim()
|
||||
if (text === 'true' || text === 'false') return text
|
||||
if (NUMERIC_LITERAL_REGEX.test(text)) return text
|
||||
try {
|
||||
return JSON.parse(text) as string
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseTimeCondition(expr: string): RequestCondition | null {
|
||||
let m = expr.match(
|
||||
/^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)$/
|
||||
)
|
||||
if (m) {
|
||||
return {
|
||||
source: 'time',
|
||||
timeFunc: m[1] as TimeFunc,
|
||||
timezone: m[2],
|
||||
mode: MATCH_RANGE,
|
||||
value: '',
|
||||
rangeStart: m[3],
|
||||
rangeEnd: m[4],
|
||||
}
|
||||
}
|
||||
m = expr.match(
|
||||
/^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)\)$/
|
||||
)
|
||||
if (m) {
|
||||
return {
|
||||
source: 'time',
|
||||
timeFunc: m[1] as TimeFunc,
|
||||
timezone: m[2],
|
||||
mode: MATCH_RANGE,
|
||||
value: '',
|
||||
rangeStart: m[3],
|
||||
rangeEnd: m[4],
|
||||
}
|
||||
}
|
||||
m = expr.match(
|
||||
/^(hour|minute|weekday|month|day)\("([^"]+)"\) (==|>=|<) ([\d.eE+-]+)$/
|
||||
)
|
||||
if (m) {
|
||||
const opMap: Record<string, string> = {
|
||||
'==': MATCH_EQ,
|
||||
'>=': MATCH_GTE,
|
||||
'<': MATCH_LT,
|
||||
}
|
||||
return {
|
||||
source: 'time',
|
||||
timeFunc: m[1] as TimeFunc,
|
||||
timezone: m[2],
|
||||
mode: opMap[m[3]] || MATCH_EQ,
|
||||
value: m[4],
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function tryParseRequestCondition(expr: string): RequestCondition | null {
|
||||
const tc = tryParseTimeCondition(expr)
|
||||
if (tc) return tc
|
||||
|
||||
let m = expr.match(/^header\("([^"]+)"\) != ""$/)
|
||||
if (m) return { source: 'header', path: m[1], mode: MATCH_EXISTS, value: '' }
|
||||
|
||||
m = expr.match(/^param\("([^"]+)"\) != nil$/)
|
||||
if (m) return { source: 'param', path: m[1], mode: MATCH_EXISTS, value: '' }
|
||||
|
||||
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/)
|
||||
if (m)
|
||||
return {
|
||||
source: 'header',
|
||||
path: m[1],
|
||||
mode: MATCH_CONTAINS,
|
||||
value: JSON.parse(m[2]) as string,
|
||||
}
|
||||
|
||||
m = expr.match(
|
||||
/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/
|
||||
)
|
||||
if (m && m[1] === m[2])
|
||||
return {
|
||||
source: 'param',
|
||||
path: m[1],
|
||||
mode: MATCH_CONTAINS,
|
||||
value: JSON.parse(m[3]) as string,
|
||||
}
|
||||
|
||||
m = expr.match(
|
||||
/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/
|
||||
)
|
||||
if (m && m[1] === m[2]) {
|
||||
const opMap: Record<string, string> = {
|
||||
'>': MATCH_GT,
|
||||
'>=': MATCH_GTE,
|
||||
'<': MATCH_LT,
|
||||
'<=': MATCH_LTE,
|
||||
}
|
||||
return { source: 'param', path: m[1], mode: opMap[m[3]], value: m[4] }
|
||||
}
|
||||
|
||||
m = expr.match(/^(param|header)\("([^"]+)"\) == (.+)$/)
|
||||
if (m) {
|
||||
const parsedValue = parseExprLiteral(m[3])
|
||||
if (parsedValue === null) return null
|
||||
return {
|
||||
source: m[1] as 'param' | 'header',
|
||||
path: m[2],
|
||||
mode: MATCH_EQ,
|
||||
value: String(parsedValue),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function tryParseRuleGroupFactor(part: string): RequestRuleGroup | null {
|
||||
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s)
|
||||
if (!m) return null
|
||||
|
||||
const conditionStr = m[1]
|
||||
const multiplier = m[2]
|
||||
|
||||
const andParts = splitTopLevelAnd(conditionStr)
|
||||
const conditions: RequestCondition[] = []
|
||||
for (const ap of andParts) {
|
||||
const cond = tryParseRequestCondition(ap.trim())
|
||||
if (!cond) return null
|
||||
conditions.push(cond)
|
||||
}
|
||||
if (conditions.length === 0) return null
|
||||
return { conditions, multiplier }
|
||||
}
|
||||
|
||||
export function tryParseRequestRuleExpr(
|
||||
expr: string
|
||||
): RequestRuleGroup[] | null {
|
||||
const trimmed = (expr || '').trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const parts = splitTopLevelMultiply(trimmed)
|
||||
const groups: RequestRuleGroup[] = []
|
||||
for (const part of parts) {
|
||||
const group = tryParseRuleGroupFactor(part)
|
||||
if (!group) return null
|
||||
groups.push(group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combine / split billing expr and request rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hasFullOuterParens(expr: string): boolean {
|
||||
if (!expr.startsWith('(') || !expr.endsWith(')')) return false
|
||||
let depth = 0
|
||||
for (let i = 0; i < expr.length; i += 1) {
|
||||
if (expr[i] === '(') depth += 1
|
||||
if (expr[i] === ')') depth -= 1
|
||||
if (depth === 0 && i < expr.length - 1) return false
|
||||
}
|
||||
return depth === 0
|
||||
}
|
||||
|
||||
function unwrapOuterParens(expr: string): string {
|
||||
let current = (expr || '').trim()
|
||||
while (hasFullOuterParens(current)) {
|
||||
current = current.slice(1, -1).trim()
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function splitBillingExprAndRequestRules(expr: string): {
|
||||
billingExpr: string
|
||||
requestRuleExpr: string
|
||||
} {
|
||||
const trimmed = (expr || '').trim()
|
||||
if (!trimmed) return { billingExpr: '', requestRuleExpr: '' }
|
||||
|
||||
const parts = splitTopLevelMultiply(trimmed)
|
||||
if (parts.length <= 1) return { billingExpr: trimmed, requestRuleExpr: '' }
|
||||
|
||||
const ruleParts: string[] = []
|
||||
const baseParts: string[] = []
|
||||
|
||||
parts.forEach((part) => {
|
||||
const parsed = tryParseRequestRuleExpr(part)
|
||||
if (parsed && parsed.length > 0) {
|
||||
ruleParts.push(part)
|
||||
} else {
|
||||
baseParts.push(part)
|
||||
}
|
||||
})
|
||||
|
||||
if (ruleParts.length === 0 || baseParts.length !== 1) {
|
||||
return { billingExpr: trimmed, requestRuleExpr: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
billingExpr: unwrapOuterParens(baseParts[0]),
|
||||
requestRuleExpr: ruleParts.join(' * '),
|
||||
}
|
||||
}
|
||||
|
||||
export function combineBillingExpr(
|
||||
baseExpr: string,
|
||||
requestRuleExpr: string
|
||||
): string {
|
||||
const base = (baseExpr || '').trim()
|
||||
const rules = (requestRuleExpr || '').trim()
|
||||
if (!base) return ''
|
||||
if (!rules) return base
|
||||
return `(${base}) * ${rules}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor: empty constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEmptyCondition(): ParamHeaderCondition {
|
||||
return { source: 'param', path: '', mode: MATCH_EQ, value: '' }
|
||||
}
|
||||
|
||||
export function createEmptyTimeCondition(): TimeCondition {
|
||||
return {
|
||||
source: 'time',
|
||||
timeFunc: 'hour',
|
||||
timezone: 'Asia/Shanghai',
|
||||
mode: MATCH_GTE,
|
||||
value: '',
|
||||
rangeStart: '',
|
||||
rangeEnd: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyRuleGroup(): RequestRuleGroup {
|
||||
return { conditions: [createEmptyCondition()], multiplier: '' }
|
||||
}
|
||||
|
||||
export function createEmptyTimeRuleGroup(): RequestRuleGroup {
|
||||
return { conditions: [createEmptyTimeCondition()], multiplier: '' }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor: match option helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MatchOption = { value: string; labelKey: string }
|
||||
|
||||
export function getRequestRuleMatchOptions(source: string): MatchOption[] {
|
||||
if (source === SOURCE_TIME) {
|
||||
return [
|
||||
{ value: MATCH_EQ, labelKey: 'Equals' },
|
||||
{ value: MATCH_GTE, labelKey: 'Greater than or equal' },
|
||||
{ value: MATCH_LT, labelKey: 'Less than' },
|
||||
{ value: MATCH_RANGE, labelKey: 'Overnight range' },
|
||||
]
|
||||
}
|
||||
const base: MatchOption[] = [
|
||||
{ value: MATCH_EQ, labelKey: 'Equals' },
|
||||
{ value: MATCH_CONTAINS, labelKey: 'Contains' },
|
||||
{ value: MATCH_EXISTS, labelKey: 'Exists' },
|
||||
]
|
||||
if (source === SOURCE_HEADER) return base
|
||||
return [
|
||||
...base,
|
||||
{ value: MATCH_GT, labelKey: 'Greater than' },
|
||||
{ value: MATCH_GTE, labelKey: 'Greater than or equal' },
|
||||
{ value: MATCH_LT, labelKey: 'Less than' },
|
||||
{ value: MATCH_LTE, labelKey: 'Less than or equal' },
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor: normalize a single condition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isTimeFunc(value: unknown): value is TimeFunc {
|
||||
return typeof value === 'string' && TIME_FUNCS.includes(value as TimeFunc)
|
||||
}
|
||||
|
||||
export function normalizeCondition(
|
||||
cond: Partial<RequestCondition> | null | undefined
|
||||
): RequestCondition {
|
||||
const source =
|
||||
cond?.source === 'time'
|
||||
? 'time'
|
||||
: cond?.source === 'header'
|
||||
? 'header'
|
||||
: 'param'
|
||||
|
||||
if (source === 'time') {
|
||||
const timeCond = cond as Partial<TimeCondition> | null | undefined
|
||||
const timeFunc: TimeFunc = isTimeFunc(timeCond?.timeFunc)
|
||||
? timeCond.timeFunc
|
||||
: 'hour'
|
||||
const options = getRequestRuleMatchOptions(SOURCE_TIME)
|
||||
const mode = options.some((item) => item.value === timeCond?.mode)
|
||||
? (timeCond?.mode as string)
|
||||
: MATCH_GTE
|
||||
return {
|
||||
source: 'time',
|
||||
timeFunc,
|
||||
timezone: timeCond?.timezone || 'Asia/Shanghai',
|
||||
mode,
|
||||
value: timeCond?.value == null ? '' : String(timeCond.value),
|
||||
rangeStart:
|
||||
timeCond?.rangeStart == null ? '' : String(timeCond.rangeStart),
|
||||
rangeEnd: timeCond?.rangeEnd == null ? '' : String(timeCond.rangeEnd),
|
||||
}
|
||||
}
|
||||
|
||||
const phCond = cond as Partial<ParamHeaderCondition> | null | undefined
|
||||
const options = getRequestRuleMatchOptions(source)
|
||||
const mode = options.some((item) => item.value === phCond?.mode)
|
||||
? (phCond?.mode as string)
|
||||
: MATCH_EQ
|
||||
return {
|
||||
source,
|
||||
path: phCond?.path || '',
|
||||
mode,
|
||||
value: phCond?.value == null ? '' : String(phCond.value),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor: build expression strings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildExprLiteral(mode: string, value: string): string {
|
||||
const text = String(value || '').trim()
|
||||
if (mode === MATCH_CONTAINS) return JSON.stringify(text)
|
||||
if (text === 'true' || text === 'false') return text
|
||||
if (NUMERIC_LITERAL_REGEX.test(text)) return text
|
||||
return JSON.stringify(text)
|
||||
}
|
||||
|
||||
function buildTimeConditionExpr(cond: TimeCondition): string {
|
||||
const normalized = normalizeCondition(cond) as TimeCondition
|
||||
const { timeFunc, timezone, mode } = normalized
|
||||
const tz = JSON.stringify(timezone)
|
||||
const fn = `${timeFunc}(${tz})`
|
||||
|
||||
if (mode === MATCH_RANGE) {
|
||||
const s = normalized.rangeStart.trim()
|
||||
const e = normalized.rangeEnd.trim()
|
||||
if (!NUMERIC_LITERAL_REGEX.test(s) || !NUMERIC_LITERAL_REGEX.test(e)) {
|
||||
return ''
|
||||
}
|
||||
return `${fn} >= ${s} || ${fn} < ${e}`
|
||||
}
|
||||
const v = normalized.value.trim()
|
||||
if (!NUMERIC_LITERAL_REGEX.test(v)) return ''
|
||||
const opMap: Record<string, string> = {
|
||||
[MATCH_EQ]: '==',
|
||||
[MATCH_GTE]: '>=',
|
||||
[MATCH_LT]: '<',
|
||||
}
|
||||
return `${fn} ${opMap[mode] || '=='} ${v}`
|
||||
}
|
||||
|
||||
function buildRequestConditionExpr(cond: RequestCondition): string {
|
||||
if (cond.source === 'time') return buildTimeConditionExpr(cond)
|
||||
const normalized = normalizeCondition(cond) as ParamHeaderCondition
|
||||
const path = normalized.path.trim()
|
||||
if (!path) return ''
|
||||
|
||||
const sourceExpr =
|
||||
normalized.source === 'header'
|
||||
? `header(${JSON.stringify(path)})`
|
||||
: `param(${JSON.stringify(path)})`
|
||||
|
||||
switch (normalized.mode) {
|
||||
case MATCH_EXISTS:
|
||||
return normalized.source === 'header'
|
||||
? `${sourceExpr} != ""`
|
||||
: `${sourceExpr} != nil`
|
||||
case MATCH_CONTAINS:
|
||||
return normalized.source === 'header'
|
||||
? `has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
|
||||
: `${sourceExpr} != nil && has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
|
||||
case MATCH_GT:
|
||||
case MATCH_GTE:
|
||||
case MATCH_LT:
|
||||
case MATCH_LTE: {
|
||||
const opMap: Record<string, string> = {
|
||||
[MATCH_GT]: '>',
|
||||
[MATCH_GTE]: '>=',
|
||||
[MATCH_LT]: '<',
|
||||
[MATCH_LTE]: '<=',
|
||||
}
|
||||
const numText = String(normalized.value).trim()
|
||||
if (!NUMERIC_LITERAL_REGEX.test(numText)) return ''
|
||||
return `${sourceExpr} != nil && ${sourceExpr} ${opMap[normalized.mode]} ${numText}`
|
||||
}
|
||||
case MATCH_EQ:
|
||||
default:
|
||||
return `${sourceExpr} == ${buildExprLiteral(normalized.mode, normalized.value)}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildRuleGroupFactor(group: RequestRuleGroup): string {
|
||||
const multiplier = (group.multiplier || '').trim()
|
||||
if (!NUMERIC_LITERAL_REGEX.test(multiplier)) return ''
|
||||
const condExprs = (group.conditions || [])
|
||||
.map(buildRequestConditionExpr)
|
||||
.filter(Boolean)
|
||||
if (condExprs.length === 0) return ''
|
||||
|
||||
const combined =
|
||||
condExprs.length === 1
|
||||
? condExprs[0]
|
||||
: condExprs.map((e) => (e.includes(' || ') ? `(${e})` : e)).join(' && ')
|
||||
return `(${combined} ? ${multiplier} : 1)`
|
||||
}
|
||||
|
||||
export function buildRequestRuleExpr(groups: RequestRuleGroup[]): string {
|
||||
return (groups || []).map(buildRuleGroupFactor).filter(Boolean).join(' * ')
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
|
||||
|
||||
import { TOKEN_UNIT_DIVISORS } from '../constants'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
parseTiersFromExpr,
|
||||
splitBillingExprAndRequestRules,
|
||||
tryParseRequestRuleExpr,
|
||||
type BillingVar,
|
||||
type ParsedTier,
|
||||
} from './billing-expr'
|
||||
import { getDisplayGroupRatio } from './model-helpers'
|
||||
|
||||
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,
|
||||
selectedGroup?: string
|
||||
): number {
|
||||
return getDisplayGroupRatio(model, selectedGroup)
|
||||
}
|
||||
|
||||
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)
|
||||
),
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
FILTER_ALL,
|
||||
QUOTA_TYPES,
|
||||
QUOTA_TYPE_VALUES,
|
||||
ENDPOINT_TYPES,
|
||||
} from '../constants'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Filter Utilities
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Filter models by search query
|
||||
*/
|
||||
export function filterBySearch(
|
||||
models: PricingModel[],
|
||||
query: string
|
||||
): PricingModel[] {
|
||||
if (!query) return models
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return models.filter(
|
||||
(m) =>
|
||||
m.model_name?.toLowerCase().includes(lowerQuery) ||
|
||||
m.description?.toLowerCase().includes(lowerQuery) ||
|
||||
m.tags?.toLowerCase().includes(lowerQuery) ||
|
||||
m.vendor_name?.toLowerCase().includes(lowerQuery)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter models by vendor
|
||||
*/
|
||||
export function filterByVendor(
|
||||
models: PricingModel[],
|
||||
vendor: string
|
||||
): PricingModel[] {
|
||||
if (vendor === FILTER_ALL) return models
|
||||
return models.filter((m) => m.vendor_name === vendor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter models by group
|
||||
*/
|
||||
export function filterByGroup(
|
||||
models: PricingModel[],
|
||||
group: string
|
||||
): PricingModel[] {
|
||||
if (group === FILTER_ALL) return models
|
||||
return models.filter((m) => m.enable_groups?.includes(group))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter models by quota type
|
||||
*/
|
||||
export function filterByQuotaType(
|
||||
models: PricingModel[],
|
||||
quotaType: string
|
||||
): PricingModel[] {
|
||||
if (quotaType === QUOTA_TYPES.ALL) return models
|
||||
const targetType =
|
||||
quotaType === QUOTA_TYPES.TOKEN
|
||||
? QUOTA_TYPE_VALUES.TOKEN
|
||||
: QUOTA_TYPE_VALUES.REQUEST
|
||||
return models.filter((m) => m.quota_type === targetType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter models by endpoint type
|
||||
*/
|
||||
export function filterByEndpointType(
|
||||
models: PricingModel[],
|
||||
endpointType: string
|
||||
): PricingModel[] {
|
||||
if (endpointType === ENDPOINT_TYPES.ALL) return models
|
||||
return models.filter((m) =>
|
||||
m.supported_endpoint_types?.includes(endpointType)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model price for sorting
|
||||
*/
|
||||
function getModelPrice(model: PricingModel): number {
|
||||
return model.quota_type === 0 ? model.model_ratio : model.model_price || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort models by specified option
|
||||
*/
|
||||
export function sortModels(
|
||||
models: PricingModel[],
|
||||
sortBy: string
|
||||
): PricingModel[] {
|
||||
const sorted = [...models]
|
||||
|
||||
switch (sortBy) {
|
||||
case SORT_OPTIONS.NAME:
|
||||
sorted.sort((a, b) =>
|
||||
(a.model_name || '').localeCompare(b.model_name || '')
|
||||
)
|
||||
break
|
||||
case SORT_OPTIONS.PRICE_LOW:
|
||||
sorted.sort((a, b) => getModelPrice(a) - getModelPrice(b))
|
||||
break
|
||||
case SORT_OPTIONS.PRICE_HIGH:
|
||||
sorted.sort((a, b) => getModelPrice(b) - getModelPrice(a))
|
||||
break
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all filters and sorting to models
|
||||
*/
|
||||
export function filterAndSortModels(
|
||||
models: PricingModel[],
|
||||
filters: {
|
||||
search: string
|
||||
vendor: string
|
||||
group: string
|
||||
quotaType: string
|
||||
endpointType: string
|
||||
tag: string
|
||||
sortBy: string
|
||||
}
|
||||
): PricingModel[] {
|
||||
let result = filterBySearch(models, filters.search)
|
||||
result = filterByVendor(result, filters.vendor)
|
||||
result = filterByGroup(result, filters.group)
|
||||
result = filterByQuotaType(result, filters.quotaType)
|
||||
result = filterByEndpointType(result, filters.endpointType)
|
||||
result = filterByTag(result, filters.tag)
|
||||
result = sortModels(result, filters.sortBy)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tags from comma-separated string
|
||||
*/
|
||||
export function parseTags(tagsString?: string): string[] {
|
||||
if (!tagsString) return []
|
||||
return tagsString
|
||||
.split(/[,;|\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all unique tags from models
|
||||
*/
|
||||
export function extractAllTags(models: PricingModel[]): string[] {
|
||||
const tagSet = new Set<string>()
|
||||
|
||||
models.forEach((model) => {
|
||||
if (model.tags) {
|
||||
const tags = parseTags(model.tags)
|
||||
tags.forEach((tag) => {
|
||||
tagSet.add(tag.toLowerCase())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(tagSet).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter models by tag
|
||||
*/
|
||||
export function filterByTag(
|
||||
models: PricingModel[],
|
||||
tag: string
|
||||
): PricingModel[] {
|
||||
if (tag === FILTER_ALL) return models
|
||||
|
||||
const tagLower = tag.toLowerCase()
|
||||
return models.filter((m) => {
|
||||
if (!m.tags) return false
|
||||
const modelTags = parseTags(m.tags).map((t) => t.toLowerCase())
|
||||
return modelTags.includes(tagLower)
|
||||
})
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pricing Lib Exports
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export * from './filters'
|
||||
export * from './price'
|
||||
export * from './model-helpers'
|
||||
export * from './billing-expr'
|
||||
export * from './tier-expr'
|
||||
export * from './mock-stats'
|
||||
export * from './seed'
|
||||
+842
@@ -0,0 +1,842 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { PricingModel } from '../types'
|
||||
import {
|
||||
hashStringToSeed,
|
||||
randomInRange,
|
||||
randomIntInRange,
|
||||
seededRandom,
|
||||
} from './seed'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Mock model statistics
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// The backend has not yet implemented latency / uptime / app-ranking data.
|
||||
// These helpers generate plausible, deterministic mock values seeded from
|
||||
// the model name (and optionally the group name) so that:
|
||||
// - Every render of the same model shows the same numbers
|
||||
// - Different models / different groups render visibly distinct values
|
||||
//
|
||||
// When the backend ships real metrics, callers should switch to the
|
||||
// real API and these helpers can be deleted. The shape of the returned
|
||||
// data is designed to mirror what we expect the real endpoints to return.
|
||||
|
||||
export type GroupPerformance = {
|
||||
group: string
|
||||
ttft_p50_ms: number
|
||||
ttft_p95_ms: number
|
||||
ttft_p99_ms: number
|
||||
throughput_tps: number
|
||||
uptime_30d_pct: number
|
||||
/** Number of monitored requests in the last 24h (display only). */
|
||||
request_volume_24h: number
|
||||
}
|
||||
|
||||
export type LatencyTimePoint = {
|
||||
timestamp: string
|
||||
group: string
|
||||
ttft_ms: number
|
||||
}
|
||||
|
||||
export type UptimeDayPoint = {
|
||||
date: string
|
||||
uptime_pct: number
|
||||
incidents: number
|
||||
outage_minutes: number
|
||||
}
|
||||
|
||||
export type AppRanking = {
|
||||
rank: number
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
growth_pct: number
|
||||
monthly_tokens: number
|
||||
url?: string
|
||||
initial: string
|
||||
}
|
||||
|
||||
const APP_TEMPLATES: Array<
|
||||
Omit<AppRanking, 'rank' | 'monthly_tokens' | 'growth_pct' | 'initial'>
|
||||
> = [
|
||||
{
|
||||
name: 'Cline',
|
||||
description: 'Autonomous coding agent inside the IDE',
|
||||
category: 'Coding',
|
||||
url: 'https://cline.bot',
|
||||
},
|
||||
{
|
||||
name: 'Roo Code',
|
||||
description: 'AI agent for VS Code with multi-step planning',
|
||||
category: 'Coding',
|
||||
url: 'https://roocode.com',
|
||||
},
|
||||
{
|
||||
name: 'Open WebUI',
|
||||
description: 'Self-hosted ChatGPT-like web interface',
|
||||
category: 'Chat',
|
||||
url: 'https://openwebui.com',
|
||||
},
|
||||
{
|
||||
name: 'LibreChat',
|
||||
description: 'Open-source chat platform with multi-model support',
|
||||
category: 'Chat',
|
||||
url: 'https://librechat.ai',
|
||||
},
|
||||
{
|
||||
name: 'Lobe Chat',
|
||||
description: 'Modern open-source chat UI with plugins',
|
||||
category: 'Chat',
|
||||
url: 'https://lobehub.com',
|
||||
},
|
||||
{
|
||||
name: 'NextChat',
|
||||
description: 'Cross-platform private ChatGPT client',
|
||||
category: 'Chat',
|
||||
url: 'https://nextchat.dev',
|
||||
},
|
||||
{
|
||||
name: 'Continue',
|
||||
description: 'Open-source AI code assistant for editors',
|
||||
category: 'Coding',
|
||||
url: 'https://continue.dev',
|
||||
},
|
||||
{
|
||||
name: 'Aider',
|
||||
description: 'Pair-programming agent in your terminal',
|
||||
category: 'Coding',
|
||||
url: 'https://aider.chat',
|
||||
},
|
||||
{
|
||||
name: 'Dify',
|
||||
description: 'LLM application development platform',
|
||||
category: 'Platform',
|
||||
url: 'https://dify.ai',
|
||||
},
|
||||
{
|
||||
name: 'FastGPT',
|
||||
description: 'Knowledge base orchestration and chat platform',
|
||||
category: 'Platform',
|
||||
url: 'https://fastgpt.in',
|
||||
},
|
||||
{
|
||||
name: 'Flowise',
|
||||
description: 'Low-code LLM workflow builder',
|
||||
category: 'Platform',
|
||||
url: 'https://flowiseai.com',
|
||||
},
|
||||
{
|
||||
name: 'OpenInterpreter',
|
||||
description: 'Natural-language code execution agent',
|
||||
category: 'Coding',
|
||||
url: 'https://openinterpreter.com',
|
||||
},
|
||||
{
|
||||
name: 'Devika',
|
||||
description: 'Open-source AI software engineer',
|
||||
category: 'Coding',
|
||||
url: 'https://github.com/stitionai/devika',
|
||||
},
|
||||
{
|
||||
name: 'Cherry Studio',
|
||||
description: 'Multi-model desktop chat client',
|
||||
category: 'Chat',
|
||||
url: 'https://cherry-ai.com',
|
||||
},
|
||||
{
|
||||
name: 'AnythingLLM',
|
||||
description: 'Workspaces around your private documents',
|
||||
category: 'Platform',
|
||||
url: 'https://anythingllm.com',
|
||||
},
|
||||
{
|
||||
name: 'OpenHands',
|
||||
description: 'Coding agent with browser-and-code tools',
|
||||
category: 'Coding',
|
||||
url: 'https://docs.all-hands.dev',
|
||||
},
|
||||
{
|
||||
name: 'Cursor',
|
||||
description: 'AI-native code editor',
|
||||
category: 'Coding',
|
||||
url: 'https://cursor.com',
|
||||
},
|
||||
{
|
||||
name: 'Zed',
|
||||
description: 'Multiplayer code editor with AI',
|
||||
category: 'Coding',
|
||||
url: 'https://zed.dev',
|
||||
},
|
||||
{
|
||||
name: 'Notion AI',
|
||||
description: 'Documents and writing assistant',
|
||||
category: 'Productivity',
|
||||
url: 'https://notion.so',
|
||||
},
|
||||
{
|
||||
name: 'Raycast AI',
|
||||
description: 'AI on your macOS launcher',
|
||||
category: 'Productivity',
|
||||
url: 'https://raycast.com',
|
||||
},
|
||||
{
|
||||
name: 'Obsidian Smart Connections',
|
||||
description: 'Connect notes with semantic search',
|
||||
category: 'Productivity',
|
||||
},
|
||||
{
|
||||
name: 'Bolt.new',
|
||||
description: 'Prompt-to-app full-stack builder',
|
||||
category: 'Coding',
|
||||
url: 'https://bolt.new',
|
||||
},
|
||||
{
|
||||
name: 'Pieces',
|
||||
description: 'AI workflow companion for developers',
|
||||
category: 'Productivity',
|
||||
url: 'https://pieces.app',
|
||||
},
|
||||
{
|
||||
name: 'AmazingAI',
|
||||
description: 'Personal AI knowledge assistant',
|
||||
category: 'Productivity',
|
||||
},
|
||||
{
|
||||
name: 'TypingMind',
|
||||
description: 'Better UI for ChatGPT and Claude',
|
||||
category: 'Chat',
|
||||
url: 'https://typingmind.com',
|
||||
},
|
||||
]
|
||||
|
||||
const PROFILE_BY_NAME = (name: string) => {
|
||||
const n = name.toLowerCase()
|
||||
if (/embed|rerank/.test(n)) return 'embedding'
|
||||
if (/image|sora|veo|kling|pika|jimeng|dalle|imagen/.test(n)) return 'image'
|
||||
if (/whisper|tts|voice|audio/.test(n)) return 'audio'
|
||||
if (/o1|o3|o4|reasoning|thinking|deepseek-r/.test(n)) return 'reasoning'
|
||||
if (/flash|haiku|mini|small|nano|fast/.test(n)) return 'fast'
|
||||
if (/gpt-5|opus|ultra|405|70b/.test(n)) return 'large'
|
||||
return 'standard'
|
||||
}
|
||||
|
||||
type ProfileSpec = {
|
||||
ttftRange: [number, number]
|
||||
throughputRange: [number, number]
|
||||
uptimeRange: [number, number]
|
||||
}
|
||||
|
||||
const PROFILE_SPECS: Record<string, ProfileSpec> = {
|
||||
embedding: {
|
||||
ttftRange: [40, 120],
|
||||
throughputRange: [0, 0],
|
||||
uptimeRange: [99.9, 99.99],
|
||||
},
|
||||
image: {
|
||||
ttftRange: [2_500, 12_000],
|
||||
throughputRange: [0, 0],
|
||||
uptimeRange: [98.5, 99.8],
|
||||
},
|
||||
audio: {
|
||||
ttftRange: [180, 600],
|
||||
throughputRange: [0, 0],
|
||||
uptimeRange: [99.5, 99.95],
|
||||
},
|
||||
reasoning: {
|
||||
ttftRange: [1_800, 5_500],
|
||||
throughputRange: [25, 70],
|
||||
uptimeRange: [99.4, 99.95],
|
||||
},
|
||||
fast: {
|
||||
ttftRange: [180, 480],
|
||||
throughputRange: [110, 240],
|
||||
uptimeRange: [99.7, 99.99],
|
||||
},
|
||||
large: {
|
||||
ttftRange: [600, 1_400],
|
||||
throughputRange: [55, 95],
|
||||
uptimeRange: [99.5, 99.95],
|
||||
},
|
||||
standard: {
|
||||
ttftRange: [400, 900],
|
||||
throughputRange: [70, 140],
|
||||
uptimeRange: [99.6, 99.97],
|
||||
},
|
||||
}
|
||||
|
||||
function rangeFromSeed(
|
||||
rand: () => number,
|
||||
[min, max]: [number, number]
|
||||
): number {
|
||||
return randomInRange(rand, min, max)
|
||||
}
|
||||
|
||||
function applyGroupFactor(value: number, factor: number): number {
|
||||
return value * factor
|
||||
}
|
||||
|
||||
function groupFactor(
|
||||
group: string,
|
||||
baseSeed: number
|
||||
): { ttft: number; throughput: number; uptime: number } {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(group || 'default'))
|
||||
return {
|
||||
ttft: 0.85 + rand() * 0.55,
|
||||
throughput: 0.85 + rand() * 0.4,
|
||||
uptime: 0.997 + rand() * 0.003,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-group performance stats for a model. Always returns at least one
|
||||
* row for each enabled group, sorted alphabetically.
|
||||
*/
|
||||
export function buildGroupPerformance(model: PricingModel): GroupPerformance[] {
|
||||
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
|
||||
const targets = groups.length > 0 ? groups : ['default']
|
||||
const profile = PROFILE_BY_NAME(model.model_name)
|
||||
const spec = PROFILE_SPECS[profile]
|
||||
const baseSeed = hashStringToSeed(model.model_name)
|
||||
|
||||
return targets
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map<GroupPerformance>((group) => {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
|
||||
const factor = groupFactor(group, baseSeed)
|
||||
const ttftP50 = applyGroupFactor(
|
||||
rangeFromSeed(rand, spec.ttftRange),
|
||||
factor.ttft
|
||||
)
|
||||
const throughput = applyGroupFactor(
|
||||
rangeFromSeed(rand, spec.throughputRange),
|
||||
factor.throughput
|
||||
)
|
||||
const uptimePct = Math.min(
|
||||
99.99,
|
||||
rangeFromSeed(rand, spec.uptimeRange) * factor.uptime
|
||||
)
|
||||
const requestVolume = randomIntInRange(rand, 18_000, 480_000)
|
||||
return {
|
||||
group,
|
||||
ttft_p50_ms: Math.round(ttftP50),
|
||||
ttft_p95_ms: Math.round(ttftP50 * (1.6 + rand() * 0.4)),
|
||||
ttft_p99_ms: Math.round(ttftP50 * (2.4 + rand() * 0.6)),
|
||||
throughput_tps: throughput === 0 ? 0 : Math.round(throughput * 10) / 10,
|
||||
uptime_30d_pct: Math.round(uptimePct * 100) / 100,
|
||||
request_volume_24h: requestVolume,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 24-hour latency series for each group. Returns one point per hour
|
||||
* (24 buckets), oldest first.
|
||||
*/
|
||||
export function buildLatencyTimeSeries(
|
||||
model: PricingModel
|
||||
): LatencyTimePoint[] {
|
||||
const performances = buildGroupPerformance(model)
|
||||
if (performances.length === 0) return []
|
||||
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:lat`)
|
||||
const points: LatencyTimePoint[] = []
|
||||
|
||||
for (const perf of performances) {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(perf.group))
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const ts = new Date(now.getTime() - i * 3_600_000)
|
||||
const noise = 0.7 + rand() * 0.7
|
||||
const trend = 0.85 + Math.sin(i / 3) * 0.1
|
||||
const value = Math.max(50, Math.round(perf.ttft_p50_ms * noise * trend))
|
||||
points.push({
|
||||
timestamp: ts.toISOString(),
|
||||
group: perf.group,
|
||||
ttft_ms: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 30-day uptime series. Returns one point per day, oldest first.
|
||||
*
|
||||
* If `group` is provided the series is anchored on that group's mean uptime,
|
||||
* otherwise it uses the per-model average. Either way the seed is derived
|
||||
* deterministically so re-renders are stable.
|
||||
*/
|
||||
export function buildUptimeSeries(
|
||||
model: PricingModel,
|
||||
group?: string
|
||||
): UptimeDayPoint[] {
|
||||
const performances = buildGroupPerformance(model)
|
||||
if (performances.length === 0) return []
|
||||
|
||||
const target = group ? performances.find((p) => p.group === group) : null
|
||||
const baseUptime = target
|
||||
? target.uptime_30d_pct
|
||||
: performances.reduce((s, p) => s + p.uptime_30d_pct, 0) /
|
||||
performances.length
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:up:${group ?? '_all'}`)
|
||||
const rand = seededRandom(baseSeed)
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const points: UptimeDayPoint[] = []
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date(today.getTime() - i * 86_400_000)
|
||||
const isoDate = date.toISOString().slice(0, 10)
|
||||
const incidentChance = rand()
|
||||
const incidents = incidentChance > 0.92 ? 1 : 0
|
||||
const outageMinutes = incidents > 0 ? Math.round(rand() * 30 + 5) : 0
|
||||
const downtimePct = (outageMinutes / 1_440) * 100
|
||||
const dayUptime = Math.max(85, Math.min(100, baseUptime - downtimePct))
|
||||
points.push({
|
||||
date: isoDate,
|
||||
uptime_pct: Math.round(dayUptime * 100) / 100,
|
||||
incidents,
|
||||
outage_minutes: outageMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic top-apps ranking for the model. The first three apps
|
||||
* always come from the same template list; the rest is shuffled by the seed
|
||||
* so different models surface different long tails.
|
||||
*/
|
||||
export function buildAppRankings(
|
||||
model: PricingModel,
|
||||
count = 12
|
||||
): AppRanking[] {
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:apps`)
|
||||
const rand = seededRandom(baseSeed)
|
||||
const candidates = [...APP_TEMPLATES]
|
||||
// Fisher–Yates shuffle.
|
||||
for (let i = candidates.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[candidates[i], candidates[j]] = [candidates[j], candidates[i]]
|
||||
}
|
||||
|
||||
const top = candidates.slice(0, count)
|
||||
const baseTokens = randomInRange(rand, 90_000_000, 320_000_000)
|
||||
|
||||
return top.map((app, idx) => {
|
||||
const decay = Math.pow(0.78, idx)
|
||||
const monthlyTokens = Math.round(baseTokens * decay * (0.85 + rand() * 0.3))
|
||||
const growthPctRaw = randomInRange(rand, -28, 84)
|
||||
const growthPct = Math.round(growthPctRaw * 10) / 10
|
||||
return {
|
||||
rank: idx + 1,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
category: app.category,
|
||||
url: app.url,
|
||||
growth_pct: growthPct,
|
||||
monthly_tokens: monthlyTokens,
|
||||
initial: app.name.charAt(0).toUpperCase(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Aggregate uptime over the most recent 30 days. */
|
||||
export function aggregateUptime(points: UptimeDayPoint[]): {
|
||||
uptime_pct: number
|
||||
incidents: number
|
||||
outage_minutes: number
|
||||
} {
|
||||
if (points.length === 0) {
|
||||
return { uptime_pct: 0, incidents: 0, outage_minutes: 0 }
|
||||
}
|
||||
const incidents = points.reduce((s, p) => s + p.incidents, 0)
|
||||
const outageMinutes = points.reduce((s, p) => s + p.outage_minutes, 0)
|
||||
const totalMinutes = points.length * 1_440
|
||||
const uptimePct = ((totalMinutes - outageMinutes) / totalMinutes) * 100
|
||||
return {
|
||||
incidents,
|
||||
outage_minutes: outageMinutes,
|
||||
uptime_pct: Math.round(uptimePct * 1000) / 1000,
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact integer formatter for token counts in apps tab. */
|
||||
export function formatTokenVolume(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0'
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock supported-parameters & rate-limits & misc API metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SupportedParameter = {
|
||||
name: string
|
||||
type:
|
||||
| 'number'
|
||||
| 'integer'
|
||||
| 'boolean'
|
||||
| 'string'
|
||||
| 'object'
|
||||
| 'array'
|
||||
| 'enum'
|
||||
defaultValue?: string | number | boolean
|
||||
range?: string
|
||||
enumValues?: string[]
|
||||
descriptionKey: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const COMMON_CHAT_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'temperature',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
range: '0 ~ 2',
|
||||
descriptionKey: 'Sampling temperature; lower is more deterministic',
|
||||
},
|
||||
{
|
||||
name: 'top_p',
|
||||
type: 'number',
|
||||
defaultValue: 1,
|
||||
range: '0 ~ 1',
|
||||
descriptionKey: 'Nucleus sampling probability mass',
|
||||
},
|
||||
{
|
||||
name: 'max_tokens',
|
||||
type: 'integer',
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Maximum number of tokens in the response',
|
||||
},
|
||||
{
|
||||
name: 'frequency_penalty',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
range: '-2 ~ 2',
|
||||
descriptionKey: 'Penalises repetition of frequent tokens',
|
||||
},
|
||||
{
|
||||
name: 'presence_penalty',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
range: '-2 ~ 2',
|
||||
descriptionKey: 'Encourages introducing new topics',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
type: 'array',
|
||||
descriptionKey: 'Up to 4 strings that stop generation',
|
||||
},
|
||||
{
|
||||
name: 'seed',
|
||||
type: 'integer',
|
||||
descriptionKey: 'Deterministic sampling seed (best-effort)',
|
||||
},
|
||||
{
|
||||
name: 'n',
|
||||
type: 'integer',
|
||||
defaultValue: 1,
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Number of completions to generate',
|
||||
},
|
||||
{
|
||||
name: 'stream',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
descriptionKey: 'Stream tokens via Server-Sent Events',
|
||||
},
|
||||
{
|
||||
name: 'response_format',
|
||||
type: 'object',
|
||||
descriptionKey: 'Force JSON object or schema-conforming output',
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
type: 'array',
|
||||
descriptionKey: 'Tool / function declarations the model may call',
|
||||
},
|
||||
{
|
||||
name: 'tool_choice',
|
||||
type: 'string',
|
||||
enumValues: ['auto', 'none', 'required'],
|
||||
descriptionKey: 'Tool-choice policy or specific tool name',
|
||||
},
|
||||
{
|
||||
name: 'logprobs',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
descriptionKey: 'Return per-token log probabilities',
|
||||
},
|
||||
{
|
||||
name: 'top_logprobs',
|
||||
type: 'integer',
|
||||
range: '0 ~ 20',
|
||||
descriptionKey: 'Number of top log probabilities returned per token',
|
||||
},
|
||||
{
|
||||
name: 'logit_bias',
|
||||
type: 'object',
|
||||
descriptionKey: 'Per-token logit bias map',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'string',
|
||||
descriptionKey: 'End-user identifier for abuse monitoring',
|
||||
},
|
||||
]
|
||||
|
||||
const REASONING_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'reasoning_effort',
|
||||
type: 'enum',
|
||||
enumValues: ['low', 'medium', 'high'],
|
||||
defaultValue: 'medium',
|
||||
descriptionKey: 'Controls how much the model thinks before answering',
|
||||
},
|
||||
{
|
||||
name: 'max_completion_tokens',
|
||||
type: 'integer',
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Maximum tokens including hidden reasoning tokens',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
type: 'array',
|
||||
descriptionKey: 'Up to 4 strings that stop generation',
|
||||
},
|
||||
{
|
||||
name: 'seed',
|
||||
type: 'integer',
|
||||
descriptionKey: 'Deterministic sampling seed (best-effort)',
|
||||
},
|
||||
{
|
||||
name: 'stream',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
descriptionKey: 'Stream tokens via Server-Sent Events',
|
||||
},
|
||||
{
|
||||
name: 'response_format',
|
||||
type: 'object',
|
||||
descriptionKey: 'Force JSON object or schema-conforming output',
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
type: 'array',
|
||||
descriptionKey: 'Tool / function declarations the model may call',
|
||||
},
|
||||
{
|
||||
name: 'tool_choice',
|
||||
type: 'string',
|
||||
enumValues: ['auto', 'none', 'required'],
|
||||
descriptionKey: 'Tool-choice policy or specific tool name',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'string',
|
||||
descriptionKey: 'End-user identifier for abuse monitoring',
|
||||
},
|
||||
]
|
||||
|
||||
const EMBEDDING_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
required: true,
|
||||
descriptionKey: 'Text or array of texts to embed',
|
||||
},
|
||||
{
|
||||
name: 'dimensions',
|
||||
type: 'integer',
|
||||
range: '>= 1',
|
||||
descriptionKey: 'Truncate embeddings to this many dimensions',
|
||||
},
|
||||
{
|
||||
name: 'encoding_format',
|
||||
type: 'enum',
|
||||
enumValues: ['float', 'base64'],
|
||||
defaultValue: 'float',
|
||||
descriptionKey: 'Wire encoding for the embedding vectors',
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'string',
|
||||
descriptionKey: 'End-user identifier for abuse monitoring',
|
||||
},
|
||||
]
|
||||
|
||||
const IMAGE_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
descriptionKey: 'Text description of the desired image',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
type: 'enum',
|
||||
enumValues: ['256x256', '512x512', '1024x1024', '1024x1792', '1792x1024'],
|
||||
defaultValue: '1024x1024',
|
||||
descriptionKey: 'Output image size',
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
type: 'enum',
|
||||
enumValues: ['standard', 'hd'],
|
||||
defaultValue: 'standard',
|
||||
descriptionKey: 'Generation quality preset',
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
type: 'enum',
|
||||
enumValues: ['vivid', 'natural'],
|
||||
defaultValue: 'vivid',
|
||||
descriptionKey: 'Aesthetic style',
|
||||
},
|
||||
{
|
||||
name: 'n',
|
||||
type: 'integer',
|
||||
defaultValue: 1,
|
||||
range: '1 ~ 10',
|
||||
descriptionKey: 'Number of images to generate',
|
||||
},
|
||||
{
|
||||
name: 'response_format',
|
||||
type: 'enum',
|
||||
enumValues: ['url', 'b64_json'],
|
||||
defaultValue: 'url',
|
||||
descriptionKey: 'How to deliver the resulting image',
|
||||
},
|
||||
]
|
||||
|
||||
const VIDEO_PARAMS: SupportedParameter[] = [
|
||||
{
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
required: true,
|
||||
descriptionKey: 'Text description of the desired video',
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
type: 'integer',
|
||||
range: '1 ~ 60',
|
||||
descriptionKey: 'Video length in seconds',
|
||||
},
|
||||
{
|
||||
name: 'aspect_ratio',
|
||||
type: 'enum',
|
||||
enumValues: ['16:9', '9:16', '1:1'],
|
||||
defaultValue: '16:9',
|
||||
descriptionKey: 'Output aspect ratio',
|
||||
},
|
||||
{
|
||||
name: 'fps',
|
||||
type: 'integer',
|
||||
range: '8 ~ 60',
|
||||
defaultValue: 24,
|
||||
descriptionKey: 'Frames per second',
|
||||
},
|
||||
]
|
||||
|
||||
type ApiCategory = 'reasoning' | 'embedding' | 'image' | 'video' | 'chat'
|
||||
|
||||
/**
|
||||
* Refine the broad PROFILE_BY_NAME bucket into an API-shape category. The
|
||||
* `image` bucket from `PROFILE_BY_NAME` lumps still-image and video models
|
||||
* together (because their performance profiles overlap); for the API tab we
|
||||
* need to distinguish them so the request-parameter table is accurate.
|
||||
*/
|
||||
function apiCategoryOf(model: PricingModel): ApiCategory {
|
||||
const profile = PROFILE_BY_NAME(model.model_name)
|
||||
if (profile === 'embedding' || profile === 'reasoning') return profile
|
||||
if (profile === 'image') {
|
||||
return /sora|veo|kling|pika|video|wan-|hunyuanvideo/i.test(model.model_name)
|
||||
? 'video'
|
||||
: 'image'
|
||||
}
|
||||
return 'chat'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of request parameters that the model accepts. The list is
|
||||
* shaped per-modality so reasoning, embedding, image, video and chat models
|
||||
* each show their relevant parameter set.
|
||||
*/
|
||||
export function buildSupportedParameters(
|
||||
model: PricingModel
|
||||
): SupportedParameter[] {
|
||||
const cat = apiCategoryOf(model)
|
||||
if (cat === 'reasoning') return REASONING_PARAMS
|
||||
if (cat === 'embedding') return EMBEDDING_PARAMS
|
||||
if (cat === 'image') return IMAGE_PARAMS
|
||||
if (cat === 'video') return VIDEO_PARAMS
|
||||
return COMMON_CHAT_PARAMS
|
||||
}
|
||||
|
||||
export type RateLimit = {
|
||||
group: string
|
||||
rpm: number
|
||||
tpm: number
|
||||
rpd: number
|
||||
}
|
||||
|
||||
/** Build per-group RPM / TPM / RPD limits for the model. */
|
||||
export function buildRateLimits(model: PricingModel): RateLimit[] {
|
||||
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
|
||||
const targets = groups.length > 0 ? groups : ['default']
|
||||
const cat = apiCategoryOf(model)
|
||||
const baseSeed = hashStringToSeed(`${model.model_name}:rl`)
|
||||
const isHeavy = cat === 'image' || cat === 'video'
|
||||
const isLight = cat === 'embedding'
|
||||
const baseRpm = isHeavy ? 60 : isLight ? 5_000 : 500
|
||||
const baseTpm = isHeavy ? 0 : isLight ? 1_000_000 : 200_000
|
||||
const baseRpd = isHeavy ? 1_000 : isLight ? 100_000 : 10_000
|
||||
|
||||
return targets
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((group) => {
|
||||
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
|
||||
const tier = 0.6 + rand() * 1.4
|
||||
return {
|
||||
group,
|
||||
rpm: Math.round((baseRpm * tier) / 10) * 10,
|
||||
tpm: baseTpm === 0 ? 0 : Math.round((baseTpm * tier) / 1_000) * 1_000,
|
||||
rpd: Math.round((baseRpd * tier) / 100) * 100,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Format an integer rate-limit value compactly. */
|
||||
export function formatRateLimit(value: number): string {
|
||||
if (value <= 0) return '—'
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000)
|
||||
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`
|
||||
return value.toLocaleString()
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { EXCLUDED_GROUPS, FILTER_ALL, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model Helper Utilities
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get available groups for a model
|
||||
*/
|
||||
export function getAvailableGroups(
|
||||
model: PricingModel,
|
||||
usableGroup: Record<string, { desc: string; ratio: number }>
|
||||
): string[] {
|
||||
const modelEnableGroups = Array.isArray(model.enable_groups)
|
||||
? model.enable_groups
|
||||
: []
|
||||
|
||||
return Object.keys(usableGroup)
|
||||
.filter((g) => !EXCLUDED_GROUPS.includes(g))
|
||||
.filter((g) => modelEnableGroups.includes(g))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a configured group ratio while preserving valid zero ratios.
|
||||
*/
|
||||
export function getConfiguredGroupRatio(
|
||||
groupRatio: Record<string, number>,
|
||||
group: string
|
||||
): number {
|
||||
const ratio = groupRatio[group]
|
||||
return typeof ratio === 'number' && Number.isFinite(ratio) ? ratio : 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the group ratio used by model square summary prices.
|
||||
*
|
||||
* When no specific group is selected, the model square shows the best price
|
||||
* available to the viewer. When a group filter is active, it shows that
|
||||
* group's price instead.
|
||||
*/
|
||||
export function getDisplayGroupRatio(
|
||||
model: PricingModel,
|
||||
selectedGroup?: string
|
||||
): number {
|
||||
const modelEnableGroups = Array.isArray(model.enable_groups)
|
||||
? model.enable_groups
|
||||
: []
|
||||
const groupRatio = model.group_ratio || {}
|
||||
|
||||
if (
|
||||
selectedGroup &&
|
||||
selectedGroup !== FILTER_ALL &&
|
||||
modelEnableGroups.includes(selectedGroup)
|
||||
) {
|
||||
return getConfiguredGroupRatio(groupRatio, selectedGroup)
|
||||
}
|
||||
|
||||
if (modelEnableGroups.length === 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
let minRatio = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const group of modelEnableGroups) {
|
||||
const ratio = groupRatio[group]
|
||||
if (
|
||||
typeof ratio === 'number' &&
|
||||
Number.isFinite(ratio) &&
|
||||
ratio < minRatio
|
||||
) {
|
||||
minRatio = ratio
|
||||
}
|
||||
}
|
||||
|
||||
return minRatio === Number.POSITIVE_INFINITY ? 1 : minRatio
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace model placeholder in endpoint path
|
||||
*/
|
||||
export function replaceModelInPath(path: string, modelName: string): string {
|
||||
return path.replaceAll('{model}', modelName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model is token-based pricing
|
||||
*/
|
||||
export function isTokenBasedModel(model: PricingModel): boolean {
|
||||
return model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
|
||||
import { QUOTA_TYPE_VALUES, TOKEN_UNIT_DIVISORS } from '../constants'
|
||||
import type { PricingModel, TokenUnit, PriceType } from '../types'
|
||||
import { getConfiguredGroupRatio, getDisplayGroupRatio } from './model-helpers'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Price Calculation Utilities
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Strip trailing zeros from formatted price string while preserving currency symbols
|
||||
*/
|
||||
export function stripTrailingZeros(formatted: string): string {
|
||||
// Match currency symbol at start, number, and potential 'k' suffix
|
||||
const match = formatted.match(/^([^\d-]*)([-\d,]+\.?\d*)(k?)$/)
|
||||
if (!match) return formatted
|
||||
|
||||
const [, symbol, number, suffix] = match
|
||||
|
||||
// Remove commas for processing
|
||||
const cleanNumber = number.replaceAll(',', '')
|
||||
|
||||
// Convert to number and back to remove trailing zeros
|
||||
const parsed = Number.parseFloat(cleanNumber)
|
||||
if (Number.isNaN(parsed)) return formatted
|
||||
|
||||
// Convert to string, which automatically removes trailing zeros
|
||||
let result = parsed.toString()
|
||||
|
||||
// If the result is in scientific notation, format it properly
|
||||
if (result.includes('e')) {
|
||||
result = parsed.toFixed(20).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
return `${symbol}${result}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate token price in USD.
|
||||
*
|
||||
* Returns NaN when the required ratio field is missing/null so callers can
|
||||
* skip rendering that price type.
|
||||
*/
|
||||
function calculateTokenPrice(
|
||||
model: PricingModel,
|
||||
type: PriceType,
|
||||
ratio: number
|
||||
): number {
|
||||
const base = model.model_ratio * 2 * ratio
|
||||
|
||||
switch (type) {
|
||||
case 'input':
|
||||
return base
|
||||
case 'output':
|
||||
return base * model.completion_ratio
|
||||
case 'cache':
|
||||
return hasRatio(model.cache_ratio)
|
||||
? base * Number(model.cache_ratio)
|
||||
: Number.NaN
|
||||
case 'create_cache':
|
||||
return hasRatio(model.create_cache_ratio)
|
||||
? base * Number(model.create_cache_ratio)
|
||||
: Number.NaN
|
||||
case 'image':
|
||||
return hasRatio(model.image_ratio)
|
||||
? base * Number(model.image_ratio)
|
||||
: Number.NaN
|
||||
case 'audio_input':
|
||||
return hasRatio(model.audio_ratio)
|
||||
? base * Number(model.audio_ratio)
|
||||
: Number.NaN
|
||||
case 'audio_output':
|
||||
return hasRatio(model.audio_ratio) &&
|
||||
hasRatio(model.audio_completion_ratio)
|
||||
? base *
|
||||
Number(model.audio_ratio) *
|
||||
Number(model.audio_completion_ratio)
|
||||
: Number.NaN
|
||||
}
|
||||
}
|
||||
|
||||
function hasRatio(value: number | null | undefined): boolean {
|
||||
return value !== undefined && value !== null && Number.isFinite(Number(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply recharge rate to price
|
||||
*
|
||||
* priceRate represents how much users need to recharge (in the display currency)
|
||||
* to get 1 USD credit. usdExchangeRate is the real exchange rate.
|
||||
*
|
||||
* The returned value will be formatted by formatCurrencyFromUSD, which will
|
||||
* multiply by the display currency's exchange rate.
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* 1. Display currency = USD:
|
||||
* - Model: 1 USD
|
||||
* - priceRate = 0.5 (recharge $0.5 to get $1 credit)
|
||||
* - usdExchangeRate = 1
|
||||
* - Return: 1 × 0.5 / 1 = 0.5
|
||||
* - formatCurrencyFromUSD(0.5) → $0.5 ✓
|
||||
*
|
||||
* 2. Display currency = CNY:
|
||||
* - Model: 1 USD
|
||||
* - priceRate = 4 (recharge ¥4 to get $1 credit)
|
||||
* - usdExchangeRate = 7 (real rate: 1 USD = ¥7)
|
||||
* - Return: 1 × 4 / 7 = 0.571
|
||||
* - formatCurrencyFromUSD(0.571) → 0.571 × 7 = ¥4 ✓
|
||||
* - Normal price: ¥7, Recharge price: ¥4 (cheaper!)
|
||||
*/
|
||||
function applyRechargeRate(
|
||||
price: number,
|
||||
showWithRecharge: boolean,
|
||||
priceRate: number,
|
||||
usdExchangeRate: number
|
||||
): number {
|
||||
if (!showWithRecharge) return price
|
||||
return (price * priceRate) / usdExchangeRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token-based price for display
|
||||
*/
|
||||
export function formatPrice(
|
||||
model: PricingModel,
|
||||
type: PriceType,
|
||||
tokenUnit: TokenUnit,
|
||||
showWithRecharge = false,
|
||||
priceRate = 1,
|
||||
usdExchangeRate = 1,
|
||||
selectedGroup?: string
|
||||
): string {
|
||||
if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const displayGroupRatio = getDisplayGroupRatio(model, selectedGroup)
|
||||
|
||||
let priceInUSD = calculateTokenPrice(model, type, displayGroupRatio)
|
||||
priceInUSD = applyRechargeRate(
|
||||
priceInUSD,
|
||||
showWithRecharge,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)
|
||||
|
||||
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
|
||||
return formatCurrencyFromUSD(price, {
|
||||
digitsLarge: 4,
|
||||
digitsSmall: 6,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for a specific group (token-based)
|
||||
*/
|
||||
export function formatGroupPrice(
|
||||
model: PricingModel,
|
||||
group: string,
|
||||
type: PriceType,
|
||||
tokenUnit: TokenUnit,
|
||||
showWithRecharge = false,
|
||||
priceRate = 1,
|
||||
usdExchangeRate = 1,
|
||||
groupRatio: Record<string, number>
|
||||
): string {
|
||||
if (model.quota_type === QUOTA_TYPE_VALUES.REQUEST) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const ratio = getConfiguredGroupRatio(groupRatio, group)
|
||||
let priceInUSD = calculateTokenPrice(model, type, ratio)
|
||||
|
||||
priceInUSD = applyRechargeRate(
|
||||
priceInUSD,
|
||||
showWithRecharge,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)
|
||||
|
||||
const price = priceInUSD / TOKEN_UNIT_DIVISORS[tokenUnit]
|
||||
return formatCurrencyFromUSD(price, {
|
||||
digitsLarge: 4,
|
||||
digitsSmall: 6,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fixed price for pay-per-request models (with specific group)
|
||||
*/
|
||||
export function formatFixedPrice(
|
||||
model: PricingModel,
|
||||
group: string,
|
||||
showWithRecharge = false,
|
||||
priceRate = 1,
|
||||
usdExchangeRate = 1,
|
||||
groupRatio: Record<string, number>
|
||||
): string {
|
||||
if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const ratio = getConfiguredGroupRatio(groupRatio, group)
|
||||
let priceInUSD = (model.model_price || 0) * ratio
|
||||
|
||||
priceInUSD = applyRechargeRate(
|
||||
priceInUSD,
|
||||
showWithRecharge,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)
|
||||
|
||||
return formatCurrencyFromUSD(priceInUSD, {
|
||||
digitsLarge: 4,
|
||||
digitsSmall: 4,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fixed price for pay-per-request models (minimum price from all groups)
|
||||
*/
|
||||
export function formatRequestPrice(
|
||||
model: PricingModel,
|
||||
showWithRecharge = false,
|
||||
priceRate = 1,
|
||||
usdExchangeRate = 1,
|
||||
selectedGroup?: string
|
||||
): string {
|
||||
if (model.quota_type !== QUOTA_TYPE_VALUES.REQUEST) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const displayGroupRatio = getDisplayGroupRatio(model, selectedGroup)
|
||||
|
||||
let priceInUSD = (model.model_price || 0) * displayGroupRatio
|
||||
|
||||
priceInUSD = applyRechargeRate(
|
||||
priceInUSD,
|
||||
showWithRecharge,
|
||||
priceRate,
|
||||
usdExchangeRate
|
||||
)
|
||||
|
||||
return formatCurrencyFromUSD(priceInUSD, {
|
||||
digitsLarge: 4,
|
||||
digitsSmall: 4,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// ----------------------------------------------------------------------------
|
||||
// Deterministic seeding helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// These utilities are used to generate stable, repeatable mock metrics for
|
||||
// model details (latency, throughput, uptime, app rankings) until the
|
||||
// backend ships real values. Seeding the PRNG from the model name (and
|
||||
// optionally the group name) ensures the same model always renders the same
|
||||
// numbers, instead of jittering on every render.
|
||||
|
||||
/** djb2-inspired string hash → non-negative 31-bit integer. */
|
||||
export function hashStringToSeed(input: string): number {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (hash * 33) ^ input.charCodeAt(i)
|
||||
}
|
||||
return Math.abs(hash | 0)
|
||||
}
|
||||
|
||||
/** Linear-congruential generator producing pseudo-random numbers in [0, 1). */
|
||||
export function seededRandom(seed: number): () => number {
|
||||
let state = (seed || 1) >>> 0
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0
|
||||
return state / 0x1_0000_0000
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick a number in [min, max] from a seeded PRNG. */
|
||||
export function randomInRange(
|
||||
rand: () => number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
return min + rand() * (max - min)
|
||||
}
|
||||
|
||||
/** Pick an integer in [min, max] (inclusive) from a seeded PRNG. */
|
||||
export function randomIntInRange(
|
||||
rand: () => number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
return Math.floor(randomInRange(rand, min, max + 1))
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { BILLING_CACHE_VAR_MAP } from './billing-expr'
|
||||
|
||||
export const CACHE_MODE_TIMED = 'timed'
|
||||
export const CACHE_MODE_GENERIC = 'generic'
|
||||
export type CacheMode = typeof CACHE_MODE_TIMED | typeof CACHE_MODE_GENERIC
|
||||
|
||||
export type TierConditionInput = {
|
||||
var: 'p' | 'c' | 'len'
|
||||
op: '<' | '<=' | '>' | '>='
|
||||
value: number | string
|
||||
}
|
||||
|
||||
export type VisualTier = {
|
||||
label: string
|
||||
conditions: TierConditionInput[]
|
||||
input_unit_cost: number
|
||||
output_unit_cost: number
|
||||
cache_mode: CacheMode
|
||||
cache_read_unit_cost?: number
|
||||
cache_create_unit_cost?: number
|
||||
cache_create_1h_unit_cost?: number
|
||||
image_unit_cost?: number
|
||||
image_output_unit_cost?: number
|
||||
audio_input_unit_cost?: number
|
||||
audio_output_unit_cost?: number
|
||||
[field: string]: unknown
|
||||
}
|
||||
|
||||
export type VisualConfig = {
|
||||
tiers: VisualTier[]
|
||||
}
|
||||
|
||||
export function getTierCacheMode(
|
||||
tier: Partial<VisualTier> | null | undefined
|
||||
): CacheMode {
|
||||
if (tier?.cache_mode === CACHE_MODE_TIMED) return CACHE_MODE_TIMED
|
||||
if (tier?.cache_mode === CACHE_MODE_GENERIC) return CACHE_MODE_GENERIC
|
||||
return Number(tier?.cache_create_1h_unit_cost) > 0
|
||||
? CACHE_MODE_TIMED
|
||||
: CACHE_MODE_GENERIC
|
||||
}
|
||||
|
||||
export function normalizeVisualTier(
|
||||
tier: Partial<VisualTier> = {}
|
||||
): VisualTier {
|
||||
return {
|
||||
label: tier.label ?? '',
|
||||
input_unit_cost: Number(tier.input_unit_cost) || 0,
|
||||
output_unit_cost: Number(tier.output_unit_cost) || 0,
|
||||
cache_mode: getTierCacheMode(tier),
|
||||
conditions: Array.isArray(tier.conditions) ? tier.conditions : [],
|
||||
...tier,
|
||||
cache_read_unit_cost: Number(tier.cache_read_unit_cost) || 0,
|
||||
cache_create_unit_cost: Number(tier.cache_create_unit_cost) || 0,
|
||||
cache_create_1h_unit_cost: Number(tier.cache_create_1h_unit_cost) || 0,
|
||||
image_unit_cost: Number(tier.image_unit_cost) || 0,
|
||||
image_output_unit_cost: Number(tier.image_output_unit_cost) || 0,
|
||||
audio_input_unit_cost: Number(tier.audio_input_unit_cost) || 0,
|
||||
audio_output_unit_cost: Number(tier.audio_output_unit_cost) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultVisualConfig(): VisualConfig {
|
||||
return {
|
||||
tiers: [
|
||||
normalizeVisualTier({
|
||||
conditions: [],
|
||||
input_unit_cost: 0,
|
||||
output_unit_cost: 0,
|
||||
label: 'base',
|
||||
cache_mode: CACHE_MODE_GENERIC,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeVisualConfig(
|
||||
config: VisualConfig | null | undefined
|
||||
): VisualConfig {
|
||||
if (!config || !Array.isArray(config.tiers) || config.tiers.length === 0) {
|
||||
return createDefaultVisualConfig()
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
tiers: config.tiers.map((tier) => normalizeVisualTier(tier)),
|
||||
}
|
||||
}
|
||||
|
||||
function buildConditionStr(conditions: TierConditionInput[]): string {
|
||||
if (!conditions || conditions.length === 0) return ''
|
||||
return conditions
|
||||
.filter((c) => c.var && c.op && c.value != null && c.value !== '')
|
||||
.map((c) => `${c.var} ${c.op} ${c.value}`)
|
||||
.join(' && ')
|
||||
}
|
||||
|
||||
function buildTierBodyExpr(tier: VisualTier): string {
|
||||
const parts: string[] = []
|
||||
const ic = Number(tier.input_unit_cost) || 0
|
||||
const oc = Number(tier.output_unit_cost) || 0
|
||||
parts.push(`p * ${ic}`)
|
||||
parts.push(`c * ${oc}`)
|
||||
for (const cv of BILLING_CACHE_VAR_MAP) {
|
||||
const v = Number((tier as Record<string, unknown>)[cv.field]) || 0
|
||||
if (v !== 0) parts.push(`${cv.exprVar} * ${v}`)
|
||||
}
|
||||
return parts.join(' + ')
|
||||
}
|
||||
|
||||
export function generateExprFromVisualConfig(
|
||||
config: VisualConfig | null | undefined
|
||||
): string {
|
||||
if (!config || !config.tiers || config.tiers.length === 0) {
|
||||
return 'p * 0 + c * 0'
|
||||
}
|
||||
const tiers = config.tiers
|
||||
|
||||
if (tiers.length === 1) {
|
||||
const tier = tiers[0]
|
||||
const label = tier.label || 'default'
|
||||
const body = `tier("${label}", ${buildTierBodyExpr(tier)})`
|
||||
const cond = buildConditionStr(tier.conditions)
|
||||
if (cond) {
|
||||
return `${cond} ? ${body} : p * 0 + c * 0`
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i < tiers.length; i++) {
|
||||
const tier = tiers[i]
|
||||
const label = tier.label || `tier_${i + 1}`
|
||||
const body = `tier("${label}", ${buildTierBodyExpr(tier)})`
|
||||
const cond = buildConditionStr(tier.conditions)
|
||||
|
||||
if (i < tiers.length - 1 && cond) {
|
||||
parts.push(`${cond} ? ${body}`)
|
||||
} else {
|
||||
parts.push(body)
|
||||
}
|
||||
}
|
||||
return parts.join(' : ')
|
||||
}
|
||||
|
||||
export function tryParseVisualConfig(
|
||||
exprStr: string | null | undefined
|
||||
): VisualConfig | null {
|
||||
if (!exprStr) return null
|
||||
try {
|
||||
let body = exprStr
|
||||
const versionMatch = body.match(/^v\d+:([\s\S]*)$/)
|
||||
if (versionMatch) body = versionMatch[1]
|
||||
const cacheVarNames = BILLING_CACHE_VAR_MAP.map((cv) => cv.exprVar)
|
||||
const optCacheStr = cacheVarNames
|
||||
.map((v) => `(?:\\s*\\+\\s*${v}\\s*\\*\\s*([\\d.eE+-]+))?`)
|
||||
.join('')
|
||||
|
||||
const bodyPat = `p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)${optCacheStr}`
|
||||
|
||||
const singleRe = new RegExp(`^tier\\("([^"]*)",\\s*${bodyPat}\\)$`)
|
||||
const simple = body.match(singleRe)
|
||||
if (simple) {
|
||||
const tier: Record<string, unknown> = {
|
||||
conditions: [],
|
||||
input_unit_cost: Number(simple[2]),
|
||||
output_unit_cost: Number(simple[3]),
|
||||
label: simple[1],
|
||||
}
|
||||
BILLING_CACHE_VAR_MAP.forEach((cv, i) => {
|
||||
const val = simple[4 + i]
|
||||
if (val != null) tier[cv.field] = Number(val)
|
||||
})
|
||||
return normalizeVisualConfig({
|
||||
tiers: [normalizeVisualTier(tier as Partial<VisualTier>)],
|
||||
})
|
||||
}
|
||||
|
||||
const condGroup =
|
||||
`((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)` +
|
||||
`(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`
|
||||
const tierRe = new RegExp(
|
||||
`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*${bodyPat}\\)`,
|
||||
'g'
|
||||
)
|
||||
const tiers: VisualTier[] = []
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = tierRe.exec(body)) !== null) {
|
||||
const condStr = match[1] || ''
|
||||
const conditions: TierConditionInput[] = []
|
||||
if (condStr) {
|
||||
for (const cp of condStr.split(/\s*&&\s*/)) {
|
||||
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/)
|
||||
if (cm) {
|
||||
conditions.push({
|
||||
var: cm[1] as TierConditionInput['var'],
|
||||
op: cm[2] as TierConditionInput['op'],
|
||||
value: Number(cm[3]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const tier: Record<string, unknown> = {
|
||||
conditions,
|
||||
input_unit_cost: Number(match[3]),
|
||||
output_unit_cost: Number(match[4]),
|
||||
label: match[2],
|
||||
}
|
||||
const m = match
|
||||
BILLING_CACHE_VAR_MAP.forEach((cv, i) => {
|
||||
const val = m[5 + i]
|
||||
if (val != null) tier[cv.field] = Number(val)
|
||||
})
|
||||
tiers.push(normalizeVisualTier(tier as Partial<VisualTier>))
|
||||
}
|
||||
if (tiers.length === 0) return null
|
||||
|
||||
const cfg = normalizeVisualConfig({ tiers })
|
||||
const regenerated = generateExprFromVisualConfig(cfg)
|
||||
if (regenerated.replace(/\s+/g, '') !== body.replace(/\s+/g, '')) {
|
||||
return null
|
||||
}
|
||||
return cfg
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local cost evaluator (for the estimator preview)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ESTIMATOR_VARS = [
|
||||
{ var: 'cr', stateKey: 'cacheReadTokens' },
|
||||
{ var: 'cc', stateKey: 'cacheCreateTokens' },
|
||||
{ var: 'cc1h', stateKey: 'cacheCreate1hTokens' },
|
||||
{ var: 'img', stateKey: 'imageTokens' },
|
||||
{ var: 'img_o', stateKey: 'imageOutputTokens' },
|
||||
{ var: 'ai', stateKey: 'audioInputTokens' },
|
||||
{ var: 'ao', stateKey: 'audioOutputTokens' },
|
||||
] as const
|
||||
|
||||
export type ExtraTokenValues = Record<
|
||||
(typeof ESTIMATOR_VARS)[number]['stateKey'],
|
||||
number
|
||||
>
|
||||
|
||||
export type EvalResult = {
|
||||
cost: number
|
||||
matchedTier: string
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function evalExprLocally(
|
||||
exprStr: string,
|
||||
promptTokens: number,
|
||||
completionTokens: number,
|
||||
extraTokenValues: ExtraTokenValues
|
||||
): EvalResult {
|
||||
try {
|
||||
if (!exprStr || !exprStr.trim()) {
|
||||
return { cost: 0, matchedTier: '', error: null }
|
||||
}
|
||||
let matchedTier = ''
|
||||
const tierFn = (name: string, value: number) => {
|
||||
matchedTier = name
|
||||
return value
|
||||
}
|
||||
const cacheReadTokens = extraTokenValues.cacheReadTokens || 0
|
||||
const cacheCreateTokens = extraTokenValues.cacheCreateTokens || 0
|
||||
const cacheCreate1hTokens = extraTokenValues.cacheCreate1hTokens || 0
|
||||
const len =
|
||||
promptTokens + cacheReadTokens + cacheCreateTokens + cacheCreate1hTokens
|
||||
const env: Record<string, unknown> = {
|
||||
p: promptTokens,
|
||||
c: completionTokens,
|
||||
len,
|
||||
tier: tierFn,
|
||||
max: Math.max,
|
||||
min: Math.min,
|
||||
abs: Math.abs,
|
||||
ceil: Math.ceil,
|
||||
floor: Math.floor,
|
||||
}
|
||||
for (const field of ESTIMATOR_VARS) {
|
||||
env[field.var] = extraTokenValues[field.stateKey] || 0
|
||||
}
|
||||
const fn = new Function(
|
||||
...Object.keys(env),
|
||||
`"use strict"; return (${exprStr});`
|
||||
)
|
||||
const cost = Number(fn(...Object.values(env))) || 0
|
||||
return { cost, matchedTier, error: null }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { cost: 0, matchedTier: '', error: message }
|
||||
}
|
||||
}
|
||||
|
||||
export function exprUsesExtraVars(exprStr: string): boolean {
|
||||
if (!exprStr) return false
|
||||
const varNames = ESTIMATOR_VARS.map((f) => f.var).join('|')
|
||||
return new RegExp(`\\b(${varNames})\\b`).test(exprStr)
|
||||
}
|
||||
|
||||
export const ESTIMATOR_EXTRA_FIELDS = ESTIMATOR_VARS
|
||||
Vendored
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pricing Types
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type PricingVendor = {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type PricingModel = {
|
||||
id: number
|
||||
model_name: string
|
||||
description?: string
|
||||
icon?: string
|
||||
vendor_id?: number
|
||||
vendor_name?: string
|
||||
vendor_icon?: string
|
||||
vendor_description?: string
|
||||
quota_type: number
|
||||
model_ratio: number
|
||||
completion_ratio: number
|
||||
model_price?: number
|
||||
cache_ratio?: number | null
|
||||
create_cache_ratio?: number | null
|
||||
image_ratio?: number | null
|
||||
audio_ratio?: number | null
|
||||
audio_completion_ratio?: number | null
|
||||
enable_groups: string[]
|
||||
tags?: string
|
||||
supported_endpoint_types?: string[]
|
||||
key?: string
|
||||
group_ratio?: Record<string, number>
|
||||
/** Billing mode (e.g. "tiered_expr") used to flag dynamic pricing */
|
||||
billing_mode?: string
|
||||
/** Raw expression describing dynamic / tiered billing */
|
||||
billing_expr?: string
|
||||
/** Pricing version returned by backend, useful for cache busting */
|
||||
pricing_version?: string
|
||||
/**
|
||||
* Optional model metadata fields reserved for backend-provided catalog data.
|
||||
* Keep them data-driven; do not synthesize display values on the client.
|
||||
*/
|
||||
context_length?: number
|
||||
max_output_tokens?: number
|
||||
knowledge_cutoff?: string
|
||||
release_date?: string
|
||||
parameter_count?: string
|
||||
input_modalities?: Modality[]
|
||||
output_modalities?: Modality[]
|
||||
capabilities?: ModelCapability[]
|
||||
}
|
||||
|
||||
/** Input/output modalities supported by a model. */
|
||||
export type Modality = 'text' | 'image' | 'audio' | 'video' | 'file'
|
||||
|
||||
/** Functional capabilities a model exposes. */
|
||||
export type ModelCapability =
|
||||
| 'function_calling'
|
||||
| 'streaming'
|
||||
| 'vision'
|
||||
| 'json_mode'
|
||||
| 'structured_output'
|
||||
| 'reasoning'
|
||||
| 'tools'
|
||||
| 'system_prompt'
|
||||
| 'web_search'
|
||||
| 'code_interpreter'
|
||||
| 'caching'
|
||||
| 'embeddings'
|
||||
|
||||
export type PricingData = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: PricingModel[]
|
||||
vendors: PricingVendor[]
|
||||
group_ratio: Record<string, number>
|
||||
usable_group: Record<string, { desc: string; ratio: number }>
|
||||
supported_endpoint: Record<string, string>
|
||||
auto_groups: string[]
|
||||
}
|
||||
|
||||
export type TokenUnit = 'M' | 'K'
|
||||
export type PriceType =
|
||||
| 'input'
|
||||
| 'output'
|
||||
| 'cache'
|
||||
| 'create_cache'
|
||||
| 'image'
|
||||
| 'audio_input'
|
||||
| 'audio_output'
|
||||
export type QuotaType = 0 | 1 // 0: token-based, 1: per-request
|
||||
Reference in New Issue
Block a user