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 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(() => 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 ( { if (open) { setValues(fromSaved(saved)); setDirty(false) } }}> Adjust

Custom weights

Sliders are independent; shares auto-balance to 100%.

{WEIGHT_AXES.map(axis => { const share = sum > 0 ? Math.round((values[axis.key] / sum) * 100) : 0 return (
{axis.label} {share}%
update(axis.key, Number(e.target.value))} className="w-full cursor-pointer" style={{ accentColor: axis.color }} aria-label={`${axis.label} weight`} />
) })} {sum <= 0 && (

At least one weight must be above zero.

)}
) } 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 = { 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 (
{value === undefined ? '–' : Math.round(v * 100)}
) } // 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(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 (

Monthly token budget

{formatTokens(remaining)} remaining · {remainingPct}% of {formatTokens(totalBudget)}
{modelsWithWidth.map((m, i) => (
))} {totalUsed > 0 && (
)}
{modelsWithWidth.map((m, i) => (
{m.displayName} {formatTokens(m.remainingTokens)}
))}
{collapsible && ( )}
) } // ── 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 ( <> {draggable ? dragHandle : ·} {rank}
{row.displayName} {row.platform} {row.supportsVision && ( Vision )} {row.supportsTools && ( Tools )} {(row.penalty ?? 0) > 0 && ( −{row.penalty} penalty )} {row.totalRequests !== undefined && row.totalRequests > 0 && ( {row.totalRequests} obs )}
{row.monthlyTokenBudget} tok/mo {row.rpmLimit ? ` · ${row.rpmLimit} rpm` : ''} {row.rpdLimit ? ` · ${row.rpdLimit} rpd` : ''}
{guard < 0.999 ? `×${guard.toFixed(2)}` : '—'} {row.score !== undefined ? row.score.toFixed(3) : '–'} onToggle(row.modelDbId, c)} /> ) } 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 = ( ) return ( ) } export default function FallbackPage() { const queryClient = useQueryClient() const [localEntries, setLocalEntries] = useState(null) const { data: entries = [], isLoading } = useQuery({ queryKey: ['fallback'], queryFn: () => apiFetch('/api/fallback'), }) const { data: tokenUsage } = useQuery({ queryKey: ['fallback', 'token-usage'], queryFn: () => apiFetch('/api/fallback/token-usage'), }) const { data: routing } = useQuery({ 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 = ( # Model Reliability Speed Intelligence Guardrails Score On ) return (
} />
{/* Monthly token budget — moved to the top */} {tokenUsage && tokenUsage.totalBudget > 0 && } {/* Strategy selector */}

Routing strategy

{routing?.weights && ( reliability {Math.round(routing.weights.reliability * 100)}% · {' '}speed {Math.round(routing.weights.speed * 100)}% · {' '}intelligence {Math.round(routing.weights.intelligence * 100)}% )}
{STRATEGIES.map(s => ( ))} {strategy === 'custom' && routing && ( strategyMutation.mutate({ strategy: 'custom', weights: w })} /> )}

{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.'}

{/* Unified routing / fallback table */} {isLoading ? (

Loading…

) : ordered.length === 0 ? (

No models available. Add API keys on the Keys page first.

) : ( <> {/* DndContext must wrap OUTSIDE the table: it renders hidden a11y live-region
s, which are invalid as direct children. */} {isManual ? (
{tableHead} e.modelDbId)} strategy={verticalListSortingStrategy}> {ordered.map((row, i) => ( ))}
) : (
{tableHead} {ordered.map((row, i) => ( ))}
)} {/* 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. */} Unsaved changes {unconfiguredPlatforms.length > 0 && (

Hidden (no keys): {unconfiguredPlatforms.join(', ')}

)} )}
) }