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

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

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

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

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

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

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

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

Verified: bun run typecheck (web/default)

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

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

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

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

* 🏆 feat(rankings): add comprehensive rankings dashboard

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

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

* fix(theme): correct theme preset selection state

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

* fix(setup): correct usage mode radio state

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

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

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

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

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

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

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

Verified: `bun run typecheck` passes.

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

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

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

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

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

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

---------

Co-authored-by: t0ng7u <dev@aiass.cc>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
This commit is contained in:
Calcium-Ion
2026-05-06 12:39:36 +08:00
committed by GitHub
co-authored by t0ng7u QuentinHsu
parent dac55f0fde
commit 8b2b03d276
317 changed files with 19928 additions and 7065 deletions
+4 -2
View File
@@ -1,5 +1,6 @@
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { TOKEN_UNIT_DIVISORS } from '../constants'
import type { PricingModel, TokenUnit } from '../types'
import {
BILLING_PRICING_VARS,
parseTiersFromExpr,
@@ -8,7 +9,6 @@ import {
type BillingVar,
type ParsedTier,
} from './billing-expr'
import type { PricingModel, TokenUnit } from '../types'
type DynamicPriceOptions = {
tokenUnit: TokenUnit
@@ -98,7 +98,9 @@ export function formatDynamicUnitPrice(
export function getDynamicPricingTiers(model: PricingModel): ParsedTier[] {
if (!isDynamicPricingModel(model)) return []
const { billingExpr } = splitBillingExprAndRequestRules(model.billing_expr || '')
const { billingExpr } = splitBillingExprAndRequestRules(
model.billing_expr || ''
)
return parseTiersFromExpr(billingExpr)
}
+3
View File
@@ -7,3 +7,6 @@ export * from './price'
export * from './model-helpers'
export * from './billing-expr'
export * from './tier-expr'
export * from './model-metadata'
export * from './mock-stats'
export * from './seed'
+844
View File
@@ -0,0 +1,844 @@
import type { PricingModel } from '../types'
import {
hashStringToSeed,
randomInRange,
randomIntInRange,
seededRandom,
} from './seed'
// ----------------------------------------------------------------------------
// Mock model statistics
// ----------------------------------------------------------------------------
//
// The backend has not yet implemented latency / uptime / app-ranking data.
// These helpers generate plausible, deterministic mock values seeded from
// the model name (and optionally the group name) so that:
// - Every render of the same model shows the same numbers
// - Different models / different groups render visibly distinct values
//
// When the backend ships real metrics, callers should switch to the
// real API and these helpers can be deleted. The shape of the returned
// data is designed to mirror what we expect the real endpoints to return.
export type GroupPerformance = {
group: string
ttft_p50_ms: number
ttft_p95_ms: number
ttft_p99_ms: number
throughput_tps: number
uptime_30d_pct: number
/** Number of monitored requests in the last 24h (display only). */
request_volume_24h: number
}
export type LatencyTimePoint = {
timestamp: string
group: string
ttft_ms: number
}
export type UptimeDayPoint = {
date: string
uptime_pct: number
incidents: number
outage_minutes: number
}
export type AppRanking = {
rank: number
name: string
description: string
category: string
growth_pct: number
monthly_tokens: number
url?: string
initial: string
}
const APP_TEMPLATES: Array<
Omit<AppRanking, 'rank' | 'monthly_tokens' | 'growth_pct' | 'initial'>
> = [
{
name: 'Cline',
description: 'Autonomous coding agent inside the IDE',
category: 'Coding',
url: 'https://cline.bot',
},
{
name: 'Roo Code',
description: 'AI agent for VS Code with multi-step planning',
category: 'Coding',
url: 'https://roocode.com',
},
{
name: 'Open WebUI',
description: 'Self-hosted ChatGPT-like web interface',
category: 'Chat',
url: 'https://openwebui.com',
},
{
name: 'LibreChat',
description: 'Open-source chat platform with multi-model support',
category: 'Chat',
url: 'https://librechat.ai',
},
{
name: 'Lobe Chat',
description: 'Modern open-source chat UI with plugins',
category: 'Chat',
url: 'https://lobehub.com',
},
{
name: 'NextChat',
description: 'Cross-platform private ChatGPT client',
category: 'Chat',
url: 'https://nextchat.dev',
},
{
name: 'Continue',
description: 'Open-source AI code assistant for editors',
category: 'Coding',
url: 'https://continue.dev',
},
{
name: 'Aider',
description: 'Pair-programming agent in your terminal',
category: 'Coding',
url: 'https://aider.chat',
},
{
name: 'Dify',
description: 'LLM application development platform',
category: 'Platform',
url: 'https://dify.ai',
},
{
name: 'FastGPT',
description: 'Knowledge base orchestration and chat platform',
category: 'Platform',
url: 'https://fastgpt.in',
},
{
name: 'Flowise',
description: 'Low-code LLM workflow builder',
category: 'Platform',
url: 'https://flowiseai.com',
},
{
name: 'OpenInterpreter',
description: 'Natural-language code execution agent',
category: 'Coding',
url: 'https://openinterpreter.com',
},
{
name: 'Devika',
description: 'Open-source AI software engineer',
category: 'Coding',
url: 'https://github.com/stitionai/devika',
},
{
name: 'Cherry Studio',
description: 'Multi-model desktop chat client',
category: 'Chat',
url: 'https://cherry-ai.com',
},
{
name: 'AnythingLLM',
description: 'Workspaces around your private documents',
category: 'Platform',
url: 'https://anythingllm.com',
},
{
name: 'OpenHands',
description: 'Coding agent with browser-and-code tools',
category: 'Coding',
url: 'https://docs.all-hands.dev',
},
{
name: 'Cursor',
description: 'AI-native code editor',
category: 'Coding',
url: 'https://cursor.com',
},
{
name: 'Zed',
description: 'Multiplayer code editor with AI',
category: 'Coding',
url: 'https://zed.dev',
},
{
name: 'Notion AI',
description: 'Documents and writing assistant',
category: 'Productivity',
url: 'https://notion.so',
},
{
name: 'Raycast AI',
description: 'AI on your macOS launcher',
category: 'Productivity',
url: 'https://raycast.com',
},
{
name: 'Obsidian Smart Connections',
description: 'Connect notes with semantic search',
category: 'Productivity',
},
{
name: 'Bolt.new',
description: 'Prompt-to-app full-stack builder',
category: 'Coding',
url: 'https://bolt.new',
},
{
name: 'Pieces',
description: 'AI workflow companion for developers',
category: 'Productivity',
url: 'https://pieces.app',
},
{
name: 'AmazingAI',
description: 'Personal AI knowledge assistant',
category: 'Productivity',
},
{
name: 'TypingMind',
description: 'Better UI for ChatGPT and Claude',
category: 'Chat',
url: 'https://typingmind.com',
},
]
const PROFILE_BY_NAME = (name: string) => {
const n = name.toLowerCase()
if (/embed|rerank/.test(n)) return 'embedding'
if (/image|sora|veo|kling|pika|jimeng|dalle|imagen/.test(n)) return 'image'
if (/whisper|tts|voice|audio/.test(n)) return 'audio'
if (/o1|o3|o4|reasoning|thinking|deepseek-r/.test(n)) return 'reasoning'
if (/flash|haiku|mini|small|nano|fast/.test(n)) return 'fast'
if (/gpt-5|opus|ultra|405|70b/.test(n)) return 'large'
return 'standard'
}
type ProfileSpec = {
ttftRange: [number, number]
throughputRange: [number, number]
uptimeRange: [number, number]
}
const PROFILE_SPECS: Record<string, ProfileSpec> = {
embedding: {
ttftRange: [40, 120],
throughputRange: [0, 0],
uptimeRange: [99.9, 99.99],
},
image: {
ttftRange: [2_500, 12_000],
throughputRange: [0, 0],
uptimeRange: [98.5, 99.8],
},
audio: {
ttftRange: [180, 600],
throughputRange: [0, 0],
uptimeRange: [99.5, 99.95],
},
reasoning: {
ttftRange: [1_800, 5_500],
throughputRange: [25, 70],
uptimeRange: [99.4, 99.95],
},
fast: {
ttftRange: [180, 480],
throughputRange: [110, 240],
uptimeRange: [99.7, 99.99],
},
large: {
ttftRange: [600, 1_400],
throughputRange: [55, 95],
uptimeRange: [99.5, 99.95],
},
standard: {
ttftRange: [400, 900],
throughputRange: [70, 140],
uptimeRange: [99.6, 99.97],
},
}
function rangeFromSeed(
rand: () => number,
[min, max]: [number, number]
): number {
return randomInRange(rand, min, max)
}
function applyGroupFactor(value: number, factor: number): number {
return value * factor
}
function groupFactor(
group: string,
baseSeed: number
): { ttft: number; throughput: number; uptime: number } {
const rand = seededRandom(baseSeed ^ hashStringToSeed(group || 'default'))
return {
ttft: 0.85 + rand() * 0.55,
throughput: 0.85 + rand() * 0.4,
uptime: 0.997 + rand() * 0.003,
}
}
/**
* Build per-group performance stats for a model. Always returns at least one
* row for each enabled group, sorted alphabetically.
*/
export function buildGroupPerformance(model: PricingModel): GroupPerformance[] {
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
const targets = groups.length > 0 ? groups : ['default']
const profile = PROFILE_BY_NAME(model.model_name)
const spec = PROFILE_SPECS[profile]
const baseSeed = hashStringToSeed(model.model_name)
return targets
.slice()
.sort((a, b) => a.localeCompare(b))
.map<GroupPerformance>((group) => {
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
const factor = groupFactor(group, baseSeed)
const ttftP50 = applyGroupFactor(
rangeFromSeed(rand, spec.ttftRange),
factor.ttft
)
const throughput = applyGroupFactor(
rangeFromSeed(rand, spec.throughputRange),
factor.throughput
)
const uptimePct = Math.min(
99.99,
rangeFromSeed(rand, spec.uptimeRange) * factor.uptime
)
const requestVolume = randomIntInRange(rand, 18_000, 480_000)
return {
group,
ttft_p50_ms: Math.round(ttftP50),
ttft_p95_ms: Math.round(ttftP50 * (1.6 + rand() * 0.4)),
ttft_p99_ms: Math.round(ttftP50 * (2.4 + rand() * 0.6)),
throughput_tps: throughput === 0 ? 0 : Math.round(throughput * 10) / 10,
uptime_30d_pct: Math.round(uptimePct * 100) / 100,
request_volume_24h: requestVolume,
}
})
}
/**
* Build a 24-hour latency series for each group. Returns one point per hour
* (24 buckets), oldest first.
*/
export function buildLatencyTimeSeries(
model: PricingModel
): LatencyTimePoint[] {
const performances = buildGroupPerformance(model)
if (performances.length === 0) return []
const now = new Date()
now.setMinutes(0, 0, 0)
const baseSeed = hashStringToSeed(`${model.model_name}:lat`)
const points: LatencyTimePoint[] = []
for (const perf of performances) {
const rand = seededRandom(baseSeed ^ hashStringToSeed(perf.group))
for (let i = 23; i >= 0; i--) {
const ts = new Date(now.getTime() - i * 3_600_000)
const noise = 0.7 + rand() * 0.7
const trend = 0.85 + Math.sin(i / 3) * 0.1
const value = Math.max(50, Math.round(perf.ttft_p50_ms * noise * trend))
points.push({
timestamp: ts.toISOString(),
group: perf.group,
ttft_ms: value,
})
}
}
return points
}
/**
* Build a 30-day uptime series. Returns one point per day, oldest first.
*
* If `group` is provided the series is anchored on that group's mean uptime,
* otherwise it uses the per-model average. Either way the seed is derived
* deterministically so re-renders are stable.
*/
export function buildUptimeSeries(
model: PricingModel,
group?: string
): UptimeDayPoint[] {
const performances = buildGroupPerformance(model)
if (performances.length === 0) return []
const target = group ? performances.find((p) => p.group === group) : null
const baseUptime = target
? target.uptime_30d_pct
: performances.reduce((s, p) => s + p.uptime_30d_pct, 0) /
performances.length
const baseSeed = hashStringToSeed(`${model.model_name}:up:${group ?? '_all'}`)
const rand = seededRandom(baseSeed)
const today = new Date()
today.setHours(0, 0, 0, 0)
const points: UptimeDayPoint[] = []
for (let i = 29; i >= 0; i--) {
const date = new Date(today.getTime() - i * 86_400_000)
const isoDate = date.toISOString().slice(0, 10)
const incidentChance = rand()
const incidents = incidentChance > 0.92 ? 1 : 0
const outageMinutes = incidents > 0 ? Math.round(rand() * 30 + 5) : 0
const downtimePct = (outageMinutes / 1_440) * 100
const dayUptime = Math.max(85, Math.min(100, baseUptime - downtimePct))
points.push({
date: isoDate,
uptime_pct: Math.round(dayUptime * 100) / 100,
incidents,
outage_minutes: outageMinutes,
})
}
return points
}
/**
* Build a deterministic top-apps ranking for the model. The first three apps
* always come from the same template list; the rest is shuffled by the seed
* so different models surface different long tails.
*/
export function buildAppRankings(
model: PricingModel,
count = 12
): AppRanking[] {
const baseSeed = hashStringToSeed(`${model.model_name}:apps`)
const rand = seededRandom(baseSeed)
const candidates = [...APP_TEMPLATES]
// FisherYates shuffle.
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1))
;[candidates[i], candidates[j]] = [candidates[j], candidates[i]]
}
const top = candidates.slice(0, count)
const baseTokens = randomInRange(rand, 90_000_000, 320_000_000)
return top.map((app, idx) => {
const decay = Math.pow(0.78, idx)
const monthlyTokens = Math.round(baseTokens * decay * (0.85 + rand() * 0.3))
const growthPctRaw = randomInRange(rand, -28, 84)
const growthPct = Math.round(growthPctRaw * 10) / 10
return {
rank: idx + 1,
name: app.name,
description: app.description,
category: app.category,
url: app.url,
growth_pct: growthPct,
monthly_tokens: monthlyTokens,
initial: app.name.charAt(0).toUpperCase(),
}
})
}
/** Aggregate uptime over the most recent 30 days. */
export function aggregateUptime(points: UptimeDayPoint[]): {
uptime_pct: number
incidents: number
outage_minutes: number
} {
if (points.length === 0) {
return { uptime_pct: 0, incidents: 0, outage_minutes: 0 }
}
const incidents = points.reduce((s, p) => s + p.incidents, 0)
const outageMinutes = points.reduce((s, p) => s + p.outage_minutes, 0)
const totalMinutes = points.length * 1_440
const uptimePct = ((totalMinutes - outageMinutes) / totalMinutes) * 100
return {
incidents,
outage_minutes: outageMinutes,
uptime_pct: Math.round(uptimePct * 1000) / 1000,
}
}
/** Format throughput for display: "0" → "—". */
export function formatThroughput(tps: number): string {
if (tps <= 0) return '—'
if (tps >= 1_000) return `${(tps / 1_000).toFixed(1)}K t/s`
return `${tps.toFixed(tps < 10 ? 2 : 1)} t/s`
}
/** Format latency in ms with proper unit selection. */
export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
if (ms >= 1_000) return `${(ms / 1_000).toFixed(2)}s`
return `${Math.round(ms)}ms`
}
/** Format uptime percentage with 2 decimal places. */
export function formatUptimePct(pct: number): string {
if (!Number.isFinite(pct)) return '—'
return `${pct.toFixed(2)}%`
}
/** Compact integer formatter for token counts in apps tab. */
export function formatTokenVolume(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0'
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return n.toString()
}
// ---------------------------------------------------------------------------
// Mock supported-parameters & rate-limits & misc API metadata
// ---------------------------------------------------------------------------
export type SupportedParameter = {
name: string
type:
| 'number'
| 'integer'
| 'boolean'
| 'string'
| 'object'
| 'array'
| 'enum'
defaultValue?: string | number | boolean
range?: string
enumValues?: string[]
descriptionKey: string
required?: boolean
}
const COMMON_CHAT_PARAMS: SupportedParameter[] = [
{
name: 'temperature',
type: 'number',
defaultValue: 1,
range: '0 ~ 2',
descriptionKey: 'Sampling temperature; lower is more deterministic',
},
{
name: 'top_p',
type: 'number',
defaultValue: 1,
range: '0 ~ 1',
descriptionKey: 'Nucleus sampling probability mass',
},
{
name: 'max_tokens',
type: 'integer',
range: '>= 1',
descriptionKey: 'Maximum number of tokens in the response',
},
{
name: 'frequency_penalty',
type: 'number',
defaultValue: 0,
range: '-2 ~ 2',
descriptionKey: 'Penalises repetition of frequent tokens',
},
{
name: 'presence_penalty',
type: 'number',
defaultValue: 0,
range: '-2 ~ 2',
descriptionKey: 'Encourages introducing new topics',
},
{
name: 'stop',
type: 'array',
descriptionKey: 'Up to 4 strings that stop generation',
},
{
name: 'seed',
type: 'integer',
descriptionKey: 'Deterministic sampling seed (best-effort)',
},
{
name: 'n',
type: 'integer',
defaultValue: 1,
range: '>= 1',
descriptionKey: 'Number of completions to generate',
},
{
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey: 'Stream tokens via Server-Sent Events',
},
{
name: 'response_format',
type: 'object',
descriptionKey: 'Force JSON object or schema-conforming output',
},
{
name: 'tools',
type: 'array',
descriptionKey: 'Tool / function declarations the model may call',
},
{
name: 'tool_choice',
type: 'string',
enumValues: ['auto', 'none', 'required'],
descriptionKey: 'Tool-choice policy or specific tool name',
},
{
name: 'logprobs',
type: 'boolean',
defaultValue: false,
descriptionKey: 'Return per-token log probabilities',
},
{
name: 'top_logprobs',
type: 'integer',
range: '0 ~ 20',
descriptionKey: 'Number of top log probabilities returned per token',
},
{
name: 'logit_bias',
type: 'object',
descriptionKey: 'Per-token logit bias map',
},
{
name: 'user',
type: 'string',
descriptionKey: 'End-user identifier for abuse monitoring',
},
]
const REASONING_PARAMS: SupportedParameter[] = [
{
name: 'reasoning_effort',
type: 'enum',
enumValues: ['low', 'medium', 'high'],
defaultValue: 'medium',
descriptionKey: 'Controls how much the model thinks before answering',
},
{
name: 'max_completion_tokens',
type: 'integer',
range: '>= 1',
descriptionKey: 'Maximum tokens including hidden reasoning tokens',
},
{
name: 'stop',
type: 'array',
descriptionKey: 'Up to 4 strings that stop generation',
},
{
name: 'seed',
type: 'integer',
descriptionKey: 'Deterministic sampling seed (best-effort)',
},
{
name: 'stream',
type: 'boolean',
defaultValue: false,
descriptionKey: 'Stream tokens via Server-Sent Events',
},
{
name: 'response_format',
type: 'object',
descriptionKey: 'Force JSON object or schema-conforming output',
},
{
name: 'tools',
type: 'array',
descriptionKey: 'Tool / function declarations the model may call',
},
{
name: 'tool_choice',
type: 'string',
enumValues: ['auto', 'none', 'required'],
descriptionKey: 'Tool-choice policy or specific tool name',
},
{
name: 'user',
type: 'string',
descriptionKey: 'End-user identifier for abuse monitoring',
},
]
const EMBEDDING_PARAMS: SupportedParameter[] = [
{
name: 'input',
type: 'string',
required: true,
descriptionKey: 'Text or array of texts to embed',
},
{
name: 'dimensions',
type: 'integer',
range: '>= 1',
descriptionKey: 'Truncate embeddings to this many dimensions',
},
{
name: 'encoding_format',
type: 'enum',
enumValues: ['float', 'base64'],
defaultValue: 'float',
descriptionKey: 'Wire encoding for the embedding vectors',
},
{
name: 'user',
type: 'string',
descriptionKey: 'End-user identifier for abuse monitoring',
},
]
const IMAGE_PARAMS: SupportedParameter[] = [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: 'Text description of the desired image',
},
{
name: 'size',
type: 'enum',
enumValues: ['256x256', '512x512', '1024x1024', '1024x1792', '1792x1024'],
defaultValue: '1024x1024',
descriptionKey: 'Output image size',
},
{
name: 'quality',
type: 'enum',
enumValues: ['standard', 'hd'],
defaultValue: 'standard',
descriptionKey: 'Generation quality preset',
},
{
name: 'style',
type: 'enum',
enumValues: ['vivid', 'natural'],
defaultValue: 'vivid',
descriptionKey: 'Aesthetic style',
},
{
name: 'n',
type: 'integer',
defaultValue: 1,
range: '1 ~ 10',
descriptionKey: 'Number of images to generate',
},
{
name: 'response_format',
type: 'enum',
enumValues: ['url', 'b64_json'],
defaultValue: 'url',
descriptionKey: 'How to deliver the resulting image',
},
]
const VIDEO_PARAMS: SupportedParameter[] = [
{
name: 'prompt',
type: 'string',
required: true,
descriptionKey: 'Text description of the desired video',
},
{
name: 'duration',
type: 'integer',
range: '1 ~ 60',
descriptionKey: 'Video length in seconds',
},
{
name: 'aspect_ratio',
type: 'enum',
enumValues: ['16:9', '9:16', '1:1'],
defaultValue: '16:9',
descriptionKey: 'Output aspect ratio',
},
{
name: 'fps',
type: 'integer',
range: '8 ~ 60',
defaultValue: 24,
descriptionKey: 'Frames per second',
},
]
type ApiCategory = 'reasoning' | 'embedding' | 'image' | 'video' | 'chat'
/**
* Refine the broad PROFILE_BY_NAME bucket into an API-shape category. The
* `image` bucket from `PROFILE_BY_NAME` lumps still-image and video models
* together (because their performance profiles overlap); for the API tab we
* need to distinguish them so the request-parameter table is accurate.
*/
function apiCategoryOf(model: PricingModel): ApiCategory {
const profile = PROFILE_BY_NAME(model.model_name)
if (profile === 'embedding' || profile === 'reasoning') return profile
if (profile === 'image') {
return /sora|veo|kling|pika|video|wan-|hunyuanvideo/i.test(model.model_name)
? 'video'
: 'image'
}
return 'chat'
}
/**
* Build the list of request parameters that the model accepts. The list is
* shaped per-modality so reasoning, embedding, image, video and chat models
* each show their relevant parameter set.
*/
export function buildSupportedParameters(
model: PricingModel
): SupportedParameter[] {
const cat = apiCategoryOf(model)
if (cat === 'reasoning') return REASONING_PARAMS
if (cat === 'embedding') return EMBEDDING_PARAMS
if (cat === 'image') return IMAGE_PARAMS
if (cat === 'video') return VIDEO_PARAMS
return COMMON_CHAT_PARAMS
}
export type RateLimit = {
group: string
rpm: number
tpm: number
rpd: number
}
/** Build per-group RPM / TPM / RPD limits for the model. */
export function buildRateLimits(model: PricingModel): RateLimit[] {
const groups = (model.enable_groups ?? []).filter((g) => g && g !== 'auto')
const targets = groups.length > 0 ? groups : ['default']
const cat = apiCategoryOf(model)
const baseSeed = hashStringToSeed(`${model.model_name}:rl`)
const isHeavy = cat === 'image' || cat === 'video'
const isLight = cat === 'embedding'
const baseRpm = isHeavy ? 60 : isLight ? 5_000 : 500
const baseTpm = isHeavy ? 0 : isLight ? 1_000_000 : 200_000
const baseRpd = isHeavy ? 1_000 : isLight ? 100_000 : 10_000
return targets
.slice()
.sort((a, b) => a.localeCompare(b))
.map((group) => {
const rand = seededRandom(baseSeed ^ hashStringToSeed(group))
const tier = 0.6 + rand() * 1.4
return {
group,
rpm: Math.round((baseRpm * tier) / 10) * 10,
tpm: baseTpm === 0 ? 0 : Math.round((baseTpm * tier) / 1_000) * 1_000,
rpd: Math.round((baseRpd * tier) / 100) * 100,
}
})
}
/** Format an integer rate-limit value compactly. */
export function formatRateLimit(value: number): string {
if (value <= 0) return '—'
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000)
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}K`
return value.toLocaleString()
}
+548
View File
@@ -0,0 +1,548 @@
import type { Modality, ModelCapability, PricingModel } from '../types'
import { hashStringToSeed, seededRandom } from './seed'
// ----------------------------------------------------------------------------
// Model metadata inference
// ----------------------------------------------------------------------------
//
// The backend does not currently return `context_length`, `max_output_tokens`,
// `knowledge_cutoff`, `release_date`, `parameter_count`, or modality/capability
// flags for a model. Until it does, we infer reasonable values client-side
// from the data we already have (endpoint types, ratios, tags, model name)
// and fall back to a deterministic mock seeded from the model name so that
// every render of the same model shows the same numbers.
//
// When the backend starts returning these fields, callers should prefer the
// explicit values on `model.*` and only fall back to the inferred ones.
const TEXT_INPUT_ENDPOINTS = new Set([
'openai',
'openai-response',
'anthropic',
'gemini',
'embeddings',
'jina-rerank',
])
const IMAGE_OUTPUT_ENDPOINTS = new Set(['image-generation'])
const VIDEO_OUTPUT_ENDPOINTS = new Set(['openai-video'])
const EMBEDDING_ENDPOINTS = new Set(['embeddings', 'jina-rerank'])
const REASONING_NAME_PATTERNS = [
/^o[1-4](?:[-:_].+)?$/i,
/reasoning/i,
/thinking/i,
/qwq/i,
/deepseek-r\d/i,
/grok.*-(?:thinking|reasoning)/i,
]
const VISION_NAME_PATTERNS = [
/vision/i,
/vl(?:[-_]|$)/i,
/multimodal/i,
/-omni/i,
]
const AUDIO_NAME_PATTERNS = [
/audio/i,
/whisper/i,
/tts/i,
/voice/i,
/-realtime/i,
]
const VIDEO_NAME_PATTERNS = [/video/i, /sora/i, /veo/i, /kling/i, /pika/i]
const CODE_NAME_PATTERNS = [/code/i, /-coder/i]
const WEB_SEARCH_PATTERNS = [/web[-_ ]?search/i, /-online/i, /perplexity/i]
const KNOWLEDGE_CUTOFFS = [
'2023-04',
'2023-10',
'2023-12',
'2024-04',
'2024-06',
'2024-08',
'2024-10',
'2024-12',
'2025-02',
'2025-04',
'2025-08',
]
const PARAM_BUCKETS = [
'1.5B',
'3B',
'7B',
'8B',
'14B',
'32B',
'70B',
'120B',
'405B',
]
const CONTEXT_BUCKETS = [
8_192, 16_384, 32_768, 65_536, 128_000, 200_000, 1_000_000,
]
const MAX_OUTPUT_BUCKETS = [2_048, 4_096, 8_192, 16_384, 32_768, 65_536]
const TAG_TO_CAPABILITY: Record<string, ModelCapability> = {
vision: 'vision',
multimodal: 'vision',
reasoning: 'reasoning',
thinking: 'reasoning',
tools: 'tools',
function: 'function_calling',
'function-calling': 'function_calling',
streaming: 'streaming',
json: 'json_mode',
structured: 'structured_output',
search: 'web_search',
code: 'code_interpreter',
embedding: 'embeddings',
}
const TAG_TO_MODALITY: Record<string, Modality> = {
text: 'text',
image: 'image',
audio: 'audio',
video: 'video',
file: 'file',
document: 'file',
pdf: 'file',
}
function pickFromBuckets<T>(buckets: T[], rand: () => number): T {
return buckets[Math.floor(rand() * buckets.length)]
}
function parseModelTags(tagsString?: string): string[] {
if (!tagsString) return []
return tagsString
.split(/[,;|\s]+/)
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
}
function nameMatches(name: string, patterns: RegExp[]): boolean {
return patterns.some((re) => re.test(name))
}
function inferInputModalities(
model: PricingModel,
tags: string[],
endpoints: string[],
name: string
): Modality[] {
const set = new Set<Modality>()
if (
endpoints.length === 0 ||
endpoints.some((e) => TEXT_INPUT_ENDPOINTS.has(e))
) {
set.add('text')
}
if (model.image_ratio != null || nameMatches(name, VISION_NAME_PATTERNS)) {
set.add('image')
}
if (model.audio_ratio != null || nameMatches(name, AUDIO_NAME_PATTERNS)) {
set.add('audio')
}
if (nameMatches(name, VIDEO_NAME_PATTERNS)) {
set.add('video')
}
for (const tag of tags) {
const m = TAG_TO_MODALITY[tag]
if (m) set.add(m)
}
if (set.size === 0) set.add('text')
return ordered(set)
}
function inferOutputModalities(
model: PricingModel,
endpoints: string[],
name: string
): Modality[] {
const set = new Set<Modality>()
if (endpoints.some((e) => IMAGE_OUTPUT_ENDPOINTS.has(e))) set.add('image')
if (endpoints.some((e) => VIDEO_OUTPUT_ENDPOINTS.has(e))) set.add('video')
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('text')
if (
model.audio_completion_ratio != null ||
/tts|voice|audio-out/i.test(name)
) {
set.add('audio')
}
if (set.size === 0) set.add('text')
return ordered(set)
}
function inferCapabilities(
model: PricingModel,
tags: string[],
endpoints: string[],
name: string,
outputs: Modality[],
inputs: Modality[]
): ModelCapability[] {
const set = new Set<ModelCapability>()
if (outputs.includes('text') && !endpoints.includes('image-generation')) {
set.add('streaming')
set.add('system_prompt')
}
if (
!endpoints.includes('image-generation') &&
!endpoints.includes('embeddings') &&
!endpoints.includes('jina-rerank')
) {
set.add('function_calling')
set.add('tools')
set.add('json_mode')
set.add('structured_output')
}
if (inputs.includes('image')) set.add('vision')
if (model.cache_ratio != null) set.add('caching')
if (endpoints.some((e) => EMBEDDING_ENDPOINTS.has(e))) set.add('embeddings')
if (nameMatches(name, REASONING_NAME_PATTERNS)) set.add('reasoning')
if (nameMatches(name, CODE_NAME_PATTERNS)) set.add('code_interpreter')
if (nameMatches(name, WEB_SEARCH_PATTERNS)) set.add('web_search')
for (const tag of tags) {
const cap = TAG_TO_CAPABILITY[tag]
if (cap) set.add(cap)
}
return Array.from(set)
}
function ordered(modalities: Set<Modality>): Modality[] {
const order: Modality[] = ['text', 'image', 'audio', 'video', 'file']
return order.filter((m) => modalities.has(m))
}
function inferContextAndOutputs(
name: string,
rand: () => number,
endpoints: string[]
): { context: number; maxOutput: number } {
if (endpoints.includes('embeddings') || endpoints.includes('jina-rerank')) {
return { context: 8_192, maxOutput: 0 }
}
if (
endpoints.includes('image-generation') ||
endpoints.includes('openai-video')
) {
return { context: 4_096, maxOutput: 0 }
}
const lower = name.toLowerCase()
if (lower.includes('1m') || lower.includes('-long')) {
return { context: 1_000_000, maxOutput: 65_536 }
}
if (
lower.includes('200k') ||
lower.includes('claude-3') ||
lower.includes('claude-4')
) {
return { context: 200_000, maxOutput: 16_384 }
}
if (lower.includes('128k') || /gpt-4o|gpt-4\.1|gpt-5|o1|o3|o4/.test(lower)) {
return { context: 128_000, maxOutput: 16_384 }
}
if (/gemini.*-2|gemini.*pro|gemini.*flash/.test(lower)) {
return { context: 1_000_000, maxOutput: 8_192 }
}
if (/gpt-3\.5|claude-2/.test(lower)) {
return { context: 16_384, maxOutput: 4_096 }
}
const context = pickFromBuckets(CONTEXT_BUCKETS, rand)
const maxOutput = Math.min(context, pickFromBuckets(MAX_OUTPUT_BUCKETS, rand))
return { context, maxOutput }
}
function inferReleaseAndCutoff(rand: () => number): {
release: string
cutoff: string
} {
const cutoff = pickFromBuckets(KNOWLEDGE_CUTOFFS, rand)
const [year, month] = cutoff.split('-').map(Number)
const offsetMonths = 4 + Math.floor(rand() * 6)
const releaseMonth = month + offsetMonths
const releaseYear = year + Math.floor((releaseMonth - 1) / 12)
const finalMonth = ((releaseMonth - 1) % 12) + 1
const release = `${releaseYear}-${String(finalMonth).padStart(2, '0')}-15`
return { release, cutoff }
}
export type ModelMetadata = {
context_length: number
max_output_tokens: number
knowledge_cutoff: string
release_date: string
parameter_count: string
input_modalities: Modality[]
output_modalities: Modality[]
capabilities: ModelCapability[]
}
/**
* Infer / mock model metadata. Prefers explicit fields on `model.*` and
* falls back to inference + a deterministic seed otherwise.
*/
export function inferModelMetadata(model: PricingModel): ModelMetadata {
const name = model.model_name || ''
const rand = seededRandom(hashStringToSeed(name))
const tags = parseModelTags(model.tags)
const endpoints = model.supported_endpoint_types || []
const inputs =
model.input_modalities ?? inferInputModalities(model, tags, endpoints, name)
const outputs =
model.output_modalities ?? inferOutputModalities(model, endpoints, name)
const capabilities =
model.capabilities ??
inferCapabilities(model, tags, endpoints, name, outputs, inputs)
const fallback = inferContextAndOutputs(name, rand, endpoints)
const cutoffAndRelease = inferReleaseAndCutoff(rand)
return {
context_length: model.context_length ?? fallback.context,
max_output_tokens: model.max_output_tokens ?? fallback.maxOutput,
knowledge_cutoff: model.knowledge_cutoff ?? cutoffAndRelease.cutoff,
release_date: model.release_date ?? cutoffAndRelease.release,
parameter_count:
model.parameter_count ?? pickFromBuckets(PARAM_BUCKETS, rand),
input_modalities: inputs,
output_modalities: outputs,
capabilities,
}
}
const TOKEN_FORMAT = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
})
/** Format a token count compactly: 128_000 → "128K", 1_000_000 → "1M". */
export function formatTokenCount(tokens: number): string {
if (!Number.isFinite(tokens) || tokens <= 0) return '—'
if (tokens >= 1_000_000) {
const value = tokens / 1_000_000
return `${TOKEN_FORMAT.format(value)}M`
}
if (tokens >= 1_000) {
const value = tokens / 1_000
return `${TOKEN_FORMAT.format(value)}K`
}
return TOKEN_FORMAT.format(tokens)
}
/** Format a YYYY-MM (or YYYY-MM-DD) date as `Mon YYYY` for display. */
export function formatYearMonth(value: string): string {
if (!value) return '—'
const [yearStr, monthStr] = value.split('-')
const year = Number(yearStr)
const month = Number(monthStr)
if (!Number.isFinite(year) || !Number.isFinite(month)) return value
const date = new Date(Date.UTC(year, month - 1, 1))
return date.toLocaleString(undefined, { year: 'numeric', month: 'short' })
}
// ---------------------------------------------------------------------------
// Provider / vendor / tokenizer / license inference
// ---------------------------------------------------------------------------
//
// These helpers derive vendor-style metadata from the model name. They are
// purely heuristic and serve only the API-info display until the backend
// returns explicit fields.
export type ModelVendor =
| 'openai'
| 'anthropic'
| 'google'
| 'meta'
| 'mistral'
| 'qwen'
| 'deepseek'
| 'xai'
| 'cohere'
| 'baidu'
| 'zhipu'
| 'moonshot'
| 'minimax'
| 'tencent'
| 'bytedance'
| 'midjourney'
| 'stability'
| 'unknown'
export type ApiInfo = {
vendor: ModelVendor
vendor_label: string
tokenizer: string
tokenizer_note?: string
license: string
license_kind: 'proprietary' | 'open' | 'open-weight' | 'unknown'
data_retention_days: number
training_opt_out: boolean
homepage?: string
}
const VENDOR_LABELS: Record<ModelVendor, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google',
meta: 'Meta',
mistral: 'Mistral AI',
qwen: 'Alibaba (Qwen)',
deepseek: 'DeepSeek',
xai: 'xAI',
cohere: 'Cohere',
baidu: 'Baidu',
zhipu: 'Zhipu AI',
moonshot: 'Moonshot AI',
minimax: 'MiniMax',
tencent: 'Tencent',
bytedance: 'ByteDance',
midjourney: 'Midjourney',
stability: 'Stability AI',
unknown: 'Unknown',
}
function detectVendor(name: string): ModelVendor {
const n = name.toLowerCase()
if (/^gpt|^o[1-4]|davinci|babbage|whisper|tts|dall.?e|sora|^omni/.test(n))
return 'openai'
if (/claude/.test(n)) return 'anthropic'
if (/gemini|gemma|imagen|veo|palm/.test(n)) return 'google'
if (/llama|^codellama/.test(n)) return 'meta'
if (/mistral|mixtral|codestral|magistral|pixtral/.test(n)) return 'mistral'
if (/qwen|qwq|qvq/.test(n)) return 'qwen'
if (/deepseek/.test(n)) return 'deepseek'
if (/grok/.test(n)) return 'xai'
if (/command|cohere|aya/.test(n)) return 'cohere'
if (/ernie|wenxin/.test(n)) return 'baidu'
if (/glm|chatglm|cogview|cogvideo/.test(n)) return 'zhipu'
if (/kimi|moonshot/.test(n)) return 'moonshot'
if (/abab|minimax|hailuo/.test(n)) return 'minimax'
if (/hunyuan/.test(n)) return 'tencent'
if (/doubao|seed|jimeng/.test(n)) return 'bytedance'
if (/midjourney|niji/.test(n)) return 'midjourney'
if (/^sd-|stable[-_]?diffusion|sdxl/.test(n)) return 'stability'
return 'unknown'
}
const TOKENIZER_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
openai: 'o200k_base',
anthropic: 'Anthropic Claude tokenizer',
google: 'SentencePiece (Gemini)',
meta: 'Llama 3 tokenizer',
mistral: 'Mistral tokenizer (BPE)',
qwen: 'Qwen tokenizer (tiktoken-compat)',
deepseek: 'DeepSeek tokenizer (BPE)',
xai: 'Grok tokenizer (BPE)',
cohere: 'Cohere tokenizer',
baidu: 'Ernie tokenizer',
zhipu: 'GLM tokenizer',
moonshot: 'Kimi tokenizer',
minimax: 'ABAB tokenizer',
tencent: 'Hunyuan tokenizer',
bytedance: 'Doubao tokenizer',
}
function inferTokenizer(
model: PricingModel,
vendor: ModelVendor
): {
tokenizer: string
note?: string
} {
const name = model.model_name.toLowerCase()
if (vendor === 'openai') {
if (/gpt-3|davinci|babbage|whisper|tts/.test(name)) {
return { tokenizer: 'cl100k_base', note: 'Older GPT-3.5 family' }
}
return { tokenizer: 'o200k_base' }
}
return { tokenizer: TOKENIZER_BY_VENDOR[vendor] ?? 'BPE (vendor-specific)' }
}
const LICENSE_BY_VENDOR: Record<
ModelVendor,
{ license: string; kind: ApiInfo['license_kind'] }
> = {
openai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
anthropic: { license: 'Proprietary (commercial)', kind: 'proprietary' },
google: { license: 'Proprietary (commercial)', kind: 'proprietary' },
meta: { license: 'Llama Community License', kind: 'open-weight' },
mistral: { license: 'Apache 2.0 / Commercial', kind: 'open-weight' },
qwen: { license: 'Tongyi Qianwen License', kind: 'open-weight' },
deepseek: { license: 'DeepSeek License', kind: 'open-weight' },
xai: { license: 'Proprietary (commercial)', kind: 'proprietary' },
cohere: { license: 'Proprietary (commercial)', kind: 'proprietary' },
baidu: { license: 'Proprietary (commercial)', kind: 'proprietary' },
zhipu: { license: 'GLM-4 License', kind: 'open-weight' },
moonshot: { license: 'Proprietary (commercial)', kind: 'proprietary' },
minimax: { license: 'Proprietary (commercial)', kind: 'proprietary' },
tencent: { license: 'Hunyuan License', kind: 'open-weight' },
bytedance: { license: 'Proprietary (commercial)', kind: 'proprietary' },
midjourney: { license: 'Proprietary (commercial)', kind: 'proprietary' },
stability: { license: 'Stability AI Community License', kind: 'open-weight' },
unknown: { license: 'Provider-specific', kind: 'unknown' },
}
const HOMEPAGE_BY_VENDOR: Partial<Record<ModelVendor, string>> = {
openai: 'https://platform.openai.com/docs/models',
anthropic: 'https://docs.anthropic.com/claude/docs/models-overview',
google: 'https://ai.google.dev/models',
meta: 'https://llama.meta.com/',
mistral: 'https://docs.mistral.ai/getting-started/models/',
qwen: 'https://qwenlm.github.io/',
deepseek: 'https://api-docs.deepseek.com/',
xai: 'https://x.ai/api',
cohere: 'https://docs.cohere.com/docs/models',
baidu: 'https://cloud.baidu.com/product/wenxinworkshop',
zhipu: 'https://open.bigmodel.cn/dev/api',
moonshot: 'https://platform.moonshot.cn/docs',
minimax: 'https://platform.minimaxi.com/document/notice',
tencent: 'https://cloud.tencent.com/document/product/1729',
bytedance: 'https://www.volcengine.com/docs/82379',
midjourney: 'https://www.midjourney.com/',
stability: 'https://platform.stability.ai/',
}
/**
* Build vendor / tokenizer / license / privacy metadata for the model.
* Returns deterministic values keyed off the model name so each render is
* stable.
*/
export function inferApiInfo(model: PricingModel): ApiInfo {
const vendor = detectVendor(model.model_name || '')
const tk = inferTokenizer(model, vendor)
const license = LICENSE_BY_VENDOR[vendor]
const rand = seededRandom(hashStringToSeed(`${model.model_name}:api`))
const retention = vendor === 'openai' ? 30 : Math.round(rand() * 90)
return {
vendor,
vendor_label: VENDOR_LABELS[vendor],
tokenizer: tk.tokenizer,
tokenizer_note: tk.note,
license: license.license,
license_kind: license.kind,
data_retention_days: retention,
training_opt_out: true,
homepage: HOMEPAGE_BY_VENDOR[vendor],
}
}
+45
View File
@@ -0,0 +1,45 @@
// ----------------------------------------------------------------------------
// Deterministic seeding helpers
// ----------------------------------------------------------------------------
//
// These utilities are used to generate stable, repeatable mock metrics for
// model details (latency, throughput, uptime, app rankings) until the
// backend ships real values. Seeding the PRNG from the model name (and
// optionally the group name) ensures the same model always renders the same
// numbers, instead of jittering on every render.
/** djb2-inspired string hash → non-negative 31-bit integer. */
export function hashStringToSeed(input: string): number {
let hash = 5381
for (let i = 0; i < input.length; i++) {
hash = (hash * 33) ^ input.charCodeAt(i)
}
return Math.abs(hash | 0)
}
/** Linear-congruential generator producing pseudo-random numbers in [0, 1). */
export function seededRandom(seed: number): () => number {
let state = (seed || 1) >>> 0
return () => {
state = (state * 1664525 + 1013904223) >>> 0
return state / 0x1_0000_0000
}
}
/** Pick a number in [min, max] from a seeded PRNG. */
export function randomInRange(
rand: () => number,
min: number,
max: number
): number {
return min + rand() * (max - min)
}
/** Pick an integer in [min, max] (inclusive) from a seeded PRNG. */
export function randomIntInRange(
rand: () => number,
min: number,
max: number
): number {
return Math.floor(randomInRange(rand, min, max + 1))
}