Spaces:
Runtime error
Runtime error
| 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 })[] | |
| } | |
| // A merged row: fallback-chain metadata + live bandit scores. | |
| 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.' }, | |
| ] | |
| // Slider axes share the colors used by the score table columns below. | |
| 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' }, | |
| ] | |
| // Slider popover for the 'custom' strategy. Sliders are independent (0-100) | |
| // and the server renormalizes any vector, so we just show each axis's | |
| // effective share live. Nothing is saved until Apply is pressed. | |
| 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', | |
| nvidia: '#76b900', | |
| mistral: '#f59e0b', | |
| openrouter: '#ec4899', | |
| github: '#6e7b8b', | |
| cohere: '#d946ef', | |
| cloudflare: '#f38020', | |
| zhipu: '#06b6d4', | |
| ollama: '#000000', | |
| kilo: '#7c3aed', | |
| pollinations: '#a855f7', | |
| llm7: '#0ea5e9', | |
| huggingface: '#ff9d00', | |
| } | |
| // A 0..1 value as a thin horizontal bar with the number beside it. | |
| 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> | |
| ) | |
| } | |
| // Legend rows visible while collapsed (~6 rows: 6 × 16px line + 5 × 6px gap). | |
| 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 | |
| // Collapse the per-model legend to a few rows; the chevron reveals the rest. | |
| // The toggle only appears when the legend actually overflows the collapsed | |
| // height (column count — and so row count — depends on viewport width). | |
| 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> | |
| ) | |
| } | |
| // ── One row of the unified table ──────────────────────────────────────────── | |
| 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' | |
| // Merge fallback metadata with live scores, keyed by model. | |
| 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))] | |
| // Entry fields win on overlap: the routing snapshot also carries `enabled` | |
| // (and identity fields), which would otherwise clobber unsaved local toggles. | |
| const rows: Row[] = configured.map(e => ({ ...(scoreById.get(e.modelDbId) ?? {}), ...e })) | |
| // Manual → the order you set (by priority). Bandit → ranked by live score. | |
| 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> | |
| ) | |
| } | |