feat: advanced custom channel (#5590)

This commit is contained in:
Seefs
2026-06-18 18:00:27 +08:00
committed by GitHub
parent 21d4d18dfc
commit 3f2c0aeda7
26 changed files with 3012 additions and 62 deletions
@@ -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>
)
}
@@ -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
View File
@@ -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 }[] = (() => {
+616
View File
@@ -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',
+56
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
// ============================================================================