feat: advanced custom channel (#5590)
This commit is contained in:
+674
@@ -0,0 +1,674 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type ReactNode, useMemo, useState } from 'react'
|
||||
import { Check, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
ADVANCED_CUSTOM_AUTH_MODE_OPTIONS,
|
||||
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
||||
ADVANCED_CUSTOM_TEMPLATE_OPTIONS,
|
||||
type AdvancedCustomAuthMode,
|
||||
buildAdvancedCustomAuth,
|
||||
createAdvancedCustomConfig,
|
||||
createAdvancedCustomRoute,
|
||||
getAdvancedCustomAuthMode,
|
||||
getAdvancedCustomIncomingPathLabel,
|
||||
getAdvancedCustomIncomingPathOptions,
|
||||
getAdvancedCustomTemplateConfig,
|
||||
getAdvancedCustomUpstreamPathPlaceholder,
|
||||
getDefaultAdvancedCustomIncomingPath,
|
||||
isAdvancedCustomIncomingPathAllowed,
|
||||
normalizeAdvancedCustomConfig,
|
||||
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'
|
||||
|
||||
function getOptionLabel(
|
||||
options: ReadonlyArray<{ value: string; label: string }>,
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label || value
|
||||
}
|
||||
|
||||
export function AdvancedCustomEditorDialog({
|
||||
open,
|
||||
value,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: AdvancedCustomEditorDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [config, setConfig] = useState<AdvancedCustomConfig>(
|
||||
() => parseAdvancedCustomConfig(value) || createAdvancedCustomConfig()
|
||||
)
|
||||
const [editMode, setEditMode] = useState<AdvancedCustomEditMode>('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 || []
|
||||
const validationError = useMemo(
|
||||
() => validateAdvancedCustomConfig(normalizedConfig),
|
||||
[normalizedConfig]
|
||||
)
|
||||
|
||||
const updateRoute = (index: number, patch: Partial<AdvancedCustomRoute>) => {
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
const nextRoutes = [...(next.advanced_routes || [])]
|
||||
nextRoutes[index] = { ...nextRoutes[index], ...patch }
|
||||
return { ...next, advanced_routes: nextRoutes }
|
||||
})
|
||||
}
|
||||
|
||||
const addRoute = () => {
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
return {
|
||||
...next,
|
||||
advanced_routes: [
|
||||
...(next.advanced_routes || []),
|
||||
createAdvancedCustomRoute(),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeRoute = (index: number) => {
|
||||
setConfig((current) => {
|
||||
const next = normalizeAdvancedCustomConfig(current)
|
||||
return {
|
||||
...next,
|
||||
advanced_routes: (next.advanced_routes || []).filter(
|
||||
(_, routeIndex) => routeIndex !== index
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setFallbackEnabled = (enabled: boolean) => {
|
||||
setConfig((current) => ({
|
||||
...normalizeAdvancedCustomConfig(current),
|
||||
advanced_fallback: { enabled },
|
||||
}))
|
||||
}
|
||||
|
||||
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
|
||||
setConfig(parsed)
|
||||
setEditMode('visual')
|
||||
}
|
||||
|
||||
const switchToJsonMode = () => {
|
||||
setJsonText(stringifyAdvancedCustomConfig(normalizedConfig))
|
||||
setJsonError('')
|
||||
setEditMode('json')
|
||||
}
|
||||
|
||||
const handleJsonChange = (nextValue: string) => {
|
||||
setJsonText(nextValue)
|
||||
if (jsonError) setJsonError('')
|
||||
}
|
||||
|
||||
const formatJson = () => {
|
||||
const parsed = parseJsonEditorConfig()
|
||||
if (!parsed) return
|
||||
setJsonText(stringifyAdvancedCustomConfig(parsed))
|
||||
}
|
||||
|
||||
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 || []),
|
||||
],
|
||||
advanced_fallback: {
|
||||
enabled:
|
||||
base.advanced_fallback?.enabled === true ||
|
||||
template.advanced_fallback?.enabled === true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeAdvancedCustomConfig(nextConfig)
|
||||
setConfig(normalized)
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('Advanced Custom Routes')}
|
||||
description={t('Advanced Custom')}
|
||||
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
|
||||
headerClassName='border-b px-6 py-4'
|
||||
footerClassName='border-t px-6 py-4'
|
||||
contentHeight='70vh'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='button' onClick={saveConfig}>
|
||||
<Check className='mr-2 h-4 w-4' />
|
||||
{t('Save changes')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='bg-muted/30 border-b px-4 py-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Mode')}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant={editMode === 'visual' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={switchToVisualMode}
|
||||
>
|
||||
{t('Visual')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant={editMode === 'json' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={switchToJsonMode}
|
||||
>
|
||||
{t('JSON Text')}
|
||||
</Button>
|
||||
|
||||
<div className='bg-border mx-1 h-5 w-px' />
|
||||
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Template')}
|
||||
</span>
|
||||
<Select
|
||||
value={templateKey}
|
||||
onValueChange={(nextValue) =>
|
||||
setTemplateKey(
|
||||
nextValue || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]?.value || ''
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='h-8 min-w-[260px] max-w-full flex-1 sm:w-[320px]'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{t(templateLabel)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
alignItemWithTrigger={false}
|
||||
className={longSelectContentClass}
|
||||
>
|
||||
<SelectGroup>
|
||||
{ADVANCED_CUSTOM_TEMPLATE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={longSelectItemClass}
|
||||
>
|
||||
<span className='min-w-0 whitespace-normal break-words leading-snug'>
|
||||
{t(option.label)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => applyTemplate('fill')}
|
||||
>
|
||||
{t('Fill Template')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => applyTemplate('append')}
|
||||
>
|
||||
{t('Append Template')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<div className='flex flex-col gap-5 p-4'>
|
||||
<div className='flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Switch
|
||||
checked={normalizedConfig.advanced_fallback?.enabled === true}
|
||||
onCheckedChange={setFallbackEnabled}
|
||||
/>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>
|
||||
{t('Fallback routing')}
|
||||
</div>
|
||||
<div className='text-muted-foreground max-w-2xl text-xs leading-relaxed'>
|
||||
{t(
|
||||
'When enabled, requests that do not match any advanced route are forwarded to the channel base URL. When disabled, unmatched requests return an error.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={addRoute}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add route')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{validationError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{validationError.routeIndex !== undefined
|
||||
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
||||
: ''}
|
||||
{t(validationError.message)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
{routes.map((route, index) => (
|
||||
<RouteEditor
|
||||
key={index}
|
||||
route={route}
|
||||
index={index}
|
||||
onChange={(patch) => updateRoute(index, patch)}
|
||||
onRemove={() => removeRoute(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={formatJson}
|
||||
>
|
||||
{t('Format')}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Advanced text editing')}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(event) => handleJsonChange(event.target.value)}
|
||||
placeholder={stringifyAdvancedCustomConfig(
|
||||
getAdvancedCustomTemplateConfig(templateKey)
|
||||
)}
|
||||
rows={22}
|
||||
className='min-h-[420px] font-mono text-xs'
|
||||
/>
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Edit JSON text directly. Format will be validated on save.')}
|
||||
</p>
|
||||
{jsonError ? (
|
||||
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteEditor({
|
||||
route,
|
||||
index,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
route: AdvancedCustomRoute
|
||||
index: number
|
||||
onChange: (patch: Partial<AdvancedCustomRoute>) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const converter = route.converter || 'none'
|
||||
const authMode = getAdvancedCustomAuthMode(route)
|
||||
const incomingPath =
|
||||
route.incoming_path || getDefaultAdvancedCustomIncomingPath(converter)
|
||||
const incomingPathOptions = useMemo(
|
||||
() => getAdvancedCustomIncomingPathOptions(converter),
|
||||
[converter]
|
||||
)
|
||||
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||
const converterLabel =
|
||||
getOptionLabel(ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter)
|
||||
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
||||
|
||||
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
||||
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
|
||||
if (!isAdvancedCustomIncomingPathAllowed(incomingPath, nextConverter)) {
|
||||
patch.incoming_path = getDefaultAdvancedCustomIncomingPath(nextConverter)
|
||||
}
|
||||
onChange(patch)
|
||||
}
|
||||
|
||||
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
|
||||
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
||||
}
|
||||
|
||||
const updateAuth = (
|
||||
field: Exclude<keyof NonNullable<AdvancedCustomRoute['auth']>, '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 (
|
||||
<div className='border-border flex flex-col gap-4 rounded-md border p-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='text-sm font-medium'>
|
||||
{t('Route')} {index + 1}
|
||||
</div>
|
||||
<Badge variant='secondary'>{t(converterLabel)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button type='button' variant='ghost' size='icon' onClick={onRemove}>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Delete')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldBlock label={t('Incoming path')}>
|
||||
<Select
|
||||
value={incomingPath}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
incoming_path:
|
||||
value || getDefaultAdvancedCustomIncomingPath(converter),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-full max-w-full'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{`${t(incomingPathLabel)} · ${incomingPath}`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
alignItemWithTrigger={false}
|
||||
className={longSelectContentClass}
|
||||
>
|
||||
<SelectGroup>
|
||||
{incomingPathOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={longSelectItemClass}
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-1 whitespace-normal leading-snug'>
|
||||
<span>{t(option.label)}</span>
|
||||
<span className='text-muted-foreground break-all font-mono text-xs'>
|
||||
{option.value}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldBlock>
|
||||
|
||||
<FieldBlock label={t('Upstream path')}>
|
||||
<Input
|
||||
value={route.upstream_path || ''}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
upstream_path: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||
{t(
|
||||
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
|
||||
)}
|
||||
</p>
|
||||
</FieldBlock>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldBlock label={t('Converter')}>
|
||||
<Select
|
||||
value={converter}
|
||||
onValueChange={(value) =>
|
||||
setConverter(value as AdvancedCustomConverter)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-full max-w-full'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{t(converterLabel)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
alignItemWithTrigger={false}
|
||||
className={longSelectContentClass}
|
||||
>
|
||||
<SelectGroup>
|
||||
{ADVANCED_CUSTOM_CONVERTER_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={longSelectItemClass}
|
||||
>
|
||||
<span className='min-w-0 whitespace-normal break-words leading-snug'>
|
||||
{t(option.label)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldBlock>
|
||||
|
||||
<FieldBlock label={t('Auth')}>
|
||||
<Select
|
||||
value={authMode}
|
||||
onValueChange={(value) =>
|
||||
setAuthMode(value as AdvancedCustomAuthMode)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-full max-w-full'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{t(authLabel)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{ADVANCED_CUSTOM_AUTH_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldBlock>
|
||||
</div>
|
||||
|
||||
{authMode === 'header' || authMode === 'query' ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldBlock label={t('Auth name')}>
|
||||
<Input
|
||||
value={route.auth?.name || ''}
|
||||
onChange={(event) => updateAuth('name', event.target.value)}
|
||||
placeholder={
|
||||
authMode === 'header' ? 'Authorization' : 'api_key'
|
||||
}
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t('Auth value')}>
|
||||
<Input
|
||||
value={route.auth?.value || ''}
|
||||
onChange={(event) => updateAuth('value', event.target.value)}
|
||||
placeholder={
|
||||
authMode === 'header' ? 'Bearer {api_key}' : '{api_key}'
|
||||
}
|
||||
/>
|
||||
</FieldBlock>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldBlock({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-2'>
|
||||
<span className='text-sm font-medium'>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+82
@@ -125,8 +125,10 @@ import {
|
||||
import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form'
|
||||
import {
|
||||
CHANNEL_FORM_DEFAULT_VALUES,
|
||||
CHANNEL_TYPE_ADVANCED_CUSTOM,
|
||||
channelFormSchema,
|
||||
channelsQueryKeys,
|
||||
getAdvancedCustomStats,
|
||||
transformChannelToFormDefaults,
|
||||
type ChannelFormValues,
|
||||
deduplicateKeys,
|
||||
@@ -147,6 +149,7 @@ import {
|
||||
} from '../../lib/status-code-risk-guard'
|
||||
import type { Channel } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import { AdvancedCustomEditorDialog } from '../dialogs/advanced-custom-editor-dialog'
|
||||
import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
|
||||
import {
|
||||
MissingModelsConfirmationDialog,
|
||||
@@ -205,6 +208,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
|
||||
return Boolean(
|
||||
values.param_override?.trim() ||
|
||||
values.header_override?.trim() ||
|
||||
values.advanced_custom?.trim() ||
|
||||
values.status_code_mapping?.trim() ||
|
||||
values.tag?.trim() ||
|
||||
values.remark?.trim() ||
|
||||
@@ -298,6 +302,8 @@ export function ChannelMutateDrawer({
|
||||
>(null)
|
||||
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false)
|
||||
const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false)
|
||||
const [advancedCustomEditorOpen, setAdvancedCustomEditorOpen] =
|
||||
useState(false)
|
||||
|
||||
const isEditing = Boolean(currentRow)
|
||||
const channelId = currentRow?.id ?? null
|
||||
@@ -375,6 +381,7 @@ export function ChannelMutateDrawer({
|
||||
'upstream_model_update_check_enabled'
|
||||
)
|
||||
const currentSettings = form.watch('settings')
|
||||
const currentAdvancedCustom = form.watch('advanced_custom')
|
||||
const {
|
||||
unlocked: doubaoApiEditUnlocked,
|
||||
handleClick: handleApiConfigSecretClick,
|
||||
@@ -407,6 +414,11 @@ export function ChannelMutateDrawer({
|
||||
[supportsMultiKeyAddMode]
|
||||
)
|
||||
|
||||
const advancedCustomStats = useMemo(
|
||||
() => getAdvancedCustomStats(currentAdvancedCustom),
|
||||
[currentAdvancedCustom]
|
||||
)
|
||||
|
||||
// Get all models list
|
||||
const allModelsList = useMemo(
|
||||
() => allModelsData?.data?.map((model) => model.id).filter(Boolean) || [],
|
||||
@@ -1817,6 +1829,62 @@ export function ChannelMutateDrawer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='advanced_custom'
|
||||
render={({ field }) => (
|
||||
<FormItem className='space-y-3 border-y py-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<FormLabel>
|
||||
{t('Advanced Custom Routes')}
|
||||
</FormLabel>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge variant='secondary'>
|
||||
{t('Routes')}:{' '}
|
||||
{advancedCustomStats.routeCount}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
advancedCustomStats.fallbackEnabled
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{t('Fallback')}:{' '}
|
||||
{advancedCustomStats.fallbackEnabled
|
||||
? t('Enabled')
|
||||
: t('Disabled')}
|
||||
</Badge>
|
||||
{!advancedCustomStats.valid && (
|
||||
<Badge variant='destructive'>
|
||||
{t('Incomplete')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setAdvancedCustomEditorOpen(true)
|
||||
}
|
||||
>
|
||||
<Route className='mr-2 h-4 w-4' />
|
||||
{t('Configure routes')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<input type='hidden' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChannelAuthSection>
|
||||
{!isEditing && (
|
||||
<FormField
|
||||
@@ -3423,6 +3491,20 @@ export function ChannelMutateDrawer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{advancedCustomEditorOpen && (
|
||||
<AdvancedCustomEditorDialog
|
||||
open={advancedCustomEditorOpen}
|
||||
value={form.watch('advanced_custom') || ''}
|
||||
onOpenChange={setAdvancedCustomEditorOpen}
|
||||
onSave={(nextValue) => {
|
||||
form.setValue('advanced_custom', nextValue, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fetch Models Dialog */}
|
||||
<FetchModelsDialog
|
||||
open={fetchModelsDialogOpen}
|
||||
|
||||
+3
-2
@@ -76,12 +76,13 @@ export const CHANNEL_TYPES = {
|
||||
55: 'Sora',
|
||||
56: 'Replicate',
|
||||
57: 'ChatGPT Subscription (Codex)',
|
||||
58: 'Advanced Custom',
|
||||
} as const
|
||||
|
||||
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
|
||||
1, 14, 33, 24, 43, 3, 41, 48, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23,
|
||||
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36, 50,
|
||||
51, 52, 53, 54, 55, 56,
|
||||
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 58, 57, 22, 21, 44, 2, 5, 36,
|
||||
50, 51, 52, 53, 54, 55, 56,
|
||||
]
|
||||
|
||||
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type {
|
||||
AdvancedCustomAuthType,
|
||||
AdvancedCustomConfig,
|
||||
AdvancedCustomConverter,
|
||||
AdvancedCustomRoute,
|
||||
AdvancedCustomRouteAuth,
|
||||
} from '../types'
|
||||
|
||||
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
|
||||
|
||||
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
||||
value: AdvancedCustomConverter
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'none', label: 'Native forwarding' },
|
||||
{
|
||||
value: 'anthropic_messages_to_openai_chat_completions',
|
||||
label: 'Anthropic Messages to OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_anthropic_messages',
|
||||
label: 'OpenAI Chat to Anthropic Messages',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_openai_responses',
|
||||
label: 'OpenAI Chat to OpenAI Responses',
|
||||
},
|
||||
{
|
||||
value: 'gemini_generate_content_to_openai_chat_completions',
|
||||
label: 'Gemini Generate Content to OpenAI Chat',
|
||||
},
|
||||
{
|
||||
value: 'openai_chat_completions_to_gemini_generate_content',
|
||||
label: 'OpenAI Chat to Gemini Generate Content',
|
||||
},
|
||||
]
|
||||
|
||||
export type AdvancedCustomAuthMode = 'default' | AdvancedCustomAuthType
|
||||
|
||||
export const ADVANCED_CUSTOM_AUTH_MODE_OPTIONS: Array<{
|
||||
value: AdvancedCustomAuthMode
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'default', label: 'Default Bearer' },
|
||||
{ value: 'none', label: 'No Auth' },
|
||||
{ value: 'header', label: 'Header' },
|
||||
{ value: 'query', label: 'Query' },
|
||||
]
|
||||
|
||||
export type AdvancedCustomIncomingPathOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOption[] =
|
||||
[
|
||||
{
|
||||
value: '/v1/chat/completions',
|
||||
label: 'OpenAI Chat Completions',
|
||||
},
|
||||
{
|
||||
value: '/v1/completions',
|
||||
label: 'OpenAI Completions',
|
||||
},
|
||||
{
|
||||
value: '/v1/responses',
|
||||
label: 'OpenAI Responses',
|
||||
},
|
||||
{
|
||||
value: '/v1/responses/compact',
|
||||
label: 'OpenAI Responses Compact',
|
||||
},
|
||||
{
|
||||
value: '/v1/embeddings',
|
||||
label: 'OpenAI Embeddings',
|
||||
},
|
||||
{
|
||||
value: '/v1/images/generations',
|
||||
label: 'OpenAI Image Generations',
|
||||
},
|
||||
{
|
||||
value: '/v1/images/edits',
|
||||
label: 'OpenAI Image Edits',
|
||||
},
|
||||
{
|
||||
value: '/v1/audio/speech',
|
||||
label: 'OpenAI Audio Speech',
|
||||
},
|
||||
{
|
||||
value: '/v1/audio/transcriptions',
|
||||
label: 'OpenAI Audio Transcriptions',
|
||||
},
|
||||
{
|
||||
value: '/v1/audio/translations',
|
||||
label: 'OpenAI Audio Translations',
|
||||
},
|
||||
{
|
||||
value: '/v1/rerank',
|
||||
label: 'OpenAI Rerank',
|
||||
},
|
||||
{
|
||||
value: '/v1/realtime',
|
||||
label: 'OpenAI Realtime',
|
||||
},
|
||||
{
|
||||
value: '/v1/messages',
|
||||
label: 'Claude Messages',
|
||||
},
|
||||
{
|
||||
value: '/v1beta/models/{model}:generateContent',
|
||||
label: 'Gemini Generate Content',
|
||||
},
|
||||
{
|
||||
value: '/v1beta/models/{model}:embedContent',
|
||||
label: 'Gemini Embed Content',
|
||||
},
|
||||
{
|
||||
value: '/v1beta/models/{model}:batchEmbedContents',
|
||||
label: 'Gemini Batch Embed Contents',
|
||||
},
|
||||
]
|
||||
|
||||
export type AdvancedCustomValidationError = {
|
||||
message: string
|
||||
routeIndex?: number
|
||||
}
|
||||
|
||||
export type AdvancedCustomTemplateOption = {
|
||||
value: string
|
||||
label: string
|
||||
config: AdvancedCustomConfig
|
||||
}
|
||||
|
||||
const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({
|
||||
type: 'header',
|
||||
name: 'Authorization',
|
||||
value: 'Bearer {api_key}',
|
||||
})
|
||||
|
||||
const apiKeyHeaderAuth = (): AdvancedCustomRouteAuth => ({
|
||||
type: 'header',
|
||||
name: 'x-api-key',
|
||||
value: '{api_key}',
|
||||
})
|
||||
|
||||
const geminiQueryAuth = (): AdvancedCustomRouteAuth => ({
|
||||
type: 'query',
|
||||
name: 'key',
|
||||
value: '{api_key}',
|
||||
})
|
||||
|
||||
export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
|
||||
[
|
||||
{
|
||||
value: 'official_openai_chat',
|
||||
label: 'Official OpenAI Chat',
|
||||
config: {
|
||||
advanced_routes: [
|
||||
{
|
||||
incoming_path: '/v1/chat/completions',
|
||||
upstream_path: 'https://api.openai.com/v1/chat/completions',
|
||||
converter: 'none',
|
||||
auth: bearerHeaderAuth(),
|
||||
},
|
||||
],
|
||||
advanced_fallback: { enabled: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'official_openai_responses',
|
||||
label: 'Official OpenAI Responses',
|
||||
config: {
|
||||
advanced_routes: [
|
||||
{
|
||||
incoming_path: '/v1/responses',
|
||||
upstream_path: 'https://api.openai.com/v1/responses',
|
||||
converter: 'none',
|
||||
auth: bearerHeaderAuth(),
|
||||
},
|
||||
],
|
||||
advanced_fallback: { enabled: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'official_openai_embeddings',
|
||||
label: 'Official OpenAI Embeddings',
|
||||
config: {
|
||||
advanced_routes: [
|
||||
{
|
||||
incoming_path: '/v1/embeddings',
|
||||
upstream_path: 'https://api.openai.com/v1/embeddings',
|
||||
converter: 'none',
|
||||
auth: bearerHeaderAuth(),
|
||||
},
|
||||
],
|
||||
advanced_fallback: { enabled: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'official_openai_images',
|
||||
label: 'Official OpenAI Images',
|
||||
config: {
|
||||
advanced_routes: [
|
||||
{
|
||||
incoming_path: '/v1/images/generations',
|
||||
upstream_path: 'https://api.openai.com/v1/images/generations',
|
||||
converter: 'none',
|
||||
auth: bearerHeaderAuth(),
|
||||
},
|
||||
{
|
||||
incoming_path: '/v1/images/edits',
|
||||
upstream_path: 'https://api.openai.com/v1/images/edits',
|
||||
converter: 'none',
|
||||
auth: bearerHeaderAuth(),
|
||||
},
|
||||
],
|
||||
advanced_fallback: { enabled: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'official_claude_messages',
|
||||
label: 'Official Claude Messages',
|
||||
config: {
|
||||
advanced_routes: [
|
||||
{
|
||||
incoming_path: '/v1/messages',
|
||||
upstream_path: 'https://api.anthropic.com/v1/messages',
|
||||
converter: 'none',
|
||||
auth: apiKeyHeaderAuth(),
|
||||
},
|
||||
],
|
||||
advanced_fallback: { enabled: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'official_gemini_native',
|
||||
label: 'Official Gemini Native',
|
||||
config: {
|
||||
advanced_routes: [
|
||||
{
|
||||
incoming_path: '/v1beta/models/{model}:generateContent',
|
||||
upstream_path:
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
|
||||
converter: 'none',
|
||||
auth: geminiQueryAuth(),
|
||||
},
|
||||
{
|
||||
incoming_path: '/v1beta/models/{model}:embedContent',
|
||||
upstream_path:
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent',
|
||||
converter: 'none',
|
||||
auth: geminiQueryAuth(),
|
||||
},
|
||||
{
|
||||
incoming_path: '/v1beta/models/{model}:batchEmbedContents',
|
||||
upstream_path:
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/{model}:batchEmbedContents',
|
||||
converter: 'none',
|
||||
auth: geminiQueryAuth(),
|
||||
},
|
||||
],
|
||||
advanced_fallback: { enabled: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'official_gemini_from_openai_chat',
|
||||
label: 'Official Gemini from OpenAI Chat',
|
||||
config: {
|
||||
advanced_routes: [
|
||||
{
|
||||
incoming_path: '/v1/chat/completions',
|
||||
upstream_path:
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
|
||||
converter: 'openai_chat_completions_to_gemini_generate_content',
|
||||
auth: geminiQueryAuth(),
|
||||
},
|
||||
],
|
||||
advanced_fallback: { enabled: false },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function cloneAdvancedCustomConfig(
|
||||
config: AdvancedCustomConfig
|
||||
): AdvancedCustomConfig {
|
||||
return JSON.parse(JSON.stringify(config)) as AdvancedCustomConfig
|
||||
}
|
||||
|
||||
export function getAdvancedCustomTemplateConfig(
|
||||
templateKey: string
|
||||
): AdvancedCustomConfig {
|
||||
const template =
|
||||
ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find(
|
||||
(option) => option.value === templateKey
|
||||
) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]
|
||||
return cloneAdvancedCustomConfig(template.config)
|
||||
}
|
||||
|
||||
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
|
||||
return {
|
||||
incoming_path: '/v1/chat/completions',
|
||||
upstream_path: '/v1/chat/completions',
|
||||
converter: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdvancedCustomConfig(): AdvancedCustomConfig {
|
||||
return {
|
||||
advanced_routes: [createAdvancedCustomRoute()],
|
||||
advanced_fallback: { enabled: false },
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdvancedCustomUpstreamPathPlaceholder(
|
||||
converter: AdvancedCustomConverter
|
||||
): string {
|
||||
if (converter === 'openai_chat_completions_to_gemini_generate_content') {
|
||||
return '/v1beta/models/{model}:generateContent'
|
||||
}
|
||||
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
||||
return '/v1/messages'
|
||||
}
|
||||
return '/v1/chat/completions'
|
||||
}
|
||||
|
||||
export function getAdvancedCustomIncomingPathOptions(
|
||||
converter: AdvancedCustomConverter
|
||||
): AdvancedCustomIncomingPathOption[] {
|
||||
return ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter((option) =>
|
||||
isConverterPathAllowed(option.value, converter)
|
||||
)
|
||||
}
|
||||
|
||||
export function getDefaultAdvancedCustomIncomingPath(
|
||||
converter: AdvancedCustomConverter
|
||||
): string {
|
||||
return (
|
||||
getAdvancedCustomIncomingPathOptions(converter)[0]?.value ||
|
||||
'/v1/chat/completions'
|
||||
)
|
||||
}
|
||||
|
||||
export function isAdvancedCustomIncomingPathAllowed(
|
||||
incomingPath: string,
|
||||
converter: AdvancedCustomConverter
|
||||
): boolean {
|
||||
return getAdvancedCustomIncomingPathOptions(converter).some(
|
||||
(option) => option.value === incomingPath
|
||||
)
|
||||
}
|
||||
|
||||
export function getAdvancedCustomIncomingPathLabel(value: string): string {
|
||||
return (
|
||||
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.find(
|
||||
(option) => option.value === value
|
||||
)?.label || value
|
||||
)
|
||||
}
|
||||
|
||||
export function parseAdvancedCustomConfig(
|
||||
value: string | undefined
|
||||
): AdvancedCustomConfig | null {
|
||||
if (!value?.trim()) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return normalizeAdvancedCustomConfig(parsed as AdvancedCustomConfig)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyAdvancedCustomConfig(
|
||||
config: AdvancedCustomConfig
|
||||
): string {
|
||||
return JSON.stringify(normalizeAdvancedCustomConfig(config), null, 2)
|
||||
}
|
||||
|
||||
export function normalizeAdvancedCustomConfig(
|
||||
config: AdvancedCustomConfig
|
||||
): AdvancedCustomConfig {
|
||||
const routes = Array.isArray(config.advanced_routes)
|
||||
? config.advanced_routes.map(normalizeAdvancedCustomRoute)
|
||||
: []
|
||||
|
||||
return {
|
||||
advanced_routes: routes,
|
||||
advanced_fallback: {
|
||||
enabled: config.advanced_fallback?.enabled === true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAdvancedCustomConfig(
|
||||
config: AdvancedCustomConfig | null
|
||||
): AdvancedCustomValidationError | null {
|
||||
if (!config) {
|
||||
return { message: 'Advanced custom configuration is required' }
|
||||
}
|
||||
|
||||
const normalized = normalizeAdvancedCustomConfig(config)
|
||||
const routes = normalized.advanced_routes || []
|
||||
const fallbackEnabled = normalized.advanced_fallback?.enabled === true
|
||||
if (routes.length === 0 && !fallbackEnabled) {
|
||||
return {
|
||||
message:
|
||||
'Advanced custom configuration requires at least one route or fallback',
|
||||
}
|
||||
}
|
||||
|
||||
const seenPaths = new Set<string>()
|
||||
for (let index = 0; index < routes.length; index += 1) {
|
||||
const route = routes[index]
|
||||
const incomingPath = route.incoming_path?.trim() || ''
|
||||
const upstreamPath = getAdvancedCustomRouteUpstreamPath(route)
|
||||
const converter = route.converter || 'none'
|
||||
|
||||
if (!incomingPath) {
|
||||
return { routeIndex: index, message: 'Incoming path is required' }
|
||||
}
|
||||
if (!incomingPath.startsWith('/')) {
|
||||
return { routeIndex: index, message: 'Incoming path must start with /' }
|
||||
}
|
||||
if (incomingPath.includes('?')) {
|
||||
return {
|
||||
routeIndex: index,
|
||||
message: 'Incoming path must not include query',
|
||||
}
|
||||
}
|
||||
if (seenPaths.has(incomingPath)) {
|
||||
return { routeIndex: index, message: 'Incoming path must be unique' }
|
||||
}
|
||||
seenPaths.add(incomingPath)
|
||||
|
||||
if (!upstreamPath) {
|
||||
return { routeIndex: index, message: 'Upstream path is required' }
|
||||
}
|
||||
if (!isFullHttpURLOrAbsolutePath(upstreamPath)) {
|
||||
return {
|
||||
routeIndex: index,
|
||||
message: 'Upstream path must be a full URL or a path starting with /',
|
||||
}
|
||||
}
|
||||
if (!isAdvancedCustomConverter(converter)) {
|
||||
return { routeIndex: index, message: 'Converter is not registered' }
|
||||
}
|
||||
if (!isConverterPathAllowed(incomingPath, converter)) {
|
||||
return {
|
||||
routeIndex: index,
|
||||
message: 'Converter does not match incoming path',
|
||||
}
|
||||
}
|
||||
|
||||
const authError = validateRouteAuth(route.auth)
|
||||
if (authError) {
|
||||
return { routeIndex: index, message: authError }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function advancedCustomConfigUsesRelativeUpstreamPath(
|
||||
config: AdvancedCustomConfig | null
|
||||
): boolean {
|
||||
if (!config) return false
|
||||
const normalized = normalizeAdvancedCustomConfig(config)
|
||||
return (normalized.advanced_routes || []).some((route) =>
|
||||
getAdvancedCustomRouteUpstreamPath(route).startsWith('/')
|
||||
)
|
||||
}
|
||||
|
||||
export function getAdvancedCustomStats(value: string | undefined): {
|
||||
routeCount: number
|
||||
fallbackEnabled: boolean
|
||||
valid: boolean
|
||||
} {
|
||||
const config = parseAdvancedCustomConfig(value)
|
||||
if (!config) {
|
||||
return { routeCount: 0, fallbackEnabled: false, valid: false }
|
||||
}
|
||||
const normalized = normalizeAdvancedCustomConfig(config)
|
||||
return {
|
||||
routeCount: normalized.advanced_routes?.length || 0,
|
||||
fallbackEnabled: normalized.advanced_fallback?.enabled === true,
|
||||
valid: validateAdvancedCustomConfig(normalized) === null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdvancedCustomAuthMode(
|
||||
route: AdvancedCustomRoute
|
||||
): AdvancedCustomAuthMode {
|
||||
return route.auth?.type || 'default'
|
||||
}
|
||||
|
||||
export function buildAdvancedCustomAuth(
|
||||
mode: AdvancedCustomAuthMode,
|
||||
previousAuth: AdvancedCustomRouteAuth | undefined
|
||||
): AdvancedCustomRouteAuth | undefined {
|
||||
if (mode === 'default') return undefined
|
||||
if (mode === 'none') return { type: 'none' }
|
||||
if (mode === 'header') {
|
||||
return {
|
||||
type: 'header',
|
||||
name: previousAuth?.name || 'Authorization',
|
||||
value: previousAuth?.value || 'Bearer {api_key}',
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'query',
|
||||
name: previousAuth?.name || 'api_key',
|
||||
value: previousAuth?.value || '{api_key}',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAdvancedCustomRoute(
|
||||
route: AdvancedCustomRoute
|
||||
): AdvancedCustomRoute {
|
||||
const nextRoute: AdvancedCustomRoute = {
|
||||
incoming_path: route.incoming_path || '',
|
||||
upstream_path: getAdvancedCustomRouteUpstreamPath(route),
|
||||
converter: route.converter || 'none',
|
||||
}
|
||||
if (route.auth) {
|
||||
nextRoute.auth = {
|
||||
type: route.auth.type,
|
||||
name: route.auth.name || '',
|
||||
value: route.auth.value || '',
|
||||
}
|
||||
}
|
||||
return nextRoute
|
||||
}
|
||||
|
||||
function getAdvancedCustomRouteUpstreamPath(route: AdvancedCustomRoute): string {
|
||||
return (route.upstream_path || '').trim()
|
||||
}
|
||||
|
||||
function isFullHttpURLOrAbsolutePath(value: string): boolean {
|
||||
if (value.startsWith('/')) return !value.startsWith('//')
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return (
|
||||
Boolean(parsed.host) &&
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isAdvancedCustomConverter(
|
||||
value: string
|
||||
): value is AdvancedCustomConverter {
|
||||
return ADVANCED_CUSTOM_CONVERTER_OPTIONS.some(
|
||||
(option) => option.value === value
|
||||
)
|
||||
}
|
||||
|
||||
function isConverterPathAllowed(
|
||||
incomingPath: string,
|
||||
converter: AdvancedCustomConverter
|
||||
): boolean {
|
||||
if (converter === 'none') return true
|
||||
if (converter === 'anthropic_messages_to_openai_chat_completions') {
|
||||
return incomingPath === '/v1/messages'
|
||||
}
|
||||
if (
|
||||
converter === 'openai_chat_completions_to_anthropic_messages' ||
|
||||
converter === 'openai_chat_completions_to_openai_responses' ||
|
||||
converter === 'openai_chat_completions_to_gemini_generate_content'
|
||||
) {
|
||||
return incomingPath === '/v1/chat/completions'
|
||||
}
|
||||
return (
|
||||
incomingPath.includes(':generateContent') ||
|
||||
incomingPath.includes(':streamGenerateContent')
|
||||
)
|
||||
}
|
||||
|
||||
function validateRouteAuth(
|
||||
auth: AdvancedCustomRouteAuth | undefined
|
||||
): string | null {
|
||||
if (!auth) return null
|
||||
if (auth.type === 'none') return null
|
||||
if (auth.type !== 'header' && auth.type !== 'query') {
|
||||
return 'Auth type is invalid'
|
||||
}
|
||||
if (!auth.name?.trim()) {
|
||||
return 'Auth name is required'
|
||||
}
|
||||
if (!auth.value?.trim()) {
|
||||
return 'Auth value is required'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
|
||||
'param_override',
|
||||
'header_override',
|
||||
'status_code_mapping',
|
||||
'advanced_custom',
|
||||
'force_format',
|
||||
'thinking_to_content',
|
||||
'pass_through_body_enabled',
|
||||
|
||||
@@ -23,6 +23,13 @@ import {
|
||||
MODEL_FETCHABLE_TYPES,
|
||||
} from '../constants'
|
||||
import type { Channel } from '../types'
|
||||
import {
|
||||
CHANNEL_TYPE_ADVANCED_CUSTOM,
|
||||
advancedCustomConfigUsesRelativeUpstreamPath,
|
||||
parseAdvancedCustomConfig,
|
||||
stringifyAdvancedCustomConfig,
|
||||
validateAdvancedCustomConfig,
|
||||
} from './advanced-custom'
|
||||
|
||||
// ============================================================================
|
||||
// Form Validation Schema
|
||||
@@ -169,6 +176,7 @@ export const channelFormSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
|
||||
advanced_custom: z.string().optional(),
|
||||
other: z.string().optional(),
|
||||
// Multi-key options (not sent to backend directly)
|
||||
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
|
||||
@@ -209,6 +217,37 @@ export const channelFormSchema = z
|
||||
)
|
||||
}
|
||||
|
||||
if (data.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
|
||||
const advancedCustomConfig = parseAdvancedCustomConfig(
|
||||
data.advanced_custom
|
||||
)
|
||||
const advancedCustomError =
|
||||
validateAdvancedCustomConfig(advancedCustomConfig)
|
||||
if (advancedCustomError) {
|
||||
addRequiredIssue(ctx, 'advanced_custom', advancedCustomError.message)
|
||||
}
|
||||
if (
|
||||
advancedCustomConfig?.advanced_fallback?.enabled === true &&
|
||||
!data.base_url?.trim()
|
||||
) {
|
||||
addRequiredIssue(
|
||||
ctx,
|
||||
'base_url',
|
||||
'Base URL is required when fallback is enabled'
|
||||
)
|
||||
}
|
||||
if (
|
||||
advancedCustomConfigUsesRelativeUpstreamPath(advancedCustomConfig) &&
|
||||
!data.base_url?.trim()
|
||||
) {
|
||||
addRequiredIssue(
|
||||
ctx,
|
||||
'base_url',
|
||||
'Base URL is required when an advanced route uses an upstream path'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if ([3, 18, 21, 39, 41, 49].includes(data.type) && !data.other?.trim()) {
|
||||
addRequiredIssue(
|
||||
ctx,
|
||||
@@ -316,6 +355,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
|
||||
upstream_model_update_check_enabled: false,
|
||||
upstream_model_update_auto_sync_enabled: false,
|
||||
upstream_model_update_ignored_models: '',
|
||||
advanced_custom: '',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -370,6 +410,7 @@ export function transformChannelToFormDefaults(
|
||||
let upstreamModelUpdateCheckEnabled = false
|
||||
let upstreamModelUpdateAutoSyncEnabled = false
|
||||
let upstreamModelUpdateIgnoredModels = ''
|
||||
let advancedCustom = ''
|
||||
|
||||
if (channel.settings) {
|
||||
try {
|
||||
@@ -394,6 +435,9 @@ export function transformChannelToFormDefaults(
|
||||
)
|
||||
? parsed.upstream_model_update_ignored_models.join(',')
|
||||
: ''
|
||||
if (parsed.advanced_custom) {
|
||||
advancedCustom = stringifyAdvancedCustomConfig(parsed.advanced_custom)
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to parse channel settings:', error)
|
||||
@@ -443,6 +487,7 @@ export function transformChannelToFormDefaults(
|
||||
upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled,
|
||||
upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled,
|
||||
upstream_model_update_ignored_models: upstreamModelUpdateIgnoredModels,
|
||||
advanced_custom: advancedCustom,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,6 +612,17 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
|
||||
const advancedCustomConfig = parseAdvancedCustomConfig(
|
||||
formData.advanced_custom
|
||||
)
|
||||
if (advancedCustomConfig) {
|
||||
settingsObj.advanced_custom = advancedCustomConfig
|
||||
}
|
||||
} else if ('advanced_custom' in settingsObj) {
|
||||
delete settingsObj.advanced_custom
|
||||
}
|
||||
|
||||
return JSON.stringify(settingsObj)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,16 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
|
||||
baseUrl: 'Default: https://api.replicate.com',
|
||||
},
|
||||
},
|
||||
58: {
|
||||
id: 58,
|
||||
name: CHANNEL_TYPES[58],
|
||||
icon: 'openai',
|
||||
hints: {
|
||||
baseUrl: 'Fallback base URL',
|
||||
key: 'Used by route auth templates',
|
||||
models: 'Models exposed by this channel',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,7 @@ export function getChannelTypeIcon(type: number): string {
|
||||
6: 'OpenAI', // OpenAIMax
|
||||
7: 'OpenAI', // OhMyGPT
|
||||
8: 'OpenAI', // Custom
|
||||
58: 'OpenAI', // Advanced Custom
|
||||
3: 'Azure', // Azure
|
||||
|
||||
// Anthropic
|
||||
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// Re-export all library functions
|
||||
export * from './channel-actions'
|
||||
export * from './advanced-custom'
|
||||
export * from './channel-form-errors'
|
||||
export * from './channel-form'
|
||||
export * from './channel-type-config'
|
||||
|
||||
+33
@@ -105,8 +105,41 @@ export interface ChannelOtherSettings {
|
||||
upstream_model_update_ignored_models?: string[]
|
||||
upstream_model_update_last_check_time?: number
|
||||
upstream_model_update_last_detected_models?: string[]
|
||||
advanced_custom?: AdvancedCustomConfig
|
||||
}
|
||||
|
||||
export interface AdvancedCustomConfig {
|
||||
advanced_routes?: AdvancedCustomRoute[]
|
||||
advanced_fallback?: AdvancedCustomFallback
|
||||
}
|
||||
|
||||
export interface AdvancedCustomRoute {
|
||||
incoming_path?: string
|
||||
upstream_path?: string
|
||||
converter?: AdvancedCustomConverter
|
||||
auth?: AdvancedCustomRouteAuth
|
||||
}
|
||||
|
||||
export interface AdvancedCustomFallback {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface AdvancedCustomRouteAuth {
|
||||
type?: AdvancedCustomAuthType
|
||||
name?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
export type AdvancedCustomConverter =
|
||||
| 'none'
|
||||
| 'anthropic_messages_to_openai_chat_completions'
|
||||
| 'openai_chat_completions_to_anthropic_messages'
|
||||
| 'openai_chat_completions_to_openai_responses'
|
||||
| 'gemini_generate_content_to_openai_chat_completions'
|
||||
| 'openai_chat_completions_to_gemini_generate_content'
|
||||
|
||||
export type AdvancedCustomAuthType = 'none' | 'header' | 'query'
|
||||
|
||||
// ============================================================================
|
||||
// API Response Types
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user