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, Search, X } from 'lucide-react' import { Link, useNavigate } from 'react-router-dom' import { useI18n } from '@/i18n' import { apiFetch } from '@/lib/api' import { Button } from '@/components/ui/button' import { CopyButton } from '@/components/copy-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' export 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 // Parsed token count from the server (single source of truth — see // server/src/lib/budget.ts). Optional only because the dev mock omits it. monthlyTokenBudgetTokens?: number // Max context length in tokens (catalog value), or null when unrecorded. // Drives the catalog context-window filter on the Models page. contextWindow?: number | null supportsVision: boolean supportsTools: boolean keyCount: number // Logical-model grouping (sent by the server when unify is relevant). Absent // for ungrouped rows; the UI falls back to a per-row "solo" group then. groupKey?: string canonicalId?: string groupLabel?: string } type RoutingStrategy = 'priority' | 'balanced' | 'smartest' | 'fastest' | 'reliable' | 'intelligence' | 'custom' type RoutingWeights = { reliability: number; speed: number; intelligence: number } export interface RoutingScore { modelDbId: number reliability: number speed: number intelligence: number headroom: number rateLimit: number score: number totalRequests: number } export 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. export type Row = FallbackEntry & Partial // `tKey` is the i18n suffix under `strategies.*` (label) and `strategies.*Blurb`. // It differs from the routing `key` for Manual, whose strategy id is 'priority'. const STRATEGIES: { key: RoutingStrategy; tKey: string }[] = [ { key: 'priority', tKey: 'manual' }, { key: 'balanced', tKey: 'balanced' }, { key: 'smartest', tKey: 'smartest' }, { key: 'fastest', tKey: 'fastest' }, { key: 'reliable', tKey: 'reliable' }, { key: 'intelligence', tKey: 'intelligence' }, { key: 'custom', tKey: 'custom' }, ] // Slider axes share the colors used by the score table columns below. // `tKey` is the i18n suffix under `strategies.weight*`. const WEIGHT_AXES: { key: keyof RoutingWeights; tKey: string; color: string }[] = [ { key: 'reliability', tKey: 'weightReliability', color: '#22c55e' }, { key: 'speed', tKey: 'weightSpeed', color: '#3b82f6' }, { key: 'intelligence', tKey: 'weightIntelligence', 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 { t } = useI18n() const [values, setValues] = useState(() => fromSaved(saved)) const [dirty, setDirty] = useState(false) // Defensive: an older/partial server response (or a future field rename) could // leave `saved` undefined; never let that white-screen the whole page (there's // no error boundary above us). Fall back to an even split. function fromSaved(w?: RoutingWeights): RoutingWeights { const safe = w ?? { reliability: 1 / 3, speed: 1 / 3, intelligence: 1 / 3 } return { reliability: Math.round(safe.reliability * 100), speed: Math.round(safe.speed * 100), intelligence: Math.round(safe.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) } }}> {t('strategies.adjust')}

{t('strategies.customWeights')}

{t('strategies.customWeightsHelp')}

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

{t('strategies.weightRequired')}

)}
) } export 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) } // Compact context-window label (whole-number K/M, base 1000): 8000 → "8K", // 128000 → "128K", 1_000_000 → "1M". Used by the catalog context badge/filter. function formatContext(n: number): string { if (n >= 1_000_000) return `${Math.round(n / 1_000_000)}M` if (n >= 1_000) return `${Math.round(n / 1_000)}K` return String(n) } // The largest context window across a logical model's providers. function groupMaxContext(members: Row[]): number { return Math.max(0, ...members.map(m => m.contextWindow ?? 0)) } // Minimum-context filter buckets for the Models page toolbar. `key` is the token // threshold (0 = no filter); numeric labels are not localized (they're numbers). const CTX_BUCKETS: { key: number; label?: string; tKey?: string }[] = [ { key: 0, tKey: 'ctxAny' }, { key: 32_000, label: '32K+' }, { key: 128_000, label: '128K+' }, { key: 1_000_000, label: '1M+' }, ] // For models with no monthly token budget, surface their rate quota instead. // Strips the catalog's decorative bits ("free · ", " per IP", "~", "?") so e.g. // "free · 40 RPM" → "40 RPM", "free · 200/hr per IP" → "200/hr", "~? (anon)" → // "anon". Returns null when nothing meaningful remains. export function cleanQuotaLabel(s: string | undefined): string | null { if (!s) return null let c = s .replace(/free\s*·\s*/ig, '') .replace(/\s*per ip\s*/ig, '') .replace(/[~?]/g, '') .replace(/\s+/g, ' ') .trim() c = c.replace(/^\(([^()]*)\)$/, '$1').trim() return c || null } // The quota badge for a logical model: its summed monthly token budget when it // has one (you can spend all providers' budgets via failover), else the best // rate cap (RPM/RPD, or the catalog's rate label) for rate-limited providers. // Shared by the Models-page group header and the per-model detail page. export function groupQuotaBadge( members: Row[], t: (key: string, vars?: Record) => string, ): { text: string; title: string } | null { const totalBudget = members.reduce((sum, m) => sum + (m.monthlyTokenBudgetTokens ?? 0), 0) const maxRpm = Math.max(0, ...members.map(m => m.rpmLimit ?? 0)) const maxRpd = Math.max(0, ...members.map(m => m.rpdLimit ?? 0)) const rateLabelText = members.map(m => cleanQuotaLabel(m.monthlyTokenBudget)).find(Boolean) ?? null if (totalBudget > 0) return { text: t('models.aggregateBudget', { count: formatTokens(totalBudget) }), title: t('models.aggregateBudgetTitle') } if (maxRpm > 0) return { text: t('models.rateRpm', { count: maxRpm }), title: t('models.rateTitle') } if (maxRpd > 0) return { text: t('models.rateRpd', { count: maxRpd }), title: t('models.rateTitle') } if (rateLabelText) return { text: rateLabelText, title: t('models.rateTitle') } return null } 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. export 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 { t } = useI18n() 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 (

{t('models.monthlyTokenBudget')}

{formatTokens(remaining)} {t('models.remaining')} · {remainingPct}% {t('models.of')} {formatTokens(totalBudget)}
{modelsWithWidth.map((m, i) => (
))} {totalUsed > 0 && (
)}
{modelsWithWidth.map((m, i) => (
{m.displayName} {formatTokens(m.remainingTokens)}
))}
{collapsible && ( )}
) } // The shared table header for the unified model/provider table — used by the // Models page and the per-model detail page so their columns line up. export function ModelTableHead() { const { t } = useI18n() return ( # {t('models.columnModel')} {t('strategies.weightReliability')} {t('strategies.weightSpeed')} {t('strategies.weightIntelligence')} {t('strategies.guardrails')} {t('strategies.scoreColumn')} {t('models.columnOn')} ) } // ── One row of the unified table ──────────────────────────────────────────── export function RowContent({ row, rank, draggable, dragHandle, onToggle, }: { row: Row rank: number draggable: boolean dragHandle?: ReactNode onToggle: (modelDbId: number, enabled: boolean) => void }) { const { t } = useI18n() const guard = (row.headroom ?? 1) * (row.rateLimit ?? 1) return ( <> {draggable ? dragHandle : ·} {rank}
{row.displayName} {row.platform} {row.supportsVision && ( {t('models.vision')} )} {row.supportsTools && ( {t('models.tools')} )} {(row.penalty ?? 0) > 0 && ( {t('models.penalty', { value: row.penalty })} )} {row.totalRequests !== undefined && row.totalRequests > 0 && ( {t('models.obs', { count: row.totalRequests })} )}
{/* Token budget only when it's a real token count; rate-limited models (NVIDIA's "free · 40 RPM") show their rate, not "… tok/mo". */} {[ (row.monthlyTokenBudgetTokens ?? 0) > 0 ? t('models.tokPerMonth', { count: row.monthlyTokenBudget }) : null, row.rpmLimit ? t('models.rpmLimit', { count: row.rpmLimit }) : null, row.rpdLimit ? t('models.rpdLimit', { count: row.rpdLimit }) : null, ].filter(Boolean).join(' · ') || cleanQuotaLabel(row.monthlyTokenBudget) || '—'}
{guard < 0.999 ? `×${guard.toFixed(2)}` : '—'} {row.score !== undefined ? row.score.toFixed(3) : '–'} onToggle(row.modelDbId, c)} /> ) } // ── Grouped (unified) rendering ────────────────────────────────────────────── // One logical model and the provider rows that serve it. interface ModelGroupRow { key: string label: string members: Row[] } // Group merged rows by their server-assigned groupKey (or a per-row "solo" key // when ungrouped). Members are ordered like the flat chain — by manual priority // under the priority strategy, by live score otherwise — and groups inherit the // best member's position so the unified order matches the flat order. function buildGroups(rows: Row[], isManual: boolean): ModelGroupRow[] { const map = new Map() for (const r of rows) { const key = r.groupKey ?? `solo:${r.modelDbId}` const arr = map.get(key) if (arr) arr.push(r) else map.set(key, [r]) } const groups = [...map.entries()].map(([key, members]) => ({ key, label: members[0].groupLabel ?? members[0].displayName, members: [...members].sort((a, b) => (isManual ? a.priority - b.priority : (b.score ?? 0) - (a.score ?? 0))), })) groups.sort((a, b) => isManual ? Math.min(...a.members.map(m => m.priority)) - Math.min(...b.members.map(m => m.priority)) : Math.max(...b.members.map(m => m.score ?? 0)) - Math.max(...a.members.map(m => m.score ?? 0)), ) return groups } const dragDots = ( ) // The collapsed header row for a logical-model group: name, provider count, // union vision/tools badges, the best member's axis bars + score, and a single // switch that enables/disables every provider in the group. function GroupHeaderCells({ group, rank, dragHandle, onToggleGroup }: { group: ModelGroupRow rank: number dragHandle?: ReactNode onToggleGroup: (memberIds: number[], enabled: boolean) => void }) { const { t } = useI18n() const anyEnabled = group.members.some(m => m.enabled) const solo = group.members.length === 1 const best = group.members.reduce((b, m) => ((m.score ?? -1) > (b.score ?? -1) ? m : b), group.members[0]) const guard = (best.headroom ?? 1) * (best.rateLimit ?? 1) const vision = group.members.some(m => m.supportsVision) const tools = group.members.some(m => m.supportsTools) const quota = groupQuotaBadge(group.members, t) const maxCtx = groupMaxContext(group.members) // The model name links to its own page, which lists every provider that serves // it (replaces the old inline expansion). const detailId = encodeURIComponent(group.members[0].canonicalId ?? group.members[0].modelId) // The unified model string to paste into .env / API payloads (#343 quick-copy). const copyId = group.members[0].canonicalId ?? group.members[0].modelId return ( <> {dragHandle ?? ·} {rank}
e.stopPropagation()} className="flex items-center gap-2 flex-wrap text-left min-w-0"> {group.label} {solo ? {group.members[0].platform} : m.platform).join(', ') })}> {t('models.providerCount', { count: group.members.length })} } {quota && ( {quota.text} )} {maxCtx > 0 && ( {t('models.ctxBadge', { size: formatContext(maxCtx) })} )} {vision && ( {t('models.vision')} )} {tools && ( {t('models.tools')} )} {/* Quick-copy the unified model id (#343). Stop propagation so it neither follows the model link nor triggers the row's navigate-on-click. */} e.stopPropagation()} className="shrink-0">
{guard < 0.999 ? `×${guard.toFixed(2)}` : '—'} {best.score !== undefined ? best.score.toFixed(3) : '–'} e.stopPropagation()}> onToggleGroup(group.members.map(m => m.modelDbId), c)} /> ) } function SortableGroupRow({ group, rank, onToggleGroup }: { group: ModelGroupRow rank: number onToggleGroup: (memberIds: number[], enabled: boolean) => void }) { const { t } = useI18n() const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `grp:${group.key}` }) const anyEnabled = group.members.some(m => m.enabled) const navigate = useNavigate() const detailId = encodeURIComponent(group.members[0].canonicalId ?? group.members[0].modelId) const handle = ( ) return ( navigate(`/models/chat/${detailId}`)} className={`border-b last:border-0 bg-card cursor-pointer transition-colors hover:[&>td]:bg-muted/50 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg ${isDragging ? 'opacity-50' : ''} ${anyEnabled ? '' : 'opacity-50'}`} > ) } export default function FallbackPage() { const { t } = useI18n() const navigate = useNavigate() const queryClient = useQueryClient() const [localEntries, setLocalEntries] = useState(null) // Catalog search + filter state (#343). const [search, setSearch] = useState('') const [filterVision, setFilterVision] = useState(false) const [filterTools, setFilterTools] = useState(false) const [minContext, setMinContext] = useState(0) 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 })) const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) function handleSave() { saveMutation.mutate(allEntries.map(e => ({ modelDbId: e.modelDbId, priority: e.priority, enabled: e.enabled }))) } const hasChanges = localEntries !== null // ── Model unification: a model served by several providers is always shown as // one logical row that links to its own page (the on/off toggle was removed). ─ const orderedGroups = buildGroups(rows, isManual) // Catalog search + filters (#343). Filtering operates on whole logical-model // groups; rank stays the model's position in the full chain so the numbers // don't renumber as you filter. Drag-to-reorder is only offered over the full, // unfiltered manual chain (reordering a filtered subset would be ambiguous). const rankByKey = new Map(orderedGroups.map((g, i) => [g.key, i + 1])) const query = search.trim().toLowerCase() const filtersActive = query !== '' || filterVision || filterTools || minContext > 0 const visibleGroups = orderedGroups.filter(g => { if (filterVision && !g.members.some(m => m.supportsVision)) return false if (filterTools && !g.members.some(m => m.supportsTools)) return false if (minContext > 0 && groupMaxContext(g.members) < minContext) return false if (query) { const hay = [ g.label, g.members[0].canonicalId ?? '', ...g.members.map(m => m.platform), ...g.members.map(m => m.displayName), ...g.members.map(m => m.modelId), ].join(' ').toLowerCase() if (!hay.includes(query)) return false } return true }) const draggable = isManual && !filtersActive function clearFilters() { setSearch('') setFilterVision(false) setFilterTools(false) setMinContext(0) } function handleGroupToggle(memberIds: number[], enabled: boolean) { const ids = new Set(memberIds) setLocalEntries(allEntries.map(e => (ids.has(e.modelDbId) ? { ...e, enabled } : e))) } // Serialize the displayed group order (group-major, member-minor) to the flat // priority list PUT /api/fallback expects; keyless rows keep their tail spot. function persistGroupOrder(groups: ModelGroupRow[]) { const order: number[] = [] for (const g of groups) for (const m of g.members) order.push(m.modelDbId) const unconfigured = allEntries.filter(e => e.keyCount === 0).map(e => e.modelDbId) const prio = new Map([...order, ...unconfigured].map((id, i) => [id, i + 1])) setLocalEntries(allEntries.map(e => ({ ...e, priority: prio.get(e.modelDbId) ?? e.priority }))) } // Reorder models (the failover priority order). Providers within a model are // ordered by the active strategy and managed on the model's own page. function handleGroupedDragEnd(event: DragEndEvent) { const { active, over } = event if (!over || active.id === over.id) return const oldI = orderedGroups.findIndex(g => `grp:${g.key}` === String(active.id)) const newI = orderedGroups.findIndex(g => `grp:${g.key}` === String(over.id)) if (oldI < 0 || newI < 0) return persistGroupOrder(arrayMove(orderedGroups, oldI, newI)) } return (
} />
{/* Monthly token budget — moved to the top */} {tokenUsage && tokenUsage.totalBudget > 0 && } {/* Strategy selector */}

{t('strategies.title')}

{routing?.weights && ( {t('strategies.weightsSummary', { 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 ? t('strategies.modeManualHint') : t('strategies.modeScoreHint')}

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

{t('common.loading')}

) : orderedGroups.length === 0 ? (

{t('models.noModelsBefore')}{t('models.keysPageLink')}{t('models.noModelsAfter')}

) : ( <> {/* Catalog toolbar: search + capability/context filters (#343) */}
setSearch(e.target.value)} placeholder={t('models.searchPlaceholder')} aria-label={t('models.searchPlaceholder')} className="w-full rounded-xl border bg-card py-1.5 pl-9 pr-8 text-sm outline-none transition-colors focus:border-foreground/30" /> {search && ( )}
{CTX_BUCKETS.map(b => ( ))}
{filtersActive && (
{t('models.showingCount', { shown: visibleGroups.length, total: orderedGroups.length })}
)} {/* DndContext must wrap OUTSIDE the table: it renders hidden a11y live-region
s, which are invalid as direct children. */} {visibleGroups.length === 0 ? (

{t('models.noMatches')}

) : draggable ? (
`grp:${g.key}`)} strategy={verticalListSortingStrategy}> {visibleGroups.map(g => ( ))}
) : (
{visibleGroups.map(g => ( navigate(`/models/chat/${encodeURIComponent(g.members[0].canonicalId ?? g.members[0].modelId)}`)} className={`border-b last:border-0 cursor-pointer transition-colors hover:[&>td]:bg-muted/50 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg ${g.members.some(m => m.enabled) ? '' : 'opacity-50'}`} > ))}
)} {/* 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. */} {t('common.unsavedChanges')} {unconfiguredPlatforms.length > 0 && (

{t('models.hiddenNoKeys', { platforms: unconfiguredPlatforms.join(', ') })}

)} )}
) }