| import { useEffect, useState, useRef, type ReactNode } from 'react'
|
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
| import {
|
| DndContext,
|
| closestCenter,
|
| KeyboardSensor,
|
| PointerSensor,
|
| useSensor,
|
| useSensors,
|
| type DragEndEvent,
|
| } from '@dnd-kit/core'
|
| import {
|
| arrayMove,
|
| SortableContext,
|
| sortableKeyboardCoordinates,
|
| useSortable,
|
| verticalListSortingStrategy,
|
| } from '@dnd-kit/sortable'
|
| import { CSS } from '@dnd-kit/utilities'
|
| import { ChevronDown, SlidersHorizontal } from 'lucide-react'
|
| import { apiFetch } from '@/lib/api'
|
| import { Button } from '@/components/ui/button'
|
| import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
| import { Switch } from '@/components/ui/switch'
|
| import { PageHeader } from '@/components/page-header'
|
| import { FloatingBar } from '@/components/floating-bar'
|
| import { ModelsTabs } from '@/components/models-tabs'
|
| import { Tooltip } from '@/components/tooltip'
|
|
|
| interface FallbackEntry {
|
| modelDbId: number
|
| priority: number
|
| effectivePriority: number
|
| penalty: number
|
| rateLimitHits: number
|
| enabled: boolean
|
| platform: string
|
| modelId: string
|
| displayName: string
|
| intelligenceRank: number
|
| speedRank: number
|
| sizeLabel: string
|
| rpmLimit: number | null
|
| rpdLimit: number | null
|
| monthlyTokenBudget: string
|
| supportsVision: boolean
|
| supportsTools: boolean
|
| keyCount: number
|
| }
|
|
|
| type RoutingStrategy = 'priority' | 'balanced' | 'smartest' | 'fastest' | 'reliable' | 'custom'
|
|
|
| type RoutingWeights = { reliability: number; speed: number; intelligence: number }
|
|
|
| interface RoutingScore {
|
| modelDbId: number
|
| reliability: number
|
| speed: number
|
| intelligence: number
|
| headroom: number
|
| rateLimit: number
|
| score: number
|
| totalRequests: number
|
| }
|
|
|
| interface RoutingData {
|
| strategy: RoutingStrategy
|
| weights: RoutingWeights | null
|
| customWeights: RoutingWeights
|
| scores: (RoutingScore & { platform: string; modelId: string; displayName: string; enabled: boolean })[]
|
| }
|
|
|
|
|
| type Row = FallbackEntry & Partial<RoutingScore>
|
|
|
| const STRATEGIES: { key: RoutingStrategy; label: string; blurb: string }[] = [
|
| { key: 'priority', label: 'Manual', blurb: 'Route in the exact order you set below. Drag the handles to reorder. No scoring; the chain is followed top-to-bottom.' },
|
| { key: 'balanced', label: 'Balanced', blurb: 'Reliability leads (50%), with speed and intelligence weighted equally (25% each). A sensible all-round default.' },
|
| { key: 'smartest', label: 'Smartest', blurb: 'Prefer the most capable model that still works. Intelligence 55%, reliability 35%, speed 10%.' },
|
| { key: 'fastest', label: 'Fastest', blurb: 'Prefer the fastest model that still works. Speed 55%, reliability 35%, intelligence 10%.' },
|
| { key: 'reliable', label: 'Most reliable', blurb: 'Maximize success rate above all. Reliability 70%, speed and intelligence 15% each.' },
|
| { key: 'custom', label: 'Custom', blurb: 'Set your own balance of reliability, speed and intelligence with sliders. Same engine as the presets, just your weights.' },
|
| ]
|
|
|
|
|
| const WEIGHT_AXES: { key: keyof RoutingWeights; label: string; color: string }[] = [
|
| { key: 'reliability', label: 'Reliability', color: '#22c55e' },
|
| { key: 'speed', label: 'Speed', color: '#3b82f6' },
|
| { key: 'intelligence', label: 'Intelligence', color: '#a855f7' },
|
| ]
|
|
|
|
|
|
|
|
|
| function CustomWeightsPopover({ saved, onSave, saving }: {
|
| saved: RoutingWeights
|
| onSave: (w: RoutingWeights) => void
|
| saving: boolean
|
| }) {
|
| const [values, setValues] = useState<RoutingWeights>(() => fromSaved(saved))
|
| const [dirty, setDirty] = useState(false)
|
|
|
| function fromSaved(w: RoutingWeights): RoutingWeights {
|
| return {
|
| reliability: Math.round(w.reliability * 100),
|
| speed: Math.round(w.speed * 100),
|
| intelligence: Math.round(w.intelligence * 100),
|
| }
|
| }
|
|
|
| function update(key: keyof RoutingWeights, v: number) {
|
| setValues({ ...values, [key]: v })
|
| setDirty(true)
|
| }
|
|
|
| function apply() {
|
| if (sum <= 0) return
|
| onSave({
|
| reliability: values.reliability / 100,
|
| speed: values.speed / 100,
|
| intelligence: values.intelligence / 100,
|
| })
|
| setDirty(false)
|
| }
|
|
|
| const sum = values.reliability + values.speed + values.intelligence
|
|
|
| return (
|
| <Popover onOpenChange={open => { if (open) { setValues(fromSaved(saved)); setDirty(false) } }}>
|
| <PopoverTrigger className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
| <SlidersHorizontal className="size-3.5" />
|
| Adjust
|
| </PopoverTrigger>
|
| <PopoverContent align="start" className="w-80">
|
| <div className="space-y-4">
|
| <div>
|
| <h3 className="text-sm font-medium">Custom weights</h3>
|
| <p className="mt-0.5 text-xs text-muted-foreground">
|
| Sliders are independent; shares auto-balance to 100%.
|
| </p>
|
| </div>
|
| {WEIGHT_AXES.map(axis => {
|
| const share = sum > 0 ? Math.round((values[axis.key] / sum) * 100) : 0
|
| return (
|
| <div key={axis.key}>
|
| <div className="mb-1 flex items-baseline justify-between text-xs">
|
| <span className="inline-flex items-center gap-1.5">
|
| <span className="size-2 rounded-sm" style={{ background: axis.color }} />
|
| {axis.label}
|
| </span>
|
| <span className="tabular-nums text-muted-foreground">{share}%</span>
|
| </div>
|
| <input
|
| type="range"
|
| min={0}
|
| max={100}
|
| step={1}
|
| value={values[axis.key]}
|
| onChange={e => update(axis.key, Number(e.target.value))}
|
| className="w-full cursor-pointer"
|
| style={{ accentColor: axis.color }}
|
| aria-label={`${axis.label} weight`}
|
| />
|
| </div>
|
| )
|
| })}
|
| {sum <= 0 && (
|
| <p className="text-xs text-amber-600 dark:text-amber-500">
|
| At least one weight must be above zero.
|
| </p>
|
| )}
|
| <Button
|
| size="sm"
|
| className="w-full"
|
| disabled={!dirty || sum <= 0 || saving}
|
| onClick={apply}
|
| >
|
| {saving ? 'Applying…' : dirty ? 'Apply' : 'Applied'}
|
| </Button>
|
| </div>
|
| </PopoverContent>
|
| </Popover>
|
| )
|
| }
|
|
|
| function formatTokens(n: number): string {
|
| 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 String(n)
|
| }
|
|
|
| interface TokenUsageData {
|
| totalBudget: number
|
| totalUsed: number
|
| models: { displayName: string; platform: string; budget: number }[]
|
| }
|
|
|
| const platformColors: Record<string, string> = {
|
| google: '#4285f4',
|
| groq: '#f55036',
|
| cerebras: '#8b5cf6',
|
| sambanova: '#14b8a6',
|
| nvidia: '#76b900',
|
| mistral: '#f59e0b',
|
| openrouter: '#ec4899',
|
| github: '#6e7b8b',
|
| cohere: '#d946ef',
|
| cloudflare: '#f38020',
|
| zhipu: '#06b6d4',
|
| ollama: '#000000',
|
| kilo: '#7c3aed',
|
| pollinations: '#a855f7',
|
| llm7: '#0ea5e9',
|
| huggingface: '#ff9d00',
|
| }
|
|
|
|
|
| function AxisBar({ value, color }: { value: number | undefined; color: string }) {
|
| const v = value ?? 0
|
| return (
|
| <div className="flex items-center gap-1.5">
|
| <div className="h-1.5 w-12 rounded-full bg-muted overflow-hidden">
|
| <div className="h-full rounded-full" style={{ width: `${Math.round(v * 100)}%`, backgroundColor: color }} />
|
| </div>
|
| <span className="font-mono text-[11px] text-muted-foreground tabular-nums w-7 text-right">
|
| {value === undefined ? '–' : Math.round(v * 100)}
|
| </span>
|
| </div>
|
| )
|
| }
|
|
|
|
|
| const LEGEND_COLLAPSED_PX = 126
|
|
|
| function TokenUsageBar({ data }: { data: TokenUsageData }) {
|
| const { totalBudget, totalUsed, models } = data
|
| const remaining = Math.max(0, totalBudget - totalUsed)
|
| const remainingPct = totalBudget > 0 ? Math.round((remaining / totalBudget) * 100) : 0
|
|
|
|
|
|
|
|
|
| const [expanded, setExpanded] = useState(false)
|
| const [collapsible, setCollapsible] = useState(false)
|
| const legendRef = useRef<HTMLDivElement>(null)
|
| useEffect(() => {
|
| const el = legendRef.current
|
| if (!el) return
|
| const check = () => setCollapsible(el.scrollHeight > LEGEND_COLLAPSED_PX + 1)
|
| check()
|
| const ro = new ResizeObserver(check)
|
| ro.observe(el)
|
| return () => ro.disconnect()
|
| }, [models.length])
|
|
|
| const modelsWithWidth = models.map(m => ({
|
| ...m,
|
| remainingTokens: totalBudget > 0 ? (m.budget / totalBudget) * remaining : 0,
|
| widthPct: totalBudget > 0 ? (m.budget / totalBudget) * (remaining / totalBudget) * 100 : 0,
|
| }))
|
| const usedPct = totalBudget > 0 ? (totalUsed / totalBudget) * 100 : 0
|
|
|
| return (
|
| <section className="rounded-3xl border bg-card p-5">
|
| <div className="flex items-baseline justify-between mb-3">
|
| <h2 className="text-sm font-medium">Monthly token budget</h2>
|
| <span className="text-xs text-muted-foreground tabular-nums">
|
| <span className="text-foreground font-medium">{formatTokens(remaining)}</span> remaining
|
| <span className="mx-1.5">·</span>
|
| {remainingPct}% of {formatTokens(totalBudget)}
|
| </span>
|
| </div>
|
|
|
| <div className="flex h-2.5 rounded-full overflow-hidden bg-muted">
|
| {modelsWithWidth.map((m, i) => (
|
| <div
|
| key={i}
|
| title={`${m.displayName} (${m.platform}): ${formatTokens(m.remainingTokens)} remaining`}
|
| style={{
|
| width: `${m.widthPct}%`,
|
| backgroundColor: platformColors[m.platform] ?? '#94a3b8',
|
| }}
|
| />
|
| ))}
|
| {totalUsed > 0 && (
|
| <div
|
| title={`Used: ${formatTokens(totalUsed)}`}
|
| className="bg-muted-foreground/30"
|
| style={{ width: `${usedPct}%` }}
|
| />
|
| )}
|
| </div>
|
|
|
| <div
|
| ref={legendRef}
|
| className="mt-4 overflow-hidden transition-[max-height] duration-300 ease-in-out"
|
| style={collapsible ? { maxHeight: expanded ? legendRef.current?.scrollHeight : LEGEND_COLLAPSED_PX } : undefined}
|
| >
|
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-5 gap-y-1.5 text-xs tabular-nums">
|
| {modelsWithWidth.map((m, i) => (
|
| <div key={i} className="flex items-center gap-2 min-w-0">
|
| <span
|
| className="size-2 rounded-sm flex-shrink-0"
|
| style={{ backgroundColor: platformColors[m.platform] ?? '#94a3b8' }}
|
| />
|
| <span className="truncate">{m.displayName}</span>
|
| <span className="flex-1" />
|
| <span className="font-mono text-muted-foreground">{formatTokens(m.remainingTokens)}</span>
|
| </div>
|
| ))}
|
| </div>
|
| </div>
|
|
|
| {collapsible && (
|
| <button
|
| onClick={() => setExpanded(e => !e)}
|
| className="mt-2 flex w-full items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
| >
|
| {expanded ? 'Show less' : `Show all ${models.length} models`}
|
| <ChevronDown className={`size-3.5 transition-transform duration-300 ${expanded ? 'rotate-180' : ''}`} />
|
| </button>
|
| )}
|
| </section>
|
| )
|
| }
|
|
|
|
|
| function RowContent({
|
| row,
|
| rank,
|
| draggable,
|
| dragHandle,
|
| onToggle,
|
| }: {
|
| row: Row
|
| rank: number
|
| draggable: boolean
|
| dragHandle?: ReactNode
|
| onToggle: (modelDbId: number, enabled: boolean) => void
|
| }) {
|
| const guard = (row.headroom ?? 1) * (row.rateLimit ?? 1)
|
| return (
|
| <>
|
| <td className="py-2 pl-3 pr-1 w-6 align-middle">
|
| {draggable ? dragHandle : <span className="text-muted-foreground/30 select-none">·</span>}
|
| </td>
|
| <td className="py-2 pr-2 w-6 text-center font-mono text-xs text-muted-foreground tabular-nums align-middle">{rank}</td>
|
| <td className="py-2 pr-3 align-middle">
|
| <div className="flex items-center gap-2 flex-wrap">
|
| <span className="font-medium text-sm">{row.displayName}</span>
|
| <span className="text-xs text-muted-foreground">{row.platform}</span>
|
| {row.supportsVision && (
|
| <span
|
| title="Accepts image input"
|
| className="text-[10px] rounded-full px-1.5 py-0.5 bg-cyan-600/15 text-cyan-700 dark:bg-cyan-400/15 dark:text-cyan-400"
|
| >
|
| Vision
|
| </span>
|
| )}
|
| {row.supportsTools && (
|
| <span
|
| title="Emits structured tool calls, so it is eligible for tool-bearing requests"
|
| className="text-[10px] rounded-full px-1.5 py-0.5 bg-violet-600/15 text-violet-700 dark:bg-violet-400/15 dark:text-violet-400"
|
| >
|
| Tools
|
| </span>
|
| )}
|
| {(row.penalty ?? 0) > 0 && (
|
| <span className="text-[10px] text-amber-600 dark:text-amber-400">−{row.penalty} penalty</span>
|
| )}
|
| {row.totalRequests !== undefined && row.totalRequests > 0 && (
|
| <span className="text-[10px] text-muted-foreground/60 tabular-nums">{row.totalRequests} obs</span>
|
| )}
|
| </div>
|
| <div className="text-[11px] text-muted-foreground/70 tabular-nums mt-0.5">
|
| {row.monthlyTokenBudget} tok/mo
|
| {row.rpmLimit ? ` · ${row.rpmLimit} rpm` : ''}
|
| {row.rpdLimit ? ` · ${row.rpdLimit} rpd` : ''}
|
| </div>
|
| </td>
|
| <td className="py-2 pr-3 align-middle"><AxisBar value={row.reliability} color="#22c55e" /></td>
|
| <td className="py-2 pr-3 align-middle"><AxisBar value={row.speed} color="#3b82f6" /></td>
|
| <td className="py-2 pr-3 align-middle"><AxisBar value={row.intelligence} color="#a855f7" /></td>
|
| <td className="py-2 pr-3 align-middle font-mono text-[11px] text-muted-foreground tabular-nums">
|
| {guard < 0.999 ? `×${guard.toFixed(2)}` : '—'}
|
| </td>
|
| <td className="py-2 pr-3 align-middle text-right font-mono text-xs font-medium tabular-nums">
|
| {row.score !== undefined ? row.score.toFixed(3) : '–'}
|
| </td>
|
| <td className="py-2 pr-3 align-middle text-right">
|
| <Switch checked={row.enabled} onCheckedChange={(c) => onToggle(row.modelDbId, c)} />
|
| </td>
|
| </>
|
| )
|
| }
|
|
|
| function SortableRow({ row, rank, onToggle }: { row: Row; rank: number; onToggle: (id: number, e: boolean) => void }) {
|
| const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.modelDbId })
|
| const handle = (
|
| <button
|
| {...attributes}
|
| {...listeners}
|
| className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-foreground transition-colors"
|
| aria-label="Drag to reorder"
|
| >
|
| <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
| <circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
|
| <circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
|
| <circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
|
| </svg>
|
| </button>
|
| )
|
| return (
|
| <tr
|
| ref={setNodeRef}
|
| style={{ transform: CSS.Transform.toString(transform), transition }}
|
| className={`border-b last:border-0 bg-card ${isDragging ? 'opacity-50' : ''} ${row.enabled ? '' : 'opacity-50'}`}
|
| >
|
| <RowContent row={row} rank={rank} draggable dragHandle={handle} onToggle={onToggle} />
|
| </tr>
|
| )
|
| }
|
|
|
| export default function FallbackPage() {
|
| const queryClient = useQueryClient()
|
| const [localEntries, setLocalEntries] = useState<FallbackEntry[] | null>(null)
|
|
|
| const { data: entries = [], isLoading } = useQuery<FallbackEntry[]>({
|
| queryKey: ['fallback'],
|
| queryFn: () => apiFetch('/api/fallback'),
|
| })
|
|
|
| const { data: tokenUsage } = useQuery<TokenUsageData>({
|
| queryKey: ['fallback', 'token-usage'],
|
| queryFn: () => apiFetch('/api/fallback/token-usage'),
|
| })
|
|
|
| const { data: routing } = useQuery<RoutingData>({
|
| queryKey: ['fallback', 'routing'],
|
| queryFn: () => apiFetch('/api/fallback/routing'),
|
| refetchInterval: 15_000,
|
| })
|
|
|
| const saveMutation = useMutation({
|
| mutationFn: (data: { modelDbId: number; priority: number; enabled: boolean }[]) =>
|
| apiFetch('/api/fallback', { method: 'PUT', body: JSON.stringify(data) }),
|
| onSuccess: () => {
|
| queryClient.invalidateQueries({ queryKey: ['fallback'] })
|
| setLocalEntries(null)
|
| },
|
| })
|
|
|
| const strategyMutation = useMutation({
|
| mutationFn: (payload: { strategy: RoutingStrategy; weights?: RoutingWeights }) =>
|
| apiFetch('/api/fallback/routing', { method: 'PUT', body: JSON.stringify(payload) }),
|
| onSuccess: () => queryClient.invalidateQueries({ queryKey: ['fallback', 'routing'] }),
|
| })
|
|
|
| const strategy: RoutingStrategy = routing?.strategy ?? 'balanced'
|
| const isManual = strategy === 'priority'
|
|
|
|
|
| const scoreById = new Map((routing?.scores ?? []).map(s => [s.modelDbId, s]))
|
| const allEntries = localEntries ?? entries
|
| const configured = allEntries.filter(e => e.keyCount > 0)
|
| const unconfiguredPlatforms = [...new Set(allEntries.filter(e => e.keyCount === 0).map(e => e.platform))]
|
|
|
|
|
|
|
| const rows: Row[] = configured.map(e => ({ ...(scoreById.get(e.modelDbId) ?? {}), ...e }))
|
|
|
| const ordered = isManual
|
| ? [...rows].sort((a, b) => a.priority - b.priority)
|
| : [...rows].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
|
| const sensors = useSensors(
|
| useSensor(PointerSensor),
|
| useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
| )
|
|
|
| function handleDragEnd(event: DragEndEvent) {
|
| const { active, over } = event
|
| if (!over || active.id === over.id) return
|
| const oldIndex = ordered.findIndex(e => e.modelDbId === active.id)
|
| const newIndex = ordered.findIndex(e => e.modelDbId === over.id)
|
| const reorderedVisible = arrayMove(ordered, oldIndex, newIndex)
|
| const unconfigured = allEntries.filter(e => e.keyCount === 0)
|
| const merged: FallbackEntry[] = [
|
| ...reorderedVisible.map((e, i) => ({ ...(e as FallbackEntry), priority: i + 1 })),
|
| ...unconfigured.map((e, i) => ({ ...e, priority: reorderedVisible.length + i + 1 })),
|
| ]
|
| setLocalEntries(merged)
|
| }
|
|
|
| function handleToggle(modelDbId: number, enabled: boolean) {
|
| setLocalEntries(allEntries.map(e => (e.modelDbId === modelDbId ? { ...e, enabled } : e)))
|
| }
|
|
|
| function handleSave() {
|
| saveMutation.mutate(allEntries.map(e => ({ modelDbId: e.modelDbId, priority: e.priority, enabled: e.enabled })))
|
| }
|
|
|
| const hasChanges = localEntries !== null
|
|
|
| const tableHead = (
|
| <thead>
|
| <tr className="text-left text-muted-foreground border-b">
|
| <th className="py-2 pl-3 pr-1 w-6"></th>
|
| <th className="py-2 pr-2 w-6 text-center font-medium">#</th>
|
| <th className="py-2 pr-3 font-medium">Model</th>
|
| <th className="py-2 pr-3 font-medium">
|
| <span className="inline-flex items-center gap-1"><span className="size-2 rounded-sm" style={{ background: '#22c55e' }} />Reliability</span>
|
| </th>
|
| <th className="py-2 pr-3 font-medium">
|
| <span className="inline-flex items-center gap-1"><span className="size-2 rounded-sm" style={{ background: '#3b82f6' }} />Speed</span>
|
| </th>
|
| <th className="py-2 pr-3 font-medium">
|
| <span className="inline-flex items-center gap-1"><span className="size-2 rounded-sm" style={{ background: '#a855f7' }} />Intelligence</span>
|
| </th>
|
| <th className="py-2 pr-3 font-medium">
|
| <Tooltip text="Always-on guardrails: free-quota headroom × live rate-limit penalty. Below 1.0 means the model is being held back.">
|
| <span className="underline decoration-dotted underline-offset-2 cursor-help">Guardrails</span>
|
| </Tooltip>
|
| </th>
|
| <th className="py-2 pr-3 font-medium text-right">
|
| <Tooltip text="Final routing score = weighted average of the three axes, multiplied by the guardrails. Higher routes first.">
|
| <span className="underline decoration-dotted underline-offset-2 cursor-help">Score</span>
|
| </Tooltip>
|
| </th>
|
| <th className="py-2 pr-3 font-medium text-right">On</th>
|
| </tr>
|
| </thead>
|
| )
|
|
|
| return (
|
| <div>
|
| <PageHeader
|
| title="Models"
|
| description="Pick a routing strategy. In Manual mode you drag to set the order; the other strategies route by live score across reliability, speed and intelligence."
|
| divider={false}
|
| actions={<ModelsTabs />}
|
| />
|
|
|
| <div className="space-y-6">
|
| {/* Monthly token budget — moved to the top */}
|
| {tokenUsage && tokenUsage.totalBudget > 0 && <TokenUsageBar data={tokenUsage} />}
|
|
|
| {/* Strategy selector */}
|
| <section className="rounded-3xl border bg-card p-5">
|
| <div className="flex items-baseline justify-between mb-3">
|
| <h2 className="text-sm font-medium">Routing strategy</h2>
|
| {routing?.weights && (
|
| <span className="text-xs text-muted-foreground tabular-nums">
|
| reliability {Math.round(routing.weights.reliability * 100)}% ·
|
| {' '}speed {Math.round(routing.weights.speed * 100)}% ·
|
| {' '}intelligence {Math.round(routing.weights.intelligence * 100)}%
|
| </span>
|
| )}
|
| </div>
|
|
|
| <div className="inline-flex flex-wrap items-center gap-1 rounded-xl border p-1">
|
| {STRATEGIES.map(s => (
|
| <Tooltip key={s.key} text={s.blurb}>
|
| <button
|
| disabled={strategyMutation.isPending}
|
| onClick={() => strategyMutation.mutate({ strategy: s.key })}
|
| className={`px-3 py-1.5 text-xs rounded-lg transition-colors ${
|
| s.key === strategy
|
| ? 'bg-foreground text-background font-medium'
|
| : 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
| }`}
|
| >
|
| {s.label}
|
| </button>
|
| </Tooltip>
|
| ))}
|
| {strategy === 'custom' && routing && (
|
| <CustomWeightsPopover
|
| saved={routing.customWeights}
|
| saving={strategyMutation.isPending}
|
| onSave={w => strategyMutation.mutate({ strategy: 'custom', weights: w })}
|
| />
|
| )}
|
| </div>
|
|
|
| <p className="mt-2 text-xs text-muted-foreground">
|
| {isManual
|
| ? 'Manual mode: requests follow the order below, top-to-bottom. Drag to reorder.'
|
| : 'Scores update from live traffic. The order below is how requests are routed right now.'}
|
| </p>
|
| </section>
|
|
|
| {/* Unified routing / fallback table */}
|
| {isLoading ? (
|
| <p className="text-sm text-muted-foreground">Loading…</p>
|
| ) : ordered.length === 0 ? (
|
| <div className="rounded-3xl border border-dashed p-8 text-center">
|
| <p className="text-sm text-muted-foreground">
|
| No models available. Add API keys on the <a href="/keys" className="underline text-foreground">Keys page</a> first.
|
| </p>
|
| </div>
|
| ) : (
|
| <>
|
| {/* DndContext must wrap OUTSIDE the table: it renders hidden a11y
|
| live-region <div>s, which are invalid as direct <table> children. */}
|
| {isManual ? (
|
| <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
| <div className="rounded-2xl border overflow-x-auto">
|
| <table className="w-full text-sm">
|
| {tableHead}
|
| <SortableContext items={ordered.map(e => e.modelDbId)} strategy={verticalListSortingStrategy}>
|
| <tbody>
|
| {ordered.map((row, i) => (
|
| <SortableRow key={row.modelDbId} row={row} rank={i + 1} onToggle={handleToggle} />
|
| ))}
|
| </tbody>
|
| </SortableContext>
|
| </table>
|
| </div>
|
| </DndContext>
|
| ) : (
|
| <div className="rounded-2xl border overflow-x-auto">
|
| <table className="w-full text-sm">
|
| {tableHead}
|
| <tbody>
|
| {ordered.map((row, i) => (
|
| <tr key={row.modelDbId} className={`border-b last:border-0 ${row.enabled ? '' : 'opacity-50'}`}>
|
| <RowContent row={row} rank={i + 1} draggable={false} onToggle={handleToggle} />
|
| </tr>
|
| ))}
|
| </tbody>
|
| </table>
|
| </div>
|
| )}
|
|
|
| {/* Floating action bar — fixed to the viewport so it's always visible,
|
| sliding up when there are unsaved changes and back down on save/discard. */}
|
| <FloatingBar show={hasChanges}>
|
| <span className="text-xs text-muted-foreground">Unsaved changes</span>
|
| <Button variant="outline" size="sm" onClick={() => setLocalEntries(null)}>Discard</Button>
|
| <Button size="sm" onClick={handleSave} disabled={saveMutation.isPending}>
|
| {saveMutation.isPending ? 'Saving…' : 'Save changes'}
|
| </Button>
|
| </FloatingBar>
|
|
|
| {unconfiguredPlatforms.length > 0 && (
|
| <p className="text-xs text-muted-foreground">Hidden (no keys): {unconfiguredPlatforms.join(', ')}</p>
|
| )}
|
| </>
|
| )}
|
| </div>
|
| </div>
|
| )
|
| }
|
| |