mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
feat(performance): implement success rate grading and enhance UI representation
This commit is contained in:
+5
-17
@@ -27,6 +27,8 @@ import {
|
|||||||
formatLatency,
|
formatLatency,
|
||||||
formatThroughput,
|
formatThroughput,
|
||||||
formatUptimePct,
|
formatUptimePct,
|
||||||
|
getSuccessRateDotClass,
|
||||||
|
getSuccessRateTextClass,
|
||||||
} from '@/features/performance-metrics/lib/format'
|
} from '@/features/performance-metrics/lib/format'
|
||||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||||
|
|
||||||
@@ -79,20 +81,6 @@ function buildPerformanceSummary(rows: PerfModelSummary[]): PerformanceSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function successRateClassName(successRate: number): string {
|
|
||||||
if (!Number.isFinite(successRate)) return 'text-muted-foreground'
|
|
||||||
if (successRate >= 99.9) return 'text-success'
|
|
||||||
if (successRate >= 99) return 'text-warning'
|
|
||||||
return 'text-destructive'
|
|
||||||
}
|
|
||||||
|
|
||||||
function successDotClassName(successRate: number): string {
|
|
||||||
if (!Number.isFinite(successRate)) return 'bg-muted-foreground'
|
|
||||||
if (successRate >= 99.9) return 'bg-success'
|
|
||||||
if (successRate >= 99) return 'bg-warning'
|
|
||||||
return 'bg-destructive'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PerformanceOverview() {
|
export function PerformanceOverview() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const metricsQuery = useQuery({
|
const metricsQuery = useQuery({
|
||||||
@@ -152,7 +140,7 @@ export function PerformanceOverview() {
|
|||||||
icon={HeartPulse}
|
icon={HeartPulse}
|
||||||
label={t('Success rate')}
|
label={t('Success rate')}
|
||||||
value={formatUptimePct(summary.successRate)}
|
value={formatUptimePct(summary.successRate)}
|
||||||
valueClassName={successRateClassName(summary.successRate)}
|
valueClassName={getSuccessRateTextClass(summary.successRate)}
|
||||||
/>
|
/>
|
||||||
<InlineMetric
|
<InlineMetric
|
||||||
icon={Timer}
|
icon={Timer}
|
||||||
@@ -221,14 +209,14 @@ function ModelBadge(props: { model: PerfModelSummary }) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-1.5 rounded-full',
|
'size-1.5 rounded-full',
|
||||||
successDotClassName(model.success_rate)
|
getSuccessRateDotClass(model.success_rate)
|
||||||
)}
|
)}
|
||||||
aria-hidden='true'
|
aria-hidden='true'
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[11px] font-semibold tabular-nums',
|
'font-mono text-[11px] font-semibold tabular-nums',
|
||||||
successRateClassName(model.success_rate)
|
getSuccessRateTextClass(model.success_rate)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatUptimePct(model.success_rate)}
|
{formatUptimePct(model.success_rate)}
|
||||||
|
|||||||
+5
-17
@@ -27,6 +27,8 @@ import {
|
|||||||
formatLatency,
|
formatLatency,
|
||||||
formatThroughput,
|
formatThroughput,
|
||||||
formatUptimePct,
|
formatUptimePct,
|
||||||
|
getSuccessRateDotClass,
|
||||||
|
getSuccessRateTextClass,
|
||||||
} from '@/features/performance-metrics/lib/format'
|
} from '@/features/performance-metrics/lib/format'
|
||||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||||
|
|
||||||
@@ -51,20 +53,6 @@ function simpleAverage(
|
|||||||
return count > 0 ? total / count : NaN
|
return count > 0 ? total / count : NaN
|
||||||
}
|
}
|
||||||
|
|
||||||
function rateTextClass(rate: number): string {
|
|
||||||
if (!Number.isFinite(rate)) return 'text-muted-foreground'
|
|
||||||
if (rate >= 99.9) return 'text-success'
|
|
||||||
if (rate >= 99) return 'text-warning'
|
|
||||||
return 'text-destructive'
|
|
||||||
}
|
|
||||||
|
|
||||||
function rateDotClass(rate: number): string {
|
|
||||||
if (!Number.isFinite(rate)) return 'bg-muted-foreground'
|
|
||||||
if (rate >= 99.9) return 'bg-success'
|
|
||||||
if (rate >= 99) return 'bg-warning'
|
|
||||||
return 'bg-destructive'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PerformanceHealthPanel() {
|
export function PerformanceHealthPanel() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const metricsQuery = useQuery({
|
const metricsQuery = useQuery({
|
||||||
@@ -121,7 +109,7 @@ export function PerformanceHealthPanel() {
|
|||||||
label={t('Success rate')}
|
label={t('Success rate')}
|
||||||
value={formatUptimePct(summary.successRate)}
|
value={formatUptimePct(summary.successRate)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
valueClassName={rateTextClass(summary.successRate)}
|
valueClassName={getSuccessRateTextClass(summary.successRate)}
|
||||||
/>
|
/>
|
||||||
<MetricCell
|
<MetricCell
|
||||||
icon={Timer}
|
icon={Timer}
|
||||||
@@ -162,14 +150,14 @@ export function PerformanceHealthPanel() {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-1.5 rounded-full',
|
'size-1.5 rounded-full',
|
||||||
rateDotClass(model.success_rate)
|
getSuccessRateDotClass(model.success_rate)
|
||||||
)}
|
)}
|
||||||
aria-hidden='true'
|
aria-hidden='true'
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[11px] font-semibold tabular-nums',
|
'font-mono text-[11px] font-semibold tabular-nums',
|
||||||
rateTextClass(model.success_rate)
|
getSuccessRateTextClass(model.success_rate)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatUptimePct(model.success_rate)}
|
{formatUptimePct(model.success_rate)}
|
||||||
|
|||||||
@@ -32,3 +32,67 @@ export function formatUptimePct(pct: number): string {
|
|||||||
if (!Number.isFinite(pct)) return '—'
|
if (!Number.isFinite(pct)) return '—'
|
||||||
return `${pct.toFixed(2)}%`
|
return `${pct.toFixed(2)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SuccessRateLevel =
|
||||||
|
| 'excellent'
|
||||||
|
| 'good'
|
||||||
|
| 'warning'
|
||||||
|
| 'critical'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
const SUCCESS_RATE_EXCELLENT_MIN = 100
|
||||||
|
const SUCCESS_RATE_GOOD_MIN = 90
|
||||||
|
const SUCCESS_RATE_WARNING_MIN = 70
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for grading a success rate (0-100).
|
||||||
|
* - excellent: 100% (full green)
|
||||||
|
* - good: >= 90% (slightly lighter green)
|
||||||
|
* - warning: >= 70%
|
||||||
|
* - critical: below 70%
|
||||||
|
* - unknown: non-finite values
|
||||||
|
*/
|
||||||
|
export function getSuccessRateLevel(rate: number): SuccessRateLevel {
|
||||||
|
if (!Number.isFinite(rate)) return 'unknown'
|
||||||
|
if (rate >= SUCCESS_RATE_EXCELLENT_MIN) return 'excellent'
|
||||||
|
if (rate >= SUCCESS_RATE_GOOD_MIN) return 'good'
|
||||||
|
if (rate >= SUCCESS_RATE_WARNING_MIN) return 'warning'
|
||||||
|
return 'critical'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUCCESS_RATE_TEXT_CLASS: Record<SuccessRateLevel, string> = {
|
||||||
|
excellent: 'text-success',
|
||||||
|
good: 'text-success/70',
|
||||||
|
warning: 'text-warning',
|
||||||
|
critical: 'text-destructive',
|
||||||
|
unknown: 'text-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUCCESS_RATE_DOT_CLASS: Record<SuccessRateLevel, string> = {
|
||||||
|
excellent: 'bg-success',
|
||||||
|
good: 'bg-success/60',
|
||||||
|
warning: 'bg-warning',
|
||||||
|
critical: 'bg-destructive',
|
||||||
|
unknown: 'bg-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex colors for non-CSS contexts (e.g. chart libraries that need raw values).
|
||||||
|
const SUCCESS_RATE_HEX_COLOR: Record<SuccessRateLevel, string> = {
|
||||||
|
excellent: '#10b981', // emerald-500 (full green)
|
||||||
|
good: '#34d399', // emerald-400 (slightly lighter green)
|
||||||
|
warning: '#f59e0b', // amber-500
|
||||||
|
critical: '#ef4444', // red-500
|
||||||
|
unknown: '#9ca3af', // gray-400
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuccessRateTextClass(rate: number): string {
|
||||||
|
return SUCCESS_RATE_TEXT_CLASS[getSuccessRateLevel(rate)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuccessRateDotClass(rate: number): string {
|
||||||
|
return SUCCESS_RATE_DOT_CLASS[getSuccessRateLevel(rate)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuccessRateColor(rate: number): string {
|
||||||
|
return SUCCESS_RATE_HEX_COLOR[getSuccessRateLevel(rate)]
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,17 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ExternalLink,
|
|
||||||
Gauge,
|
Gauge,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
ShieldCheck,
|
|
||||||
Sigma,
|
Sigma,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { BundledLanguage } from 'shiki/bundle/web'
|
import type { BundledLanguage } from 'shiki/bundle/web'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useStatus } from '@/hooks/use-status'
|
import { useStatus } from '@/hooks/use-status'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
@@ -48,7 +45,6 @@ import {
|
|||||||
type SupportedParameter,
|
type SupportedParameter,
|
||||||
} from '../lib/mock-stats'
|
} from '../lib/mock-stats'
|
||||||
import { replaceModelInPath } from '../lib/model-helpers'
|
import { replaceModelInPath } from '../lib/model-helpers'
|
||||||
import { inferApiInfo } from '../lib/model-metadata'
|
|
||||||
import type { PricingModel } from '../types'
|
import type { PricingModel } from '../types'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -722,106 +718,6 @@ function RateLimitsSection(props: { model: PricingModel }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Provider info card (vendor / tokenizer / license / privacy)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Exported separately so the Overview tab can render it alongside capabilities
|
|
||||||
// and modalities (i.e. "what is this model?" rather than "how do I call it?").
|
|
||||||
|
|
||||||
export function ModelDetailsProviderInfo(props: { model: PricingModel }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const info = useMemo(() => inferApiInfo(props.model), [props.model])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<SectionTitle icon={ShieldCheck}>
|
|
||||||
{t('Provider & data privacy')}
|
|
||||||
</SectionTitle>
|
|
||||||
|
|
||||||
<div className='border-border/60 bg-border/60 grid grid-cols-1 gap-px overflow-hidden rounded-lg border sm:grid-cols-2'>
|
|
||||||
<InfoCell label={t('Provider')}>
|
|
||||||
<div className='flex items-center gap-1.5'>
|
|
||||||
<span className='text-sm font-medium'>{info.vendor_label}</span>
|
|
||||||
{info.homepage && (
|
|
||||||
<a
|
|
||||||
href={info.homepage}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='text-muted-foreground hover:text-foreground inline-flex items-center gap-0.5 text-[11px]'
|
|
||||||
>
|
|
||||||
{t('Docs')}
|
|
||||||
<ExternalLink className='size-3' />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</InfoCell>
|
|
||||||
|
|
||||||
<InfoCell label={t('Tokenizer')}>
|
|
||||||
<div className='flex flex-col gap-0.5'>
|
|
||||||
<code className='font-mono text-xs'>{info.tokenizer}</code>
|
|
||||||
{info.tokenizer_note && (
|
|
||||||
<span className='text-muted-foreground text-[10px]'>
|
|
||||||
{info.tokenizer_note}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</InfoCell>
|
|
||||||
|
|
||||||
<InfoCell label={t('License')}>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<span className='text-sm'>{info.license}</span>
|
|
||||||
<Badge
|
|
||||||
variant='outline'
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-fit px-1.5 text-[9px] font-medium',
|
|
||||||
info.license_kind === 'open' &&
|
|
||||||
'border-emerald-500/40 text-emerald-600 dark:text-emerald-400',
|
|
||||||
info.license_kind === 'open-weight' &&
|
|
||||||
'border-sky-500/40 text-sky-600 dark:text-sky-400',
|
|
||||||
info.license_kind === 'proprietary' &&
|
|
||||||
'border-amber-500/40 text-amber-600 dark:text-amber-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{info.license_kind === 'open'
|
|
||||||
? t('Open source')
|
|
||||||
: info.license_kind === 'open-weight'
|
|
||||||
? t('Open weights')
|
|
||||||
: info.license_kind === 'proprietary'
|
|
||||||
? t('Proprietary')
|
|
||||||
: t('Unknown')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</InfoCell>
|
|
||||||
|
|
||||||
<InfoCell label={t('Data retention')}>
|
|
||||||
<span className='text-sm'>
|
|
||||||
{info.data_retention_days === 0
|
|
||||||
? t('Zero retention')
|
|
||||||
: `${info.data_retention_days} ${t('days')}`}
|
|
||||||
</span>
|
|
||||||
<span className='text-muted-foreground text-[10px]'>
|
|
||||||
{info.training_opt_out
|
|
||||||
? t('Not used for upstream training by default')
|
|
||||||
: t('May be used for training by upstream provider')}
|
|
||||||
</span>
|
|
||||||
</InfoCell>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoCell(props: { label: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className='bg-card flex flex-col gap-1 px-3 py-2.5'>
|
|
||||||
<span className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Authentication preview
|
// Authentication preview
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
/*
|
|
||||||
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 {
|
|
||||||
BookOpenCheck,
|
|
||||||
Braces,
|
|
||||||
Code2,
|
|
||||||
Database,
|
|
||||||
FileCode,
|
|
||||||
Globe,
|
|
||||||
type LucideIcon,
|
|
||||||
PanelTopOpen,
|
|
||||||
ScanEye,
|
|
||||||
Settings2,
|
|
||||||
Sparkles,
|
|
||||||
Workflow,
|
|
||||||
Zap,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import type { ModelCapability } from '../types'
|
|
||||||
|
|
||||||
type CapabilityMeta = {
|
|
||||||
icon: LucideIcon
|
|
||||||
labelKey: string
|
|
||||||
descriptionKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const CAPABILITY_META: Record<ModelCapability, CapabilityMeta> = {
|
|
||||||
function_calling: {
|
|
||||||
icon: Workflow,
|
|
||||||
labelKey: 'Function calling',
|
|
||||||
descriptionKey:
|
|
||||||
'Invoke developer-defined functions with structured arguments',
|
|
||||||
},
|
|
||||||
streaming: {
|
|
||||||
icon: Zap,
|
|
||||||
labelKey: 'Streaming',
|
|
||||||
descriptionKey: 'Stream tokens incrementally as they are generated',
|
|
||||||
},
|
|
||||||
vision: {
|
|
||||||
icon: ScanEye,
|
|
||||||
labelKey: 'Vision',
|
|
||||||
descriptionKey: 'Understand image inputs alongside text',
|
|
||||||
},
|
|
||||||
json_mode: {
|
|
||||||
icon: Braces,
|
|
||||||
labelKey: 'JSON mode',
|
|
||||||
descriptionKey: 'Force a syntactically valid JSON response',
|
|
||||||
},
|
|
||||||
structured_output: {
|
|
||||||
icon: FileCode,
|
|
||||||
labelKey: 'Structured output',
|
|
||||||
descriptionKey: 'Return data conforming to a JSON schema',
|
|
||||||
},
|
|
||||||
reasoning: {
|
|
||||||
icon: Sparkles,
|
|
||||||
labelKey: 'Reasoning',
|
|
||||||
descriptionKey: 'Multi-step thinking before final answer',
|
|
||||||
},
|
|
||||||
tools: {
|
|
||||||
icon: Settings2,
|
|
||||||
labelKey: 'Tools',
|
|
||||||
descriptionKey: 'Use external tools to extend capabilities',
|
|
||||||
},
|
|
||||||
system_prompt: {
|
|
||||||
icon: PanelTopOpen,
|
|
||||||
labelKey: 'System prompt',
|
|
||||||
descriptionKey: 'Steer behaviour with a system instruction',
|
|
||||||
},
|
|
||||||
web_search: {
|
|
||||||
icon: Globe,
|
|
||||||
labelKey: 'Web search',
|
|
||||||
descriptionKey: 'Search the public web at inference time',
|
|
||||||
},
|
|
||||||
code_interpreter: {
|
|
||||||
icon: Code2,
|
|
||||||
labelKey: 'Code interpreter',
|
|
||||||
descriptionKey: 'Execute code in a sandbox during the response',
|
|
||||||
},
|
|
||||||
caching: {
|
|
||||||
icon: Database,
|
|
||||||
labelKey: 'Prompt caching',
|
|
||||||
descriptionKey: 'Cache repeated prompt prefixes for cheaper, faster reuse',
|
|
||||||
},
|
|
||||||
embeddings: {
|
|
||||||
icon: BookOpenCheck,
|
|
||||||
labelKey: 'Embeddings',
|
|
||||||
descriptionKey: 'Return vector embeddings for inputs',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Order capabilities for display. We put the most user-facing capabilities
|
|
||||||
* first, then the rest. Anything not listed sinks to the bottom in a stable
|
|
||||||
* order so the layout looks tidy across models.
|
|
||||||
*/
|
|
||||||
const CAPABILITY_ORDER: ModelCapability[] = [
|
|
||||||
'streaming',
|
|
||||||
'function_calling',
|
|
||||||
'tools',
|
|
||||||
'json_mode',
|
|
||||||
'structured_output',
|
|
||||||
'vision',
|
|
||||||
'reasoning',
|
|
||||||
'caching',
|
|
||||||
'system_prompt',
|
|
||||||
'web_search',
|
|
||||||
'code_interpreter',
|
|
||||||
'embeddings',
|
|
||||||
]
|
|
||||||
|
|
||||||
function orderCapabilities(capabilities: ModelCapability[]): ModelCapability[] {
|
|
||||||
const set = new Set(capabilities)
|
|
||||||
const ordered = CAPABILITY_ORDER.filter((c) => set.has(c))
|
|
||||||
for (const c of capabilities) {
|
|
||||||
if (!ordered.includes(c)) ordered.push(c)
|
|
||||||
}
|
|
||||||
return ordered
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelDetailsCapabilities(props: {
|
|
||||||
capabilities: ModelCapability[]
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const ordered = orderCapabilities(props.capabilities)
|
|
||||||
|
|
||||||
if (ordered.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
|
||||||
{t('No capabilities reported for this model.')}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='grid grid-cols-2 gap-2 @md/details:grid-cols-3 @2xl/details:grid-cols-4'>
|
|
||||||
{ordered.map((capability) => {
|
|
||||||
const meta = CAPABILITY_META[capability]
|
|
||||||
if (!meta) return null
|
|
||||||
const Icon = meta.icon
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={capability}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-start gap-2 rounded-lg border p-3 transition-colors',
|
|
||||||
'hover:bg-muted/30'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className='bg-muted text-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md transition-colors group-hover:bg-emerald-100 group-hover:text-emerald-700 dark:group-hover:bg-emerald-500/20 dark:group-hover:text-emerald-300'>
|
|
||||||
<Icon className='size-3.5' />
|
|
||||||
</span>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<div className='text-foreground truncate text-xs font-semibold'>
|
|
||||||
{t(meta.labelKey)}
|
|
||||||
</div>
|
|
||||||
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[11px] leading-snug'>
|
|
||||||
{t(meta.descriptionKey)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ import { useChartTheme } from '@/lib/use-chart-theme'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { VCHART_OPTION } from '@/lib/vchart'
|
import { VCHART_OPTION } from '@/lib/vchart'
|
||||||
import { useThemeCustomization } from '@/context/theme-customization-provider'
|
import { useThemeCustomization } from '@/context/theme-customization-provider'
|
||||||
|
import { getSuccessRateColor } from '@/features/performance-metrics/lib/format'
|
||||||
import type { LatencyTimePoint, UptimeDayPoint } from '../lib/mock-stats'
|
import type { LatencyTimePoint, UptimeDayPoint } from '../lib/mock-stats'
|
||||||
|
|
||||||
function formatHourLabel(iso: string): string {
|
function formatHourLabel(iso: string): string {
|
||||||
@@ -229,11 +230,7 @@ export function UptimeTrendChart(props: {
|
|||||||
size: 5,
|
size: 5,
|
||||||
stroke: '#ffffff',
|
stroke: '#ffffff',
|
||||||
lineWidth: 1.5,
|
lineWidth: 1.5,
|
||||||
fill: (datum: { uptime: number }) => {
|
fill: (datum: { uptime: number }) => getSuccessRateColor(datum.uptime),
|
||||||
if (datum.uptime >= 99.9) return '#10b981'
|
|
||||||
if (datum.uptime >= 99.0) return '#f59e0b'
|
|
||||||
return '#ef4444'
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
/*
|
|
||||||
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 {
|
|
||||||
FileText,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Mic2,
|
|
||||||
Type as TypeIcon,
|
|
||||||
Video,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import { StaticDataTable } from '@/components/data-table'
|
|
||||||
import type { Modality } from '../types'
|
|
||||||
|
|
||||||
type IconComponent = React.ComponentType<{ className?: string }>
|
|
||||||
|
|
||||||
const MODALITY_META: Record<
|
|
||||||
Modality,
|
|
||||||
{ icon: IconComponent; labelKey: string }
|
|
||||||
> = {
|
|
||||||
text: { icon: TypeIcon, labelKey: 'Text' },
|
|
||||||
image: { icon: ImageIcon, labelKey: 'Image' },
|
|
||||||
audio: { icon: Mic2, labelKey: 'Audio' },
|
|
||||||
video: { icon: Video, labelKey: 'Video' },
|
|
||||||
file: { icon: FileText, labelKey: 'File' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_MODALITIES: Modality[] = ['text', 'image', 'audio', 'video', 'file']
|
|
||||||
|
|
||||||
/** Inline modality icons (used by the quick-stats flow). */
|
|
||||||
export function ModalityIcons(props: {
|
|
||||||
modalities: Modality[]
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
if (props.modalities.length === 0) {
|
|
||||||
return <span className='text-muted-foreground text-xs'>—</span>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className='inline-flex items-center gap-1'>
|
|
||||||
{props.modalities.map((modality) => {
|
|
||||||
const meta = MODALITY_META[modality]
|
|
||||||
const Icon = meta.icon
|
|
||||||
return (
|
|
||||||
<Tooltip key={modality}>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<span
|
|
||||||
aria-label={t(meta.labelKey)}
|
|
||||||
className='text-foreground/80 inline-flex'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className={cn('size-3.5', props.className)} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side='top' className='text-xs'>
|
|
||||||
{t(meta.labelKey)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2 × N matrix showing which modalities are supported as input vs output.
|
|
||||||
* Cells with a checkmark indicate support; empty cells show a dash.
|
|
||||||
*/
|
|
||||||
export function ModalitiesMatrix(props: {
|
|
||||||
input: Modality[]
|
|
||||||
output: Modality[]
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const inputSet = new Set(props.input)
|
|
||||||
const outputSet = new Set(props.output)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StaticDataTable
|
|
||||||
className='rounded-lg'
|
|
||||||
tableClassName='text-sm'
|
|
||||||
headerRowClassName='bg-muted/40'
|
|
||||||
data={[
|
|
||||||
{ label: t('Input'), set: inputSet },
|
|
||||||
{ label: t('Output'), set: outputSet },
|
|
||||||
]}
|
|
||||||
getRowKey={(row) => row.label}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
id: 'modality',
|
|
||||||
header: t('Modality'),
|
|
||||||
className:
|
|
||||||
'text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
|
|
||||||
cellClassName:
|
|
||||||
'text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
|
|
||||||
cell: (row) => row.label,
|
|
||||||
},
|
|
||||||
...ALL_MODALITIES.map((modality) => ({
|
|
||||||
id: modality,
|
|
||||||
header: t(MODALITY_META[modality].labelKey),
|
|
||||||
className:
|
|
||||||
'text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase',
|
|
||||||
cellClassName: (row: { label: string; set: Set<Modality> }) =>
|
|
||||||
cn(
|
|
||||||
'border-l px-3 py-2 text-center',
|
|
||||||
row.set.has(modality)
|
|
||||||
? 'bg-emerald-50/40 dark:bg-emerald-500/10'
|
|
||||||
: 'bg-background'
|
|
||||||
),
|
|
||||||
cell: (row: { label: string; set: Set<Modality> }) => {
|
|
||||||
const enabled = row.set.has(modality)
|
|
||||||
const Icon = MODALITY_META[modality].icon
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center justify-center',
|
|
||||||
enabled
|
|
||||||
? 'text-emerald-700 dark:text-emerald-300'
|
|
||||||
: 'text-muted-foreground/40'
|
|
||||||
)}
|
|
||||||
aria-label={
|
|
||||||
enabled
|
|
||||||
? t('{{modality}} supported', {
|
|
||||||
modality: t(MODALITY_META[modality].labelKey),
|
|
||||||
})
|
|
||||||
: t('{{modality}} not supported', {
|
|
||||||
modality: t(MODALITY_META[modality].labelKey),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className='size-4' />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
formatLatency,
|
formatLatency,
|
||||||
formatThroughput,
|
formatThroughput,
|
||||||
formatUptimePct,
|
formatUptimePct,
|
||||||
|
getSuccessRateTextClass,
|
||||||
} from '@/features/performance-metrics/lib/format'
|
} from '@/features/performance-metrics/lib/format'
|
||||||
import type { PerformanceGroup } from '@/features/performance-metrics/types'
|
import type { PerformanceGroup } from '@/features/performance-metrics/types'
|
||||||
import { type UptimeDayPoint } from '../lib/mock-stats'
|
import { type UptimeDayPoint } from '../lib/mock-stats'
|
||||||
@@ -43,10 +44,9 @@ function StatCard(props: {
|
|||||||
label: string
|
label: string
|
||||||
value: React.ReactNode
|
value: React.ReactNode
|
||||||
hint?: string
|
hint?: string
|
||||||
intent?: 'default' | 'warning' | 'success'
|
valueClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const Icon = props.icon
|
const Icon = props.icon
|
||||||
const intent = props.intent ?? 'default'
|
|
||||||
return (
|
return (
|
||||||
<div className='bg-background flex flex-col gap-1 rounded-lg border p-3'>
|
<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'>
|
<span className='text-muted-foreground inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wider uppercase'>
|
||||||
@@ -56,8 +56,7 @@ function StatCard(props: {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground font-mono text-lg font-semibold tabular-nums',
|
'text-foreground font-mono text-lg font-semibold tabular-nums',
|
||||||
intent === 'warning' && 'text-amber-600 dark:text-amber-400',
|
props.valueClassName
|
||||||
intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.value}
|
{props.value}
|
||||||
@@ -217,12 +216,6 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
|||||||
successRates.length
|
successRates.length
|
||||||
: 0
|
: 0
|
||||||
const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0)
|
const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0)
|
||||||
let intent: 'default' | 'warning' | 'success' = 'warning'
|
|
||||||
if (successRate >= 99.9) {
|
|
||||||
intent = 'success'
|
|
||||||
} else if (successRate >= 99) {
|
|
||||||
intent = 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
@@ -249,7 +242,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
|||||||
})
|
})
|
||||||
: t('No incidents in the last 24 hours')
|
: t('No incidents in the last 24 hours')
|
||||||
}
|
}
|
||||||
intent={intent}
|
valueClassName={getSuccessRateTextClass(successRate)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
/*
|
|
||||||
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 {
|
|
||||||
CalendarClock,
|
|
||||||
FileText,
|
|
||||||
Layers,
|
|
||||||
Maximize2,
|
|
||||||
Sparkles,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
|
||||||
formatTokenCount,
|
|
||||||
formatYearMonth,
|
|
||||||
type ModelMetadata,
|
|
||||||
} from '../lib/model-metadata'
|
|
||||||
import type { Modality } from '../types'
|
|
||||||
import { ModalityIcons } from './model-details-modalities'
|
|
||||||
|
|
||||||
type QuickStatsProps = {
|
|
||||||
metadata: ModelMetadata
|
|
||||||
}
|
|
||||||
|
|
||||||
type Stat = {
|
|
||||||
key: string
|
|
||||||
icon: React.ComponentType<{ className?: string }>
|
|
||||||
label: string
|
|
||||||
value: React.ReactNode
|
|
||||||
hint?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStats(
|
|
||||||
metadata: ModelMetadata,
|
|
||||||
t: (key: string) => string
|
|
||||||
): Stat[] {
|
|
||||||
const stats: Stat[] = [
|
|
||||||
{
|
|
||||||
key: 'context',
|
|
||||||
icon: Layers,
|
|
||||||
label: t('Context'),
|
|
||||||
value: formatTokenCount(metadata.context_length),
|
|
||||||
hint: t('Maximum input window'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (metadata.max_output_tokens > 0) {
|
|
||||||
stats.push({
|
|
||||||
key: 'max-output',
|
|
||||||
icon: Maximize2,
|
|
||||||
label: t('Max output'),
|
|
||||||
value: formatTokenCount(metadata.max_output_tokens),
|
|
||||||
hint: t('Maximum tokens per response'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.push({
|
|
||||||
key: 'modalities',
|
|
||||||
icon: FileText,
|
|
||||||
label: t('Modalities'),
|
|
||||||
value: (
|
|
||||||
<ModalityFlow
|
|
||||||
input={metadata.input_modalities}
|
|
||||||
output={metadata.output_modalities}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (metadata.knowledge_cutoff) {
|
|
||||||
stats.push({
|
|
||||||
key: 'knowledge',
|
|
||||||
icon: Sparkles,
|
|
||||||
label: t('Knowledge cutoff'),
|
|
||||||
value: formatYearMonth(metadata.knowledge_cutoff),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.release_date) {
|
|
||||||
stats.push({
|
|
||||||
key: 'release',
|
|
||||||
icon: CalendarClock,
|
|
||||||
label: t('Released'),
|
|
||||||
value: formatYearMonth(metadata.release_date),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModalityFlow(props: { input: Modality[]; output: Modality[] }) {
|
|
||||||
return (
|
|
||||||
<span className='inline-flex items-center gap-1 align-middle'>
|
|
||||||
<ModalityIcons modalities={props.input} className='size-3.5' />
|
|
||||||
<span className='text-muted-foreground/40'>→</span>
|
|
||||||
<ModalityIcons modalities={props.output} className='size-3.5' />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelDetailsQuickStats(props: QuickStatsProps) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const stats = buildStats(props.metadata, t)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='bg-muted/20 grid grid-cols-2 gap-px overflow-hidden rounded-lg border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
|
|
||||||
{stats.map((stat) => {
|
|
||||||
const Icon = stat.icon
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stat.key}
|
|
||||||
className={cn(
|
|
||||||
'bg-background flex min-w-0 flex-col gap-0.5 px-3 py-2.5'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1 text-[10px] font-medium tracking-wider uppercase'>
|
|
||||||
<Icon className='size-3 shrink-0' />
|
|
||||||
<span className='truncate'>{stat.label}</span>
|
|
||||||
</span>
|
|
||||||
<span className='text-foreground truncate text-sm font-semibold tabular-nums'>
|
|
||||||
{stat.value}
|
|
||||||
</span>
|
|
||||||
{stat.hint && (
|
|
||||||
<span className='text-muted-foreground/60 truncate text-[10px]'>
|
|
||||||
{stat.hint}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+7
-18
@@ -25,7 +25,11 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { formatUptimePct } from '@/features/performance-metrics/lib/format'
|
import {
|
||||||
|
formatUptimePct,
|
||||||
|
getSuccessRateDotClass,
|
||||||
|
getSuccessRateTextClass,
|
||||||
|
} from '@/features/performance-metrics/lib/format'
|
||||||
import { aggregateUptime, type UptimeDayPoint } from '../lib/mock-stats'
|
import { aggregateUptime, type UptimeDayPoint } from '../lib/mock-stats'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -50,14 +54,6 @@ type UptimeSparklineProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function colourFor(uptime: number): string {
|
|
||||||
if (uptime >= 99.9) return 'bg-emerald-500'
|
|
||||||
if (uptime >= 99.0) return 'bg-emerald-400'
|
|
||||||
if (uptime >= 95.0) return 'bg-amber-500'
|
|
||||||
if (uptime >= 90.0) return 'bg-amber-600'
|
|
||||||
return 'bg-rose-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
function heightFor(uptime: number): string {
|
function heightFor(uptime: number): string {
|
||||||
if (uptime >= 99.9) return 'h-full'
|
if (uptime >= 99.9) return 'h-full'
|
||||||
if (uptime >= 99.0) return 'h-[88%]'
|
if (uptime >= 99.0) return 'h-[88%]'
|
||||||
@@ -66,13 +62,6 @@ function heightFor(uptime: number): string {
|
|||||||
return 'h-[40%]'
|
return 'h-[40%]'
|
||||||
}
|
}
|
||||||
|
|
||||||
function overallTextColour(pct: number): string {
|
|
||||||
if (pct >= 99.9) return 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
if (pct >= 99.0) return 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
if (pct >= 95.0) return 'text-amber-600 dark:text-amber-400'
|
|
||||||
return 'text-rose-600 dark:text-rose-400'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UptimeSparkline(props: UptimeSparklineProps) {
|
export function UptimeSparkline(props: UptimeSparklineProps) {
|
||||||
const size = props.size ?? 'md'
|
const size = props.size ?? 'md'
|
||||||
const showOverall = props.showOverall ?? true
|
const showOverall = props.showOverall ?? true
|
||||||
@@ -116,7 +105,7 @@ export function UptimeSparkline(props: UptimeSparklineProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-sm',
|
'w-full rounded-sm',
|
||||||
colourFor(day.uptime_pct),
|
getSuccessRateDotClass(day.uptime_pct),
|
||||||
heightFor(day.uptime_pct)
|
heightFor(day.uptime_pct)
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -138,7 +127,7 @@ export function UptimeSparkline(props: UptimeSparklineProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-sm font-semibold tabular-nums',
|
'font-mono text-sm font-semibold tabular-nums',
|
||||||
overallTextColour(overall)
|
getSuccessRateTextClass(overall)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{overall.toFixed(1)}%
|
{overall.toFixed(1)}%
|
||||||
|
|||||||
+341
-98
@@ -19,7 +19,18 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||||
import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CalendarClock,
|
||||||
|
Code2,
|
||||||
|
FileText,
|
||||||
|
HeartPulse,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
Maximize2,
|
||||||
|
Sparkles,
|
||||||
|
Timer,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -43,6 +54,7 @@ import {
|
|||||||
formatLatency,
|
formatLatency,
|
||||||
formatThroughput,
|
formatThroughput,
|
||||||
formatUptimePct,
|
formatUptimePct,
|
||||||
|
getSuccessRateTextClass,
|
||||||
} from '@/features/performance-metrics/lib/format'
|
} from '@/features/performance-metrics/lib/format'
|
||||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||||
import { usePricingData } from '../hooks/use-pricing-data'
|
import { usePricingData } from '../hooks/use-pricing-data'
|
||||||
@@ -54,20 +66,16 @@ import {
|
|||||||
} from '../lib/dynamic-price'
|
} from '../lib/dynamic-price'
|
||||||
import { parseTags } from '../lib/filters'
|
import { parseTags } from '../lib/filters'
|
||||||
import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
|
import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
|
||||||
import { inferModelMetadata } from '../lib/model-metadata'
|
|
||||||
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
|
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
|
||||||
import type {
|
import type {
|
||||||
Modality,
|
|
||||||
ModelCapability,
|
ModelCapability,
|
||||||
PriceType,
|
PriceType,
|
||||||
PricingModel,
|
PricingModel,
|
||||||
TokenUnit,
|
TokenUnit,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
|
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
|
||||||
import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
|
import { ModelDetailsApi } from './model-details-api'
|
||||||
import { ModalityIcons } from './model-details-modalities'
|
|
||||||
import { ModelDetailsPerformance } from './model-details-performance'
|
import { ModelDetailsPerformance } from './model-details-performance'
|
||||||
import { ModelDetailsQuickStats } from './model-details-quick-stats'
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Local UI helpers
|
// Local UI helpers
|
||||||
@@ -96,80 +104,51 @@ const CAPABILITY_LABEL_KEYS: Record<ModelCapability, string> = {
|
|||||||
embeddings: 'Embeddings',
|
embeddings: 'Embeddings',
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactCapabilityList(props: { capabilities: ModelCapability[] }) {
|
const MODALITY_LABEL_KEYS: Record<string, string> = {
|
||||||
const { t } = useTranslation()
|
text: 'Text',
|
||||||
|
image: 'Image',
|
||||||
|
audio: 'Audio',
|
||||||
|
video: 'Video',
|
||||||
|
file: 'File',
|
||||||
|
}
|
||||||
|
|
||||||
if (props.capabilities.length === 0) {
|
const TOKEN_FORMAT = new Intl.NumberFormat(undefined, {
|
||||||
return (
|
maximumFractionDigits: 1,
|
||||||
<span className='text-muted-foreground text-xs'>
|
})
|
||||||
{t('No capabilities reported for this model.')}
|
|
||||||
</span>
|
function formatCatalogTokenCount(tokens: number): string {
|
||||||
)
|
if (!Number.isFinite(tokens) || tokens <= 0) return ''
|
||||||
|
if (tokens >= 1_000_000) {
|
||||||
|
return `${TOKEN_FORMAT.format(tokens / 1_000_000)}M`
|
||||||
}
|
}
|
||||||
|
if (tokens >= 1_000) {
|
||||||
return (
|
return `${TOKEN_FORMAT.format(tokens / 1_000)}K`
|
||||||
<div className='flex flex-wrap gap-1.5'>
|
}
|
||||||
{props.capabilities.map((capability) => (
|
return TOKEN_FORMAT.format(tokens)
|
||||||
<span
|
|
||||||
key={capability}
|
|
||||||
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
|
|
||||||
>
|
|
||||||
{t(CAPABILITY_LABEL_KEYS[capability] ?? capability)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactModalities(props: { input: Modality[]; output: Modality[] }) {
|
function formatCatalogYearMonth(value?: string): string {
|
||||||
const { t } = useTranslation()
|
if (!value) return ''
|
||||||
|
const [yearStr, monthStr] = value.split('-')
|
||||||
return (
|
const year = Number(yearStr)
|
||||||
<div className='grid gap-2 sm:grid-cols-2'>
|
const month = Number(monthStr)
|
||||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
if (!Number.isFinite(year) || !Number.isFinite(month)) return value
|
||||||
<span className='text-muted-foreground text-xs font-medium'>
|
const date = new Date(Date.UTC(year, month - 1, 1))
|
||||||
{t('Input')}
|
return date.toLocaleString(undefined, { year: 'numeric', month: 'short' })
|
||||||
</span>
|
|
||||||
<ModalityIcons modalities={props.input} />
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
|
||||||
<span className='text-muted-foreground text-xs font-medium'>
|
|
||||||
{t('Output')}
|
|
||||||
</span>
|
|
||||||
<ModalityIcons modalities={props.output} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelSignalsSection(props: {
|
function normalizeCatalogItems(items?: readonly string[]): string[] {
|
||||||
capabilities: ModelCapability[]
|
if (!items) return []
|
||||||
input: Modality[]
|
return items.filter((item) => item.trim().length > 0)
|
||||||
output: Modality[]
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<SectionTitle>
|
|
||||||
{t('Capabilities')} / {t('Supported modalities')}
|
|
||||||
</SectionTitle>
|
|
||||||
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
|
|
||||||
<CompactCapabilityList capabilities={props.capabilities} />
|
|
||||||
<CompactModalities input={props.input} output={props.output} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewMetric(props: {
|
function OverviewMetric(props: {
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
label: string
|
label: string
|
||||||
value: React.ReactNode
|
value: React.ReactNode
|
||||||
intent?: 'default' | 'warning' | 'success'
|
valueClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const Icon = props.icon
|
const Icon = props.icon
|
||||||
const intent = props.intent ?? 'default'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
|
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
|
||||||
@@ -181,8 +160,7 @@ function OverviewMetric(props: {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
|
'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
|
||||||
intent === 'warning' && 'text-amber-600 dark:text-amber-400',
|
props.valueClassName
|
||||||
intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.value}
|
{props.value}
|
||||||
@@ -208,12 +186,6 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
|
|||||||
successRates.length > 0
|
successRates.length > 0
|
||||||
? successRates.reduce((sum, rate) => sum + rate, 0) / successRates.length
|
? successRates.reduce((sum, rate) => sum + rate, 0) / successRates.length
|
||||||
: Number.NaN
|
: Number.NaN
|
||||||
let successIntent: 'default' | 'warning' | 'success' = 'warning'
|
|
||||||
if (successRate >= 99.9) {
|
|
||||||
successIntent = 'success'
|
|
||||||
} else if (successRate >= 99) {
|
|
||||||
successIntent = 'default'
|
|
||||||
}
|
|
||||||
const tpsValues = groups
|
const tpsValues = groups
|
||||||
.map((group) => group.avg_tps)
|
.map((group) => group.avg_tps)
|
||||||
.filter((value) => value > 0)
|
.filter((value) => value > 0)
|
||||||
@@ -248,12 +220,305 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
|
|||||||
icon={HeartPulse}
|
icon={HeartPulse}
|
||||||
label={t('Success rate')}
|
label={t('Success rate')}
|
||||||
value={formatUptimePct(successRate)}
|
value={formatUptimePct(successRate)}
|
||||||
intent={successIntent}
|
valueClassName={getSuccessRateTextClass(successRate)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CatalogPillList(props: { items: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className='flex min-w-0 flex-wrap gap-1.5'>
|
||||||
|
{props.items.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item}
|
||||||
|
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogTextValue(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className='text-foreground min-w-0 truncate text-sm font-semibold'>
|
||||||
|
{props.children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogInfoCell(props: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className='bg-card flex min-w-0 flex-col gap-1 px-3 py-2.5'>
|
||||||
|
<span className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>
|
||||||
|
{props.label}
|
||||||
|
</span>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalityLabels(props: { items: string[] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (props.items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className='inline-flex items-center gap-1 align-middle'>
|
||||||
|
{props.items.map((item) => (
|
||||||
|
<span key={item} className='font-medium'>
|
||||||
|
{t(MODALITY_LABEL_KEYS[item] ?? item)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelBackendQuickStats(props: { model: PricingModel }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const model = props.model
|
||||||
|
const inputModalities = normalizeCatalogItems(model.input_modalities)
|
||||||
|
const outputModalities = normalizeCatalogItems(model.output_modalities)
|
||||||
|
const contextLength = model.context_length ?? 0
|
||||||
|
const maxOutput = model.max_output_tokens ?? 0
|
||||||
|
const knowledgeCutoff = formatCatalogYearMonth(model.knowledge_cutoff)
|
||||||
|
const releaseDate = formatCatalogYearMonth(model.release_date)
|
||||||
|
|
||||||
|
const stats: {
|
||||||
|
key: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
label: string
|
||||||
|
value: React.ReactNode
|
||||||
|
hint?: string
|
||||||
|
}[] = []
|
||||||
|
|
||||||
|
if (contextLength > 0) {
|
||||||
|
stats.push({
|
||||||
|
key: 'context',
|
||||||
|
icon: Layers,
|
||||||
|
label: t('Context'),
|
||||||
|
value: formatCatalogTokenCount(contextLength),
|
||||||
|
hint: t('Maximum input window'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxOutput > 0) {
|
||||||
|
stats.push({
|
||||||
|
key: 'max-output',
|
||||||
|
icon: Maximize2,
|
||||||
|
label: t('Max output'),
|
||||||
|
value: formatCatalogTokenCount(maxOutput),
|
||||||
|
hint: t('Maximum tokens per response'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputModalities.length > 0 || outputModalities.length > 0) {
|
||||||
|
stats.push({
|
||||||
|
key: 'modalities',
|
||||||
|
icon: FileText,
|
||||||
|
label: t('Modalities'),
|
||||||
|
value: (
|
||||||
|
<span className='inline-flex items-center gap-1'>
|
||||||
|
<ModalityLabels items={inputModalities} />
|
||||||
|
{inputModalities.length > 0 && outputModalities.length > 0 && (
|
||||||
|
<span className='text-muted-foreground/40'>→</span>
|
||||||
|
)}
|
||||||
|
<ModalityLabels items={outputModalities} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knowledgeCutoff) {
|
||||||
|
stats.push({
|
||||||
|
key: 'knowledge',
|
||||||
|
icon: Sparkles,
|
||||||
|
label: t('Knowledge cutoff'),
|
||||||
|
value: knowledgeCutoff,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseDate) {
|
||||||
|
stats.push({
|
||||||
|
key: 'release',
|
||||||
|
icon: CalendarClock,
|
||||||
|
label: t('Released'),
|
||||||
|
value: releaseDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-muted/20 grid grid-cols-2 gap-px overflow-hidden rounded-lg border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stat.key}
|
||||||
|
className='bg-background flex min-w-0 flex-col gap-0.5 px-3 py-2.5'
|
||||||
|
>
|
||||||
|
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1 text-[10px] font-medium tracking-wider uppercase'>
|
||||||
|
<Icon className='size-3 shrink-0' />
|
||||||
|
<span className='truncate'>{stat.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className='text-foreground truncate text-sm font-semibold tabular-nums'>
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
|
{stat.hint && (
|
||||||
|
<span className='text-muted-foreground/60 truncate text-[10px]'>
|
||||||
|
{stat.hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelBackendSignalsSection(props: { model: PricingModel }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const capabilities = normalizeCatalogItems(props.model.capabilities)
|
||||||
|
const inputModalities = normalizeCatalogItems(props.model.input_modalities)
|
||||||
|
const outputModalities = normalizeCatalogItems(props.model.output_modalities)
|
||||||
|
|
||||||
|
if (
|
||||||
|
capabilities.length === 0 &&
|
||||||
|
inputModalities.length === 0 &&
|
||||||
|
outputModalities.length === 0
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionTitle>
|
||||||
|
{t('Capabilities')} / {t('Supported modalities')}
|
||||||
|
</SectionTitle>
|
||||||
|
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
|
||||||
|
{capabilities.length > 0 ? (
|
||||||
|
<CatalogPillList
|
||||||
|
items={capabilities.map((capability) =>
|
||||||
|
t(
|
||||||
|
CAPABILITY_LABEL_KEYS[capability as ModelCapability] ??
|
||||||
|
capability
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
{(inputModalities.length > 0 || outputModalities.length > 0) && (
|
||||||
|
<div className='grid gap-2 sm:grid-cols-2'>
|
||||||
|
{inputModalities.length > 0 && (
|
||||||
|
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||||
|
<span className='text-muted-foreground text-xs font-medium'>
|
||||||
|
{t('Input')}
|
||||||
|
</span>
|
||||||
|
<CatalogTextValue>
|
||||||
|
<ModalityLabels items={inputModalities} />
|
||||||
|
</CatalogTextValue>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{outputModalities.length > 0 && (
|
||||||
|
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||||
|
<span className='text-muted-foreground text-xs font-medium'>
|
||||||
|
{t('Output')}
|
||||||
|
</span>
|
||||||
|
<CatalogTextValue>
|
||||||
|
<ModalityLabels items={outputModalities} />
|
||||||
|
</CatalogTextValue>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelBackendProviderSection(props: { model: PricingModel }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const model = props.model
|
||||||
|
const groups = normalizeCatalogItems(model.enable_groups)
|
||||||
|
const endpoints = normalizeCatalogItems(model.supported_endpoint_types)
|
||||||
|
const tags = parseTags(model.tags)
|
||||||
|
const cells: React.ReactNode[] = []
|
||||||
|
|
||||||
|
if (model.vendor_name) {
|
||||||
|
cells.push(
|
||||||
|
<CatalogInfoCell key='provider' label={t('Provider')}>
|
||||||
|
<CatalogTextValue>{model.vendor_name}</CatalogTextValue>
|
||||||
|
</CatalogInfoCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push(
|
||||||
|
<CatalogInfoCell key='type' label={t('Type')}>
|
||||||
|
<CatalogTextValue>
|
||||||
|
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||||
|
? t('Token-based')
|
||||||
|
: t('Per Request')}
|
||||||
|
</CatalogTextValue>
|
||||||
|
</CatalogInfoCell>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (groups.length > 0) {
|
||||||
|
cells.push(
|
||||||
|
<CatalogInfoCell key='groups' label={t('Groups')}>
|
||||||
|
<CatalogPillList items={groups} />
|
||||||
|
</CatalogInfoCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoints.length > 0) {
|
||||||
|
cells.push(
|
||||||
|
<CatalogInfoCell key='endpoints' label={t('Endpoints')}>
|
||||||
|
<CatalogPillList items={endpoints} />
|
||||||
|
</CatalogInfoCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length > 0) {
|
||||||
|
cells.push(
|
||||||
|
<CatalogInfoCell key='tags' label={t('Tags')}>
|
||||||
|
<CatalogPillList items={tags} />
|
||||||
|
</CatalogInfoCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.parameter_count) {
|
||||||
|
cells.push(
|
||||||
|
<CatalogInfoCell key='parameters' label={t('Parameters')}>
|
||||||
|
<CatalogTextValue>{model.parameter_count}</CatalogTextValue>
|
||||||
|
</CatalogInfoCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cells.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionTitle>{t('Model')}</SectionTitle>
|
||||||
|
<div className='border-border/60 bg-border/60 grid grid-cols-1 gap-px overflow-hidden rounded-lg border sm:grid-cols-2'>
|
||||||
|
{cells}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelBackendDetailsSection(props: { model: PricingModel }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ModelBackendQuickStats model={props.model} />
|
||||||
|
<ModelBackendSignalsSection model={props.model} />
|
||||||
|
<ModelBackendProviderSection model={props.model} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Model header (always visible above the detail sections)
|
// Model header (always visible above the detail sections)
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -264,7 +529,6 @@ function ModelHeader(props: { model: PricingModel }) {
|
|||||||
const modelIconKey = model.icon || model.vendor_icon
|
const modelIconKey = model.icon || model.vendor_icon
|
||||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
|
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
|
||||||
const description = model.description || model.vendor_description || null
|
const description = model.description || model.vendor_description || null
|
||||||
const tags = parseTags(model.tags)
|
|
||||||
const isSpecialExpression =
|
const isSpecialExpression =
|
||||||
model.billing_mode === 'tiered_expr' &&
|
model.billing_mode === 'tiered_expr' &&
|
||||||
Boolean(model.billing_expr) &&
|
Boolean(model.billing_expr) &&
|
||||||
@@ -312,18 +576,6 @@ function ModelHeader(props: { model: PricingModel }) {
|
|||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{tags.length > 0 && (
|
|
||||||
<div className='mt-2.5 flex flex-wrap gap-1'>
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className='bg-muted text-muted-foreground rounded px-2 py-0.5 text-[11px] font-medium'
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -901,7 +1153,6 @@ export interface ModelDetailsContentProps {
|
|||||||
export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const showRechargePrice = props.showRechargePrice ?? false
|
const showRechargePrice = props.showRechargePrice ?? false
|
||||||
const metadata = useMemo(() => inferModelMetadata(props.model), [props.model])
|
|
||||||
|
|
||||||
const isDynamic =
|
const isDynamic =
|
||||||
props.model.billing_mode === 'tiered_expr' &&
|
props.model.billing_mode === 'tiered_expr' &&
|
||||||
@@ -955,15 +1206,7 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ModelDetailsQuickStats metadata={metadata} />
|
<ModelBackendDetailsSection model={props.model} />
|
||||||
|
|
||||||
<ModelSignalsSection
|
|
||||||
capabilities={metadata.capabilities}
|
|
||||||
input={metadata.input_modalities}
|
|
||||||
output={metadata.output_modalities}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelDetailsProviderInfo model={props.model} />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='performance' className='outline-none'>
|
<TabsContent value='performance' className='outline-none'>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import {
|
import {
|
||||||
formatLatency,
|
formatLatency,
|
||||||
formatThroughput,
|
formatThroughput,
|
||||||
|
getSuccessRateDotClass,
|
||||||
} from '@/features/performance-metrics/lib/format'
|
} from '@/features/performance-metrics/lib/format'
|
||||||
|
|
||||||
export type ModelPerfBadgeData = {
|
export type ModelPerfBadgeData = {
|
||||||
@@ -49,12 +50,7 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
|||||||
|
|
||||||
const { avg_latency_ms, avg_tps, success_rate } = props.perf
|
const { avg_latency_ms, avg_tps, success_rate } = props.perf
|
||||||
|
|
||||||
let statusColor = 'bg-emerald-500'
|
const statusColor = getSuccessRateDotClass(success_rate)
|
||||||
if (success_rate < 99) {
|
|
||||||
statusColor = 'bg-red-500'
|
|
||||||
} else if (success_rate < 99.9) {
|
|
||||||
statusColor = 'bg-amber-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -25,6 +25,5 @@ export * from './price'
|
|||||||
export * from './model-helpers'
|
export * from './model-helpers'
|
||||||
export * from './billing-expr'
|
export * from './billing-expr'
|
||||||
export * from './tier-expr'
|
export * from './tier-expr'
|
||||||
export * from './model-metadata'
|
|
||||||
export * from './mock-stats'
|
export * from './mock-stats'
|
||||||
export * from './seed'
|
export * from './seed'
|
||||||
|
|||||||
@@ -1,569 +0,0 @@
|
|||||||
/*
|
|
||||||
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 { Modality, ModelCapability, PricingModel } from '../types'
|
|
||||||
import { hashStringToSeed, seededRandom } from './seed'
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
// Model metadata inference
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// The backend does not currently return `context_length`, `max_output_tokens`,
|
|
||||||
// `knowledge_cutoff`, `release_date`, `parameter_count`, or modality/capability
|
|
||||||
// flags for a model. Until it does, we infer reasonable values client-side
|
|
||||||
// from the data we already have (endpoint types, ratios, tags, model name)
|
|
||||||
// and fall back to a deterministic mock seeded from the model name so that
|
|
||||||
// every render of the same model shows the same numbers.
|
|
||||||
//
|
|
||||||
// When the backend starts returning these fields, callers should prefer the
|
|
||||||
// explicit values on `model.*` and only fall back to the inferred ones.
|
|
||||||
|
|
||||||
const TEXT_INPUT_ENDPOINTS = new Set([
|
|
||||||
'openai',
|
|
||||||
'openai-response',
|
|
||||||
'anthropic',
|
|
||||||
'gemini',
|
|
||||||
'embeddings',
|
|
||||||
'jina-rerank',
|
|
||||||
])
|
|
||||||
|
|
||||||
const IMAGE_OUTPUT_ENDPOINTS = new Set(['image-generation'])
|
|
||||||
const VIDEO_OUTPUT_ENDPOINTS = new Set(['openai-video'])
|
|
||||||
const EMBEDDING_ENDPOINTS = new Set(['embeddings', 'jina-rerank'])
|
|
||||||
|
|
||||||
const REASONING_NAME_PATTERNS = [
|
|
||||||
/^o[1-4](?:[-:_].+)?$/i,
|
|
||||||
/reasoning/i,
|
|
||||||
/thinking/i,
|
|
||||||
/qwq/i,
|
|
||||||
/deepseek-r\d/i,
|
|
||||||
/grok.*-(?:thinking|reasoning)/i,
|
|
||||||
]
|
|
||||||
|
|
||||||
const VISION_NAME_PATTERNS = [
|
|
||||||
/vision/i,
|
|
||||||
/vl(?:[-_]|$)/i,
|
|
||||||
/multimodal/i,
|
|
||||||
/-omni/i,
|
|
||||||
]
|
|
||||||
|
|
||||||
const AUDIO_NAME_PATTERNS = [
|
|
||||||
/audio/i,
|
|
||||||
/whisper/i,
|
|
||||||
/tts/i,
|
|
||||||
/voice/i,
|
|
||||||
/-realtime/i,
|
|
||||||
]
|
|
||||||
|
|
||||||
const VIDEO_NAME_PATTERNS = [/video/i, /sora/i, /veo/i, /kling/i, /pika/i]
|
|
||||||
|
|
||||||
const CODE_NAME_PATTERNS = [/code/i, /-coder/i]
|
|
||||||
|
|
||||||
const WEB_SEARCH_PATTERNS = [/web[-_ ]?search/i, /-online/i, /perplexity/i]
|
|
||||||
|
|
||||||
const KNOWLEDGE_CUTOFFS = [
|
|
||||||
'2023-04',
|
|
||||||
'2023-10',
|
|
||||||
'2023-12',
|
|
||||||
'2024-04',
|
|
||||||
'2024-06',
|
|
||||||
'2024-08',
|
|
||||||
'2024-10',
|
|
||||||
'2024-12',
|
|
||||||
'2025-02',
|
|
||||||
'2025-04',
|
|
||||||
'2025-08',
|
|
||||||
]
|
|
||||||
|
|
||||||
const PARAM_BUCKETS = [
|
|
||||||
'1.5B',
|
|
||||||
'3B',
|
|
||||||
'7B',
|
|
||||||
'8B',
|
|
||||||
'14B',
|
|
||||||
'32B',
|
|
||||||
'70B',
|
|
||||||
'120B',
|
|
||||||
'405B',
|
|
||||||
]
|
|
||||||
|
|
||||||
const CONTEXT_BUCKETS = [
|
|
||||||
8_192, 16_384, 32_768, 65_536, 128_000, 200_000, 1_000_000,
|
|
||||||
]
|
|
||||||
const MAX_OUTPUT_BUCKETS = [2_048, 4_096, 8_192, 16_384, 32_768, 65_536]
|
|
||||||
|
|
||||||
const TAG_TO_CAPABILITY: Record<string, ModelCapability> = {
|
|
||||||
vision: 'vision',
|
|
||||||
multimodal: 'vision',
|
|
||||||
reasoning: 'reasoning',
|
|
||||||
thinking: 'reasoning',
|
|
||||||
tools: 'tools',
|
|
||||||
function: 'function_calling',
|
|
||||||
'function-calling': 'function_calling',
|
|
||||||
streaming: 'streaming',
|
|
||||||
json: 'json_mode',
|
|
||||||
structured: 'structured_output',
|
|
||||||
search: 'web_search',
|
|
||||||
code: 'code_interpreter',
|
|
||||||
embedding: 'embeddings',
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAG_TO_MODALITY: Record<string, Modality> = {
|
|
||||||
text: 'text',
|
|
||||||
image: 'image',
|
|
||||||
audio: 'audio',
|
|
||||||
video: 'video',
|
|
||||||
file: 'file',
|
|
||||||
document: 'file',
|
|
||||||
pdf: 'file',
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFromBuckets<T>(buckets: T[], rand: () => number): T {
|
|
||||||
return buckets[Math.floor(rand() * buckets.length)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseModelTags(tagsString?: string): string[] {
|
|
||||||
if (!tagsString) return []
|
|
||||||
return tagsString
|
|
||||||
.split(/[,;|\s]+/)
|
|
||||||
.map((t) => t.trim().toLowerCase())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function nameMatches(name: string, patterns: RegExp[]): boolean {
|
|
||||||
return patterns.some((re) => re.test(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferInputModalities(
|
|
||||||
model: PricingModel,
|
|
||||||
tags: string[],
|
|
||||||
endpoints: string[],
|
|
||||||
name: string
|
|
||||||
): Modality[] {
|
|
||||||
const set = new Set<Modality>()
|
|
||||||
|
|
||||||
if (
|
|
||||||
endpoints.length === 0 ||
|
|
||||||
endpoints.some((e) => TEXT_INPUT_ENDPOINTS.has(e))
|
|
||||||
) {
|
|
||||||
set.add('text')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.image_ratio != null || nameMatches(name, VISION_NAME_PATTERNS)) {
|
|
||||||
set.add('image')
|
|
||||||
}
|
|
||||||
if (model.audio_ratio != null || nameMatches(name, AUDIO_NAME_PATTERNS)) {
|
|
||||||
set.add('audio')
|
|
||||||
}
|
|
||||||
if (nameMatches(name, VIDEO_NAME_PATTERNS)) {
|
|
||||||
set.add('video')
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tag of tags) {
|
|
||||||
const m = TAG_TO_MODALITY[tag]
|
|
||||||
if (m) set.add(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (set.size === 0) set.add('text')
|
|
||||||
return ordered(set)
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferOutputModalities(
|
|
||||||
model: PricingModel,
|
|
||||||
endpoints: string[],
|
|
||||||
name: string
|
|
||||||
): Modality[] {
|
|
||||||
const set = new Set<Modality>()
|
|
||||||
|
|
||||||
if (endpoints.some((e) => IMAGE_OUTPUT_ENDPOINTS.has(e))) set.add('image')
|
|
||||||
if (endpoints.some((e) => VIDEO_OUTPUT_ENDPOINTS.has(e))) set.add('video')
|
|
||||||
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('text')
|
|
||||||
|
|
||||||
if (
|
|
||||||
model.audio_completion_ratio != null ||
|
|
||||||
/tts|voice|audio-out/i.test(name)
|
|
||||||
) {
|
|
||||||
set.add('audio')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (set.size === 0) set.add('text')
|
|
||||||
return ordered(set)
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferCapabilities(
|
|
||||||
model: PricingModel,
|
|
||||||
tags: string[],
|
|
||||||
endpoints: string[],
|
|
||||||
name: string,
|
|
||||||
outputs: Modality[],
|
|
||||||
inputs: Modality[]
|
|
||||||
): ModelCapability[] {
|
|
||||||
const set = new Set<ModelCapability>()
|
|
||||||
|
|
||||||
if (outputs.includes('text') && !endpoints.includes('image-generation')) {
|
|
||||||
set.add('streaming')
|
|
||||||
set.add('system_prompt')
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!endpoints.includes('image-generation') &&
|
|
||||||
!endpoints.includes('embeddings') &&
|
|
||||||
!endpoints.includes('jina-rerank')
|
|
||||||
) {
|
|
||||||
set.add('function_calling')
|
|
||||||
set.add('tools')
|
|
||||||
set.add('json_mode')
|
|
||||||
set.add('structured_output')
|
|
||||||
}
|
|
||||||
if (inputs.includes('image')) set.add('vision')
|
|
||||||
if (model.cache_ratio != null) set.add('caching')
|
|
||||||
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('embeddings')
|
|
||||||
if (nameMatches(name, REASONING_NAME_PATTERNS)) set.add('reasoning')
|
|
||||||
if (nameMatches(name, CODE_NAME_PATTERNS)) set.add('code_interpreter')
|
|
||||||
if (nameMatches(name, WEB_SEARCH_PATTERNS)) set.add('web_search')
|
|
||||||
|
|
||||||
for (const tag of tags) {
|
|
||||||
const cap = TAG_TO_CAPABILITY[tag]
|
|
||||||
if (cap) set.add(cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(set)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ordered(modalities: Set<Modality>): Modality[] {
|
|
||||||
const order: Modality[] = ['text', 'image', 'audio', 'video', 'file']
|
|
||||||
return order.filter((m) => modalities.has(m))
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferContextAndOutputs(
|
|
||||||
name: string,
|
|
||||||
rand: () => number,
|
|
||||||
endpoints: string[]
|
|
||||||
): { context: number; maxOutput: number } {
|
|
||||||
if (endpoints.includes('embeddings') || endpoints.includes('jina-rerank')) {
|
|
||||||
return { context: 8_192, maxOutput: 0 }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
endpoints.includes('image-generation') ||
|
|
||||||
endpoints.includes('openai-video')
|
|
||||||
) {
|
|
||||||
return { context: 4_096, maxOutput: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const lower = name.toLowerCase()
|
|
||||||
if (lower.includes('1m') || lower.includes('-long')) {
|
|
||||||
return { context: 1_000_000, maxOutput: 65_536 }
|
|
||||||
}
|
|
||||||
if (/claude.*(?:4|opus|sonnet)/.test(lower)) {
|
|
||||||
return { context: 1_000_000, maxOutput: 65_536 }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
lower.includes('200k') ||
|
|
||||||
lower.includes('claude-3') ||
|
|
||||||
lower.includes('claude-4')
|
|
||||||
) {
|
|
||||||
return { context: 200_000, maxOutput: 16_384 }
|
|
||||||
}
|
|
||||||
if (lower.includes('128k') || /gpt-4o|gpt-4\.1|gpt-5|o1|o3|o4/.test(lower)) {
|
|
||||||
return { context: 128_000, maxOutput: 16_384 }
|
|
||||||
}
|
|
||||||
if (/gemini.*-2|gemini.*pro|gemini.*flash/.test(lower)) {
|
|
||||||
return { context: 1_000_000, maxOutput: 8_192 }
|
|
||||||
}
|
|
||||||
if (/gpt-3\.5|claude-2/.test(lower)) {
|
|
||||||
return { context: 16_384, maxOutput: 4_096 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = pickFromBuckets(CONTEXT_BUCKETS, rand)
|
|
||||||
const maxOutput = Math.min(context, pickFromBuckets(MAX_OUTPUT_BUCKETS, rand))
|
|
||||||
return { context, maxOutput }
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferReleaseAndCutoff(rand: () => number): {
|
|
||||||
release: string
|
|
||||||
cutoff: string
|
|
||||||
} {
|
|
||||||
const cutoff = pickFromBuckets(KNOWLEDGE_CUTOFFS, rand)
|
|
||||||
const [year, month] = cutoff.split('-').map(Number)
|
|
||||||
const offsetMonths = 4 + Math.floor(rand() * 6)
|
|
||||||
const releaseMonth = month + offsetMonths
|
|
||||||
const releaseYear = year + Math.floor((releaseMonth - 1) / 12)
|
|
||||||
const finalMonth = ((releaseMonth - 1) % 12) + 1
|
|
||||||
const release = `${releaseYear}-${String(finalMonth).padStart(2, '0')}-15`
|
|
||||||
return { release, cutoff }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelMetadata = {
|
|
||||||
context_length: number
|
|
||||||
max_output_tokens: number
|
|
||||||
knowledge_cutoff: string
|
|
||||||
release_date: string
|
|
||||||
parameter_count: string
|
|
||||||
input_modalities: Modality[]
|
|
||||||
output_modalities: Modality[]
|
|
||||||
capabilities: ModelCapability[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Infer / mock model metadata. Prefers explicit fields on `model.*` and
|
|
||||||
* falls back to inference + a deterministic seed otherwise.
|
|
||||||
*/
|
|
||||||
export function inferModelMetadata(model: PricingModel): ModelMetadata {
|
|
||||||
const name = model.model_name || ''
|
|
||||||
const rand = seededRandom(hashStringToSeed(name))
|
|
||||||
const tags = parseModelTags(model.tags)
|
|
||||||
const endpoints = model.supported_endpoint_types || []
|
|
||||||
|
|
||||||
const inputs =
|
|
||||||
model.input_modalities ?? inferInputModalities(model, tags, endpoints, name)
|
|
||||||
const outputs =
|
|
||||||
model.output_modalities ?? inferOutputModalities(model, endpoints, name)
|
|
||||||
const capabilities =
|
|
||||||
model.capabilities ??
|
|
||||||
inferCapabilities(model, tags, endpoints, name, outputs, inputs)
|
|
||||||
|
|
||||||
const fallback = inferContextAndOutputs(name, rand, endpoints)
|
|
||||||
const cutoffAndRelease = inferReleaseAndCutoff(rand)
|
|
||||||
|
|
||||||
return {
|
|
||||||
context_length: model.context_length ?? fallback.context,
|
|
||||||
max_output_tokens: model.max_output_tokens ?? fallback.maxOutput,
|
|
||||||
knowledge_cutoff: model.knowledge_cutoff ?? cutoffAndRelease.cutoff,
|
|
||||||
release_date: model.release_date ?? cutoffAndRelease.release,
|
|
||||||
parameter_count:
|
|
||||||
model.parameter_count ?? pickFromBuckets(PARAM_BUCKETS, rand),
|
|
||||||
input_modalities: inputs,
|
|
||||||
output_modalities: outputs,
|
|
||||||
capabilities,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOKEN_FORMAT = new Intl.NumberFormat(undefined, {
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Format a token count compactly: 128_000 → "128K", 1_000_000 → "1M". */
|
|
||||||
export function formatTokenCount(tokens: number): string {
|
|
||||||
if (!Number.isFinite(tokens) || tokens <= 0) return '—'
|
|
||||||
if (tokens >= 1_000_000) {
|
|
||||||
const value = tokens / 1_000_000
|
|
||||||
return `${TOKEN_FORMAT.format(value)}M`
|
|
||||||
}
|
|
||||||
if (tokens >= 1_000) {
|
|
||||||
const value = tokens / 1_000
|
|
||||||
return `${TOKEN_FORMAT.format(value)}K`
|
|
||||||
}
|
|
||||||
return TOKEN_FORMAT.format(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a YYYY-MM (or YYYY-MM-DD) date as `Mon YYYY` for display. */
|
|
||||||
export function formatYearMonth(value: string): string {
|
|
||||||
if (!value) return '—'
|
|
||||||
const [yearStr, monthStr] = value.split('-')
|
|
||||||
const year = Number(yearStr)
|
|
||||||
const month = Number(monthStr)
|
|
||||||
if (!Number.isFinite(year) || !Number.isFinite(month)) return value
|
|
||||||
const date = new Date(Date.UTC(year, month - 1, 1))
|
|
||||||
return date.toLocaleString(undefined, { year: 'numeric', month: 'short' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Provider / vendor / tokenizer / license inference
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// These helpers derive vendor-style metadata from the model name. They are
|
|
||||||
// purely heuristic and serve only the API-info display until the backend
|
|
||||||
// returns explicit fields.
|
|
||||||
|
|
||||||
export type ModelVendor =
|
|
||||||
| 'openai'
|
|
||||||
| 'anthropic'
|
|
||||||
| 'google'
|
|
||||||
| 'meta'
|
|
||||||
| 'mistral'
|
|
||||||
| 'qwen'
|
|
||||||
| 'deepseek'
|
|
||||||
| 'xai'
|
|
||||||
| 'cohere'
|
|
||||||
| 'baidu'
|
|
||||||
| 'zhipu'
|
|
||||||
| 'moonshot'
|
|
||||||
| 'minimax'
|
|
||||||
| 'tencent'
|
|
||||||
| 'bytedance'
|
|
||||||
| 'midjourney'
|
|
||||||
| 'stability'
|
|
||||||
| 'unknown'
|
|
||||||
|
|
||||||
export type ApiInfo = {
|
|
||||||
vendor: ModelVendor
|
|
||||||
vendor_label: string
|
|
||||||
tokenizer: string
|
|
||||||
tokenizer_note?: string
|
|
||||||
license: string
|
|
||||||
license_kind: 'proprietary' | 'open' | 'open-weight' | 'unknown'
|
|
||||||
data_retention_days: number
|
|
||||||
training_opt_out: boolean
|
|
||||||
homepage?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const VENDOR_LABELS: Record<ModelVendor, string> = {
|
|
||||||
openai: 'OpenAI',
|
|
||||||
anthropic: 'Anthropic',
|
|
||||||
google: 'Google',
|
|
||||||
meta: 'Meta',
|
|
||||||
mistral: 'Mistral AI',
|
|
||||||
qwen: 'Alibaba (Qwen)',
|
|
||||||
deepseek: 'DeepSeek',
|
|
||||||
xai: 'xAI',
|
|
||||||
cohere: 'Cohere',
|
|
||||||
baidu: 'Baidu',
|
|
||||||
zhipu: 'Zhipu AI',
|
|
||||||
moonshot: 'Moonshot AI',
|
|
||||||
minimax: 'MiniMax',
|
|
||||||
tencent: 'Tencent',
|
|
||||||
bytedance: 'ByteDance',
|
|
||||||
midjourney: 'Midjourney',
|
|
||||||
stability: 'Stability AI',
|
|
||||||
unknown: 'Unknown',
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectVendor(name: string): ModelVendor {
|
|
||||||
const n = name.toLowerCase()
|
|
||||||
if (/^gpt|^o[1-4]|davinci|babbage|whisper|tts|dall.?e|sora|^omni/.test(n))
|
|
||||||
return 'openai'
|
|
||||||
if (/claude/.test(n)) return 'anthropic'
|
|
||||||
if (/gemini|gemma|imagen|veo|palm/.test(n)) return 'google'
|
|
||||||
if (/llama|^codellama/.test(n)) return 'meta'
|
|
||||||
if (/mistral|mixtral|codestral|magistral|pixtral/.test(n)) return 'mistral'
|
|
||||||
if (/qwen|qwq|qvq/.test(n)) return 'qwen'
|
|
||||||
if (/deepseek/.test(n)) return 'deepseek'
|
|
||||||
if (/grok/.test(n)) return 'xai'
|
|
||||||
if (/command|cohere|aya/.test(n)) return 'cohere'
|
|
||||||
if (/ernie|wenxin/.test(n)) return 'baidu'
|
|
||||||
if (/glm|chatglm|cogview|cogvideo/.test(n)) return 'zhipu'
|
|
||||||
if (/kimi|moonshot/.test(n)) return 'moonshot'
|
|
||||||
if (/abab|minimax|hailuo/.test(n)) return 'minimax'
|
|
||||||
if (/hunyuan/.test(n)) return 'tencent'
|
|
||||||
if (/doubao|seed|jimeng/.test(n)) return 'bytedance'
|
|
||||||
if (/midjourney|niji/.test(n)) return 'midjourney'
|
|
||||||
if (/^sd-|stable[-_]?diffusion|sdxl/.test(n)) return 'stability'
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOKENIZER_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
|
|
||||||
openai: 'o200k_base',
|
|
||||||
anthropic: 'Anthropic Claude tokenizer',
|
|
||||||
google: 'SentencePiece (Gemini)',
|
|
||||||
meta: 'Llama 3 tokenizer',
|
|
||||||
mistral: 'Mistral tokenizer (BPE)',
|
|
||||||
qwen: 'Qwen tokenizer (tiktoken-compat)',
|
|
||||||
deepseek: 'DeepSeek tokenizer (BPE)',
|
|
||||||
xai: 'Grok tokenizer (BPE)',
|
|
||||||
cohere: 'Cohere tokenizer',
|
|
||||||
baidu: 'Ernie tokenizer',
|
|
||||||
zhipu: 'GLM tokenizer',
|
|
||||||
moonshot: 'Kimi tokenizer',
|
|
||||||
minimax: 'ABAB tokenizer',
|
|
||||||
tencent: 'Hunyuan tokenizer',
|
|
||||||
bytedance: 'Doubao tokenizer',
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferTokenizer(
|
|
||||||
model: PricingModel,
|
|
||||||
vendor: ModelVendor
|
|
||||||
): {
|
|
||||||
tokenizer: string
|
|
||||||
note?: string
|
|
||||||
} {
|
|
||||||
const name = model.model_name.toLowerCase()
|
|
||||||
if (vendor === 'openai') {
|
|
||||||
if (/gpt-3|davinci|babbage|whisper|tts/.test(name)) {
|
|
||||||
return { tokenizer: 'cl100k_base', note: 'Older GPT-3.5 family' }
|
|
||||||
}
|
|
||||||
return { tokenizer: 'o200k_base' }
|
|
||||||
}
|
|
||||||
return { tokenizer: TOKENIZER_BY_VENDOR[vendor] ?? 'BPE (vendor-specific)' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const LICENSE_BY_VENDOR: Record<
|
|
||||||
ModelVendor,
|
|
||||||
{ license: string; kind: ApiInfo['license_kind'] }
|
|
||||||
> = {
|
|
||||||
openai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
anthropic: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
google: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
meta: { license: 'Llama Community License', kind: 'open-weight' },
|
|
||||||
mistral: { license: 'Apache 2.0 / Commercial', kind: 'open-weight' },
|
|
||||||
qwen: { license: 'Tongyi Qianwen License', kind: 'open-weight' },
|
|
||||||
deepseek: { license: 'DeepSeek License', kind: 'open-weight' },
|
|
||||||
xai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
cohere: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
baidu: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
zhipu: { license: 'GLM-4 License', kind: 'open-weight' },
|
|
||||||
moonshot: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
minimax: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
tencent: { license: 'Hunyuan License', kind: 'open-weight' },
|
|
||||||
bytedance: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
midjourney: { license: 'Proprietary (commercial)', kind: 'proprietary' },
|
|
||||||
stability: { license: 'Stability AI Community License', kind: 'open-weight' },
|
|
||||||
unknown: { license: 'Provider-specific', kind: 'unknown' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const HOMEPAGE_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
|
|
||||||
openai: 'https://platform.openai.com/docs/models',
|
|
||||||
anthropic: 'https://docs.anthropic.com/claude/docs/models-overview',
|
|
||||||
google: 'https://ai.google.dev/models',
|
|
||||||
meta: 'https://llama.meta.com/',
|
|
||||||
mistral: 'https://docs.mistral.ai/getting-started/models/',
|
|
||||||
qwen: 'https://qwenlm.github.io/',
|
|
||||||
deepseek: 'https://api-docs.deepseek.com/',
|
|
||||||
xai: 'https://x.ai/api',
|
|
||||||
cohere: 'https://docs.cohere.com/docs/models',
|
|
||||||
baidu: 'https://cloud.baidu.com/product/wenxinworkshop',
|
|
||||||
zhipu: 'https://open.bigmodel.cn/dev/api',
|
|
||||||
moonshot: 'https://platform.moonshot.cn/docs',
|
|
||||||
minimax: 'https://platform.minimaxi.com/document/notice',
|
|
||||||
tencent: 'https://cloud.tencent.com/document/product/1729',
|
|
||||||
bytedance: 'https://www.volcengine.com/docs/82379',
|
|
||||||
midjourney: 'https://www.midjourney.com/',
|
|
||||||
stability: 'https://platform.stability.ai/',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build vendor / tokenizer / license / privacy metadata for the model.
|
|
||||||
* Returns deterministic values keyed off the model name so each render is
|
|
||||||
* stable.
|
|
||||||
*/
|
|
||||||
export function inferApiInfo(model: PricingModel): ApiInfo {
|
|
||||||
const vendor = detectVendor(model.model_name || '')
|
|
||||||
const tk = inferTokenizer(model, vendor)
|
|
||||||
const license = LICENSE_BY_VENDOR[vendor]
|
|
||||||
const rand = seededRandom(hashStringToSeed(`${model.model_name}:api`))
|
|
||||||
const retention = vendor === 'openai' ? 30 : Math.round(rand() * 90)
|
|
||||||
return {
|
|
||||||
vendor,
|
|
||||||
vendor_label: VENDOR_LABELS[vendor],
|
|
||||||
tokenizer: tk.tokenizer,
|
|
||||||
tokenizer_note: tk.note,
|
|
||||||
license: license.license,
|
|
||||||
license_kind: license.kind,
|
|
||||||
data_retention_days: retention,
|
|
||||||
training_opt_out: true,
|
|
||||||
homepage: HOMEPAGE_BY_VENDOR[vendor],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-4
@@ -57,10 +57,8 @@ export type PricingModel = {
|
|||||||
/** Pricing version returned by backend, useful for cache busting */
|
/** Pricing version returned by backend, useful for cache busting */
|
||||||
pricing_version?: string
|
pricing_version?: string
|
||||||
/**
|
/**
|
||||||
* Optional model metadata fields. These are not yet returned by the backend
|
* Optional model metadata fields reserved for backend-provided catalog data.
|
||||||
* and are populated client-side from {@link inferModelMetadata}.
|
* Keep them data-driven; do not synthesize display values on the client.
|
||||||
* When the backend ships these fields, the inference layer becomes a
|
|
||||||
* fallback rather than the source of truth.
|
|
||||||
*/
|
*/
|
||||||
context_length?: number
|
context_length?: number
|
||||||
max_output_tokens?: number
|
max_output_tokens?: number
|
||||||
|
|||||||
+15
-2
@@ -16,12 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { type FormEvent, useEffect, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
|
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
|
||||||
|
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
|
||||||
import { addTimeToDate } from '@/lib/time'
|
import { addTimeToDate } from '@/lib/time'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -135,6 +136,18 @@ export function RedemptionsMutateDrawer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
if (!isUpdate) {
|
||||||
|
const name = form.getValues('name')
|
||||||
|
if (!name?.trim()) {
|
||||||
|
const quota = parseQuotaFromDollars(form.getValues('quota_dollars'))
|
||||||
|
form.setValue('name', formatQuota(quota), { shouldValidate: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void form.handleSubmit(onSubmit)(event)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSetExpiry = (months: number, days: number, hours: number) => {
|
const handleSetExpiry = (months: number, days: number, hours: number) => {
|
||||||
const newDate = addTimeToDate(months, days, hours)
|
const newDate = addTimeToDate(months, days, hours)
|
||||||
form.setValue('expired_time', newDate)
|
form.setValue('expired_time', newDate)
|
||||||
@@ -177,7 +190,7 @@ export function RedemptionsMutateDrawer({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id='redemption-form'
|
id='redemption-form'
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={handleSubmit}
|
||||||
className={sideDrawerFormClassName()}
|
className={sideDrawerFormClassName()}
|
||||||
>
|
>
|
||||||
<SideDrawerSection>
|
<SideDrawerSection>
|
||||||
|
|||||||
Reference in New Issue
Block a user