/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { ArrowDown, ArrowDownToLine, ArrowRight, ArrowUp, Check, Info, Plus, Shuffle, Trash2, type LucideIcon, } from 'lucide-react' import { type ReactNode, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Dialog } from '@/components/dialog' import { JsonCodeEditor } from '@/components/json-code-editor' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger, } from '@/components/ui/popover' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, ADVANCED_CUSTOM_CONVERTER_OPTIONS, ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS, ADVANCED_CUSTOM_MODEL_LIST_LABEL, ADVANCED_CUSTOM_MODEL_LIST_PATH, ADVANCED_CUSTOM_TEMPLATE_OPTIONS, type AdvancedCustomAuthMode, buildAdvancedCustomAuth, createAdvancedCustomConfig, createAdvancedCustomRoute, getAdvancedCustomAuthMode, getAdvancedCustomConverterDefaults, getAdvancedCustomConverterOptions, getAdvancedCustomIncomingPathLabel, getAdvancedCustomModelRuleKind, getAdvancedCustomRegexModelPattern, getAdvancedCustomTemplateConfig, getAdvancedCustomUpstreamPathPlaceholder, getDefaultAdvancedCustomIncomingPath, isAdvancedCustomIncomingPathAllowed, normalizeAdvancedCustomConfig, parseAdvancedCustomRouteModels, parseAdvancedCustomConfig, stringifyAdvancedCustomConfig, validateAdvancedCustomConfig, } from '../../lib/advanced-custom' import type { AdvancedCustomAuthType, AdvancedCustomConfig, AdvancedCustomConverter, AdvancedCustomRoute, } from '../../types' type AdvancedCustomEditorDialogProps = { open: boolean value: string onOpenChange: (open: boolean) => void onSave: (value: string) => void } type AdvancedCustomEditMode = 'visual' | 'json' const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]' const longSelectItemClass = 'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal' const routeEditorGridClassName = 'lg:grid-cols-[6rem_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,0.85fr)_7rem]' const upstreamPathDescriptionKey = 'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.' const catchAllOrderErrorMessage = 'Catch-all route must be last for the same incoming path' const emptyAdvancedRoutes: AdvancedCustomRoute[] = [] type AdvancedCustomRouteRow = { route: AdvancedCustomRoute routeKey: string index: number } type AdvancedCustomRouteGroup = { incomingPath: string routeRows: AdvancedCustomRouteRow[] } function getOptionLabel( options: ReadonlyArray<{ value: string; label: string }>, value: string ) { return options.find((option) => option.value === value)?.label || value } function getRouteIncomingPath(route: AdvancedCustomRoute): string { return (route.incoming_path || '').trim() } function isCatchAllRoute(route: AdvancedCustomRoute): boolean { return !route.models || route.models.length === 0 } function buildRouteGroups( routeRows: AdvancedCustomRouteRow[] ): AdvancedCustomRouteGroup[] { const groups: AdvancedCustomRouteGroup[] = [] const groupByPath = new Map() for (const routeRow of routeRows) { const incomingPath = getRouteIncomingPath(routeRow.route) let group = groupByPath.get(incomingPath) if (!group) { group = { incomingPath, routeRows: [] } groupByPath.set(incomingPath, group) groups.push(group) } group.routeRows.push(routeRow) } return groups } export function AdvancedCustomEditorDialog({ open, value, onOpenChange, onSave, }: AdvancedCustomEditorDialogProps) { const { t } = useTranslation() const routeKeyCounterRef = useRef(0) const [config, setConfig] = useState( () => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() ) const [routeKeys, setRouteKeys] = useState(() => { const initialConfig = parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() const normalized = normalizeAdvancedCustomConfig(initialConfig) return (normalized.advanced_routes || []).map( (_, routeIndex) => `advanced-custom-route-initial-${routeIndex}` ) }) const [editMode, setEditMode] = useState('visual') const [jsonText, setJsonText] = useState(() => stringifyAdvancedCustomConfig( parseAdvancedCustomConfig(value) || createAdvancedCustomConfig() ) ) const [jsonError, setJsonError] = useState('') const [templateKey, setTemplateKey] = useState( ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || '' ) const templateLabel = useMemo( () => getOptionLabel(ADVANCED_CUSTOM_TEMPLATE_OPTIONS, templateKey), [templateKey] ) const normalizedConfig = useMemo( () => normalizeAdvancedCustomConfig(config), [config] ) const routes = normalizedConfig.advanced_routes || emptyAdvancedRoutes const routeRows = useMemo( () => routes.map((route, index) => ({ route, index, routeKey: routeKeys.at(index) || route.incoming_path || route.upstream_path || route.converter || 'advanced-custom-route', })), [routeKeys, routes] ) const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows]) const usedIncomingPaths = useMemo( () => new Set(routeGroups.map((routeGroup) => routeGroup.incomingPath)), [routeGroups] ) const availableIncomingPathOptions = useMemo( () => ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter( (option) => !usedIncomingPaths.has(option.value) ), [usedIncomingPaths] ) const validationError = useMemo( () => validateAdvancedCustomConfig(normalizedConfig), [normalizedConfig] ) const canFixCatchAllOrder = validationError?.message === catchAllOrderErrorMessage const createRouteKey = () => { routeKeyCounterRef.current += 1 return `advanced-custom-route-${routeKeyCounterRef.current}` } const createRouteKeys = (count: number) => Array.from({ length: count }, () => createRouteKey()) const updateRoute = (index: number, patch: Partial) => { setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) const nextRoutes = [...(next.advanced_routes || [])] nextRoutes[index] = { ...nextRoutes[index], ...patch } return { ...next, advanced_routes: nextRoutes } }) } const replaceRoutes = ( nextRoutes: AdvancedCustomRoute[], nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) ) => { setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) return { ...next, advanced_routes: nextRoutes } }) setRouteKeys(nextRouteKeys) } const addRoute = (incomingPath: string | null) => { if (!incomingPath || usedIncomingPaths.has(incomingPath)) return setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) return { ...next, advanced_routes: [ ...(next.advanced_routes || []), { ...createAdvancedCustomRoute(), incoming_path: incomingPath, upstream_path: incomingPath, }, ], } }) setRouteKeys((current) => [...current, createRouteKey()]) } const addRouteForIncomingPath = (incomingPath: string) => { const resolvedIncomingPath = incomingPath || '/v1/chat/completions' setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) return { ...next, advanced_routes: [ ...(next.advanced_routes || []), { ...createAdvancedCustomRoute(), incoming_path: resolvedIncomingPath, upstream_path: resolvedIncomingPath, }, ], } }) setRouteKeys((current) => [...current, createRouteKey()]) } const removeRoute = (index: number) => { setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) return { ...next, advanced_routes: (next.advanced_routes || []).filter( (_, routeIndex) => routeIndex !== index ), } }) setRouteKeys((current) => current.filter((_, routeIndex) => routeIndex !== index) ) } const updateGroupIncomingPath = ( group: AdvancedCustomRouteGroup, nextIncomingPath: string | null ) => { const resolvedIncomingPath = nextIncomingPath || '/v1/chat/completions' const groupRouteIndexes = new Set( group.routeRows.map((routeRow) => routeRow.index) ) const nextRoutes = routes.map((route, routeIndex) => { if (!groupRouteIndexes.has(routeIndex)) return route if (resolvedIncomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH) { return { ...route, incoming_path: resolvedIncomingPath, upstream_path: ADVANCED_CUSTOM_MODEL_LIST_PATH, converter: 'none' as const, models: [], } } const converter = route.converter || 'none' return { ...route, incoming_path: resolvedIncomingPath, converter: isAdvancedCustomIncomingPathAllowed( resolvedIncomingPath, converter ) ? converter : 'none', } }) replaceRoutes(nextRoutes) } const swapRoutes = (fromIndex: number, toIndex: number) => { if (fromIndex === toIndex) return const nextRoutes = [...routes] const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) const fromRoute = nextRoutes[fromIndex] nextRoutes[fromIndex] = nextRoutes[toIndex] nextRoutes[toIndex] = fromRoute const fromRouteKey = nextRouteKeys[fromIndex] nextRouteKeys[fromIndex] = nextRouteKeys[toIndex] nextRouteKeys[toIndex] = fromRouteKey replaceRoutes(nextRoutes, nextRouteKeys) } const moveRouteWithinGroup = (index: number, direction: -1 | 1) => { const incomingPath = getRouteIncomingPath(routes[index]) const samePathIndexes = routes .map((route, routeIndex) => ({ route, routeIndex })) .filter(({ route }) => getRouteIncomingPath(route) === incomingPath) .map(({ routeIndex }) => routeIndex) const position = samePathIndexes.indexOf(index) const nextIndex = samePathIndexes.at(position + direction) if (nextIndex === undefined) return swapRoutes(index, nextIndex) } const moveRouteToGroupEnd = (index: number) => { const incomingPath = getRouteIncomingPath(routes[index]) let lastSamePathIndex = -1 for (let routeIndex = routes.length - 1; routeIndex >= 0; routeIndex -= 1) { if (getRouteIncomingPath(routes[routeIndex]) === incomingPath) { lastSamePathIndex = routeIndex break } } if (lastSamePathIndex < 0 || index === lastSamePathIndex) return const nextRoutes = [...routes] const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) const [route] = nextRoutes.splice(index, 1) const [routeKey] = nextRouteKeys.splice(index, 1) nextRoutes.splice(lastSamePathIndex, 0, route) nextRouteKeys.splice(lastSamePathIndex, 0, routeKey) replaceRoutes(nextRoutes, nextRouteKeys) } const fixCatchAllOrder = () => { const routeRowsByPath = new Map() for (const routeRow of routeRows) { const incomingPath = getRouteIncomingPath(routeRow.route) routeRowsByPath.set(incomingPath, [ ...(routeRowsByPath.get(incomingPath) || []), routeRow, ]) } const orderedRowsByPath = new Map() for (const [incomingPath, rows] of routeRowsByPath) { orderedRowsByPath.set(incomingPath, [ ...rows.filter((routeRow) => !isCatchAllRoute(routeRow.route)), ...rows.filter((routeRow) => isCatchAllRoute(routeRow.route)), ]) } const nextRows = routeRows.map((routeRow) => { const incomingPath = getRouteIncomingPath(routeRow.route) const orderedRows = orderedRowsByPath.get(incomingPath) return orderedRows?.shift() || routeRow }) replaceRoutes( nextRows.map((routeRow) => routeRow.route), nextRows.map((routeRow) => routeRow.routeKey) ) } const parseJsonEditorConfig = (): AdvancedCustomConfig | null => { const parsed = parseAdvancedCustomConfig(jsonText) if (!parsed) { setJsonError(t('Invalid JSON')) return null } const error = validateAdvancedCustomConfig(parsed) if (error) { setJsonError(t(error.message)) return null } setJsonError('') return parsed } const switchToVisualMode = () => { const parsed = parseJsonEditorConfig() if (!parsed) return const normalized = normalizeAdvancedCustomConfig(parsed) setConfig(normalized) setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0)) setEditMode('visual') } const switchToJsonMode = () => { setJsonText(stringifyAdvancedCustomConfig(normalizedConfig)) setJsonError('') setEditMode('json') } const handleJsonChange = (nextValue: string) => { setJsonText(nextValue) if (jsonError) setJsonError('') } const applyTemplate = (mode: 'fill' | 'append') => { const templateConfig = getAdvancedCustomTemplateConfig(templateKey) let nextConfig = templateConfig if (mode === 'append') { const baseConfig = editMode === 'json' ? parseJsonEditorConfig() : normalizedConfig if (!baseConfig) return const base = normalizeAdvancedCustomConfig(baseConfig) const template = normalizeAdvancedCustomConfig(templateConfig) nextConfig = { advanced_routes: [ ...(base.advanced_routes || []), ...(template.advanced_routes || []), ], } } const normalized = normalizeAdvancedCustomConfig(nextConfig) setConfig(normalized) setRouteKeys(createRouteKeys(normalized.advanced_routes?.length || 0)) setJsonText(stringifyAdvancedCustomConfig(normalized)) setJsonError('') } const saveConfig = () => { if (editMode === 'json') { const parsed = parseJsonEditorConfig() if (!parsed) { toast.error(t('Please fix JSON errors before saving')) return } onSave(stringifyAdvancedCustomConfig(parsed)) onOpenChange(false) return } if (validationError) { toast.error(t(validationError.message)) return } onSave(stringifyAdvancedCustomConfig(normalizedConfig)) onOpenChange(false) } return ( } >
{t('Mode')}
{t('Template')}
{editMode === 'visual' ? (
{validationError ? ( {validationError.routeIndex !== undefined ? `${t('Route')} ${validationError.routeIndex + 1}: ` : ''} {t(validationError.message)} {canFixCatchAllOrder ? ( ) : null} ) : null}

{t(upstreamPathDescriptionKey)}

{routeGroups.map((routeGroup) => ( addRouteForIncomingPath(routeGroup.incomingPath) } onIncomingPathChange={(nextIncomingPath) => updateGroupIncomingPath(routeGroup, nextIncomingPath) } onMoveRoute={(index, direction) => moveRouteWithinGroup(index, direction) } onMoveRouteToEnd={moveRouteToGroupEnd} onRemoveRoute={removeRoute} onRouteChange={updateRoute} /> ))}
) : (
{t('Advanced text editing')}

{t('Edit JSON text directly. Format will be validated on save.')}

{jsonError ? (

{jsonError}

) : null}
)}
) } function RouteGroupEditor({ group, usedIncomingPaths, validationError, onAddRoute, onIncomingPathChange, onMoveRoute, onMoveRouteToEnd, onRemoveRoute, onRouteChange, }: { group: AdvancedCustomRouteGroup usedIncomingPaths: ReadonlySet validationError: ReturnType onAddRoute: () => void onIncomingPathChange: (incomingPath: string | null) => void onMoveRoute: (index: number, direction: -1 | 1) => void onMoveRouteToEnd: (index: number) => void onRemoveRoute: (index: number) => void onRouteChange: (index: number, patch: Partial) => void }) { const { t } = useTranslation() const incomingPath = group.incomingPath || '/v1/chat/completions' const isModelListGroup = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath) const catchAllRoute = group.routeRows.find((routeRow) => isCatchAllRoute(routeRow.route) ) const catchAllRoutePosition = catchAllRoute ? group.routeRows.findIndex( (routeRow) => routeRow.index === catchAllRoute.index ) : -1 const hasCatchAll = catchAllRoute !== undefined const catchAllIsLast = !hasCatchAll || catchAllRoutePosition === group.routeRows.length - 1 const groupHasError = validationError?.routeIndex !== undefined && group.routeRows.some( (routeRow) => routeRow.index === validationError.routeIndex ) return (
{t('Route group')} {group.routeRows.length} {t('Routes')} {isModelListGroup ? ( {ADVANCED_CUSTOM_MODEL_LIST_LABEL} ) : ( {hasCatchAll ? t('Fallback route') : t('Model-scoped only')} )} {!isModelListGroup && !catchAllIsLast ? ( {t('Fallback must be last')} ) : null}
{!isModelListGroup ? ( ) : null}

{isModelListGroup ? t( 'This route discovers upstream OpenAI models and cannot be split or matched by client model rules.' ) : t( 'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.' )}

{groupHasError && validationError ? (

{validationError.routeIndex !== undefined ? `${t('Route')} ${validationError.routeIndex + 1}: ` : ''} {t(validationError.message)}

) : null}
{group.routeRows.map((routeRow, position) => { const canMoveUp = position > 0 const canMoveDown = position < group.routeRows.length - 1 const catchAllOutOfOrder = isCatchAllRoute(routeRow.route) && canMoveDown const routeErrorMessage = validationError?.routeIndex === routeRow.index ? validationError.message : undefined return ( onRouteChange(routeRow.index, patch)} onMoveDown={() => onMoveRoute(routeRow.index, 1)} onMoveUp={() => onMoveRoute(routeRow.index, -1)} onMoveCatchAllToEnd={() => onMoveRouteToEnd(routeRow.index)} onRemove={() => onRemoveRoute(routeRow.index)} /> ) })}
) } function RouteEditor({ route, index, errorMessage, canMoveUp, canMoveDown, catchAllOutOfOrder, onChange, onMoveUp, onMoveDown, onMoveCatchAllToEnd, onRemove, }: { route: AdvancedCustomRoute index: number errorMessage?: string canMoveUp: boolean canMoveDown: boolean catchAllOutOfOrder: boolean onChange: (patch: Partial) => void onMoveUp: () => void onMoveDown: () => void onMoveCatchAllToEnd: () => void onRemove: () => void }) { const { t } = useTranslation() const converter = route.converter || 'none' const authMode = getAdvancedCustomAuthMode(route) const incomingPath = route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter) const isModelListRoute = incomingPath === ADVANCED_CUSTOM_MODEL_LIST_PATH const converterOptions = useMemo( () => getAdvancedCustomConverterOptions(incomingPath), [incomingPath] ) const converterLabel = getOptionLabel( ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter ) const converterTriggerLabel = ADVANCED_CUSTOM_CONVERTER_OPTIONS.find( (option) => option.value === converter )?.triggerLabel || converterLabel const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode) const isNativeConverter = converter === 'none' const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle const modelsInputValue = route.models?.join(', ') || '' const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue) const isFallback = !isModelListRoute && parsedRouteModels.length === 0 const setConverter = (nextConverter: AdvancedCustomConverter) => { let nextIncomingPath = incomingPath if (!isAdvancedCustomIncomingPathAllowed(nextIncomingPath, nextConverter)) { nextIncomingPath = getDefaultAdvancedCustomIncomingPath(nextConverter) } const defaults = getAdvancedCustomConverterDefaults( nextConverter, nextIncomingPath ) onChange({ converter: nextConverter, incoming_path: nextIncomingPath, upstream_path: defaults.upstream_path, auth: defaults.auth, }) } const setAuthMode = (mode: AdvancedCustomAuthMode) => { onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) }) } const setModelsInput = (value: string) => { onChange({ models: value === '' ? [] : value.split(','), }) } const normalizeModelsInput = (value: string) => { onChange({ models: parseAdvancedCustomRouteModels(value) }) } const updateAuth = ( field: Exclude, 'type'>, value: string ) => { const currentAuth = route.auth if (!currentAuth || currentAuth.type === 'none') return onChange({ auth: { type: currentAuth.type as AdvancedCustomAuthType, name: currentAuth.name || '', value: currentAuth.value || '', [field]: value, }, }) } return (
{t('Route')} {index + 1}
{isModelListRoute ? ( {ADVANCED_CUSTOM_MODEL_LIST_LABEL} ) : null} {!isModelListRoute && isFallback ? ( {t('Fallback')} ) : null} } > {t(converterLabel)}
{catchAllOutOfOrder ? ( ) : null}
{t('Client model')} } className='lg:gap-1' labelClassName='lg:sr-only' > {isModelListRoute && parsedRouteModels.length === 0 ? (
{ADVANCED_CUSTOM_MODEL_LIST_LABEL}
) : ( <> setModelsInput(event.target.value)} onBlur={(event) => normalizeModelsInput(event.target.value)} placeholder={ isFallback ? t('Leave empty for fallback') : t('e.g. gpt-4o, gemini-2.5-flash') } aria-invalid={Boolean(errorMessage)} />
{isFallback ? ( {t('Fallback')} ) : ( parsedRouteModels.map((model) => { const ruleKind = getAdvancedCustomModelRuleKind(model) const displayModel = ruleKind === 'regex' ? getAdvancedCustomRegexModelPattern(model) || model : model return ( {t(ruleKind === 'regex' ? 'Regex' : 'Exact')} {displayModel} ) }) )}
)}
onChange({ upstream_path: event.target.value, }) } placeholder={getAdvancedCustomUpstreamPathPlaceholder( converter, incomingPath )} />

{t(upstreamPathDescriptionKey)}

{catchAllOutOfOrder ? ( ) : null}
{errorMessage ? (

{t(errorMessage)}

) : null} {authMode === 'header' || authMode === 'query' ? ( <>
) : null}
) } function ModelRuleHelpPopover() { const { t } = useTranslation() return ( } > {t('Client model matching')} {t( 'Rules match the original model value from the client request body.' )}

{t( 'Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.' )}

{t( 'Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.' )}

{t( 'Leave the final split empty as the fallback for models not matched above.' )}

) } function TooltipIconButton({ label, icon: Icon, disabled, onClick, }: { label: string icon: LucideIcon disabled?: boolean onClick: () => void }) { return ( } > {label} ) } function FieldBlock({ label, className, labelClassName, children, }: { label: ReactNode className?: string labelClassName?: string children: ReactNode }) { return (
{label} {children}
) }