Files
new-api/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
T

4598 lines
202 KiB
TypeScript
Vendored

/*
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 { zodResolver } from '@hookform/resolvers/zod'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
ArrowRight,
AlertCircle,
Boxes,
CheckCircle2,
Circle,
HelpCircle,
KeyRound,
Loader2,
Server,
Sparkles,
Trash2,
Copy,
FileText,
Eraser,
Plus,
Eye,
RefreshCw,
Code,
Route,
Settings,
SlidersHorizontal,
Wand2,
} from 'lucide-react'
import {
type ReactNode,
useEffect,
useState,
useMemo,
useCallback,
useRef,
} from 'react'
import { type SubmitErrorHandler, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSectionClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { JsonEditor } from '@/components/json-editor'
import { MultiSelect } from '@/components/multi-select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
SecureVerificationDialog,
useSecureVerification,
} from '@/features/auth/secure-verification'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
hasPermission,
} from '@/lib/admin-permissions'
import { getLobeIcon } from '@/lib/lobe-icon'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import {
fetchModels,
getAllModels,
getChannel,
getChannelKey,
getGroups,
getPrefillGroups,
refreshCodexCredential,
} from '../../api'
import {
ADD_MODE_OPTIONS,
CHANNEL_STATUS_LABELS,
CHANNEL_TYPE_OPTIONS,
CHANNEL_TYPE_WARNINGS,
ERROR_MESSAGES,
FIELD_DESCRIPTIONS,
FIELD_PLACEHOLDERS,
MODEL_FETCHABLE_TYPES,
} from '../../constants'
import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form'
import {
CHANNEL_FORM_DEFAULT_VALUES,
CHANNEL_TYPE_ADVANCED_CUSTOM,
channelFormSchema,
channelsQueryKeys,
getAdvancedCustomStats,
transformChannelToFormDefaults,
type ChannelFormValues,
deduplicateKeys,
getChannelTypeIcon,
getKeyPromptForType,
parseModelsString,
formatModelsArray,
extractRedirectModels,
extractMappingSourceModels,
hasModelConfigChanged,
findMissingModelsInMapping,
validateModelMappingJson,
hasAdvancedSettingsErrors,
} from '../../lib'
import {
collectInvalidStatusCodeEntries,
collectNewDisallowedStatusCodeRedirects,
} 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,
type MissingModelsAction,
} from '../dialogs/missing-models-confirmation-dialog'
import { ParamOverrideEditorDialog } from '../dialogs/param-override-editor-dialog'
import { StatusCodeRiskDialog } from '../dialogs/status-code-risk-dialog'
import { ModelMappingEditor } from '../model-mapping-editor'
import {
ChannelAdvancedSection,
ChannelApiAccessSection,
ChannelAuthSection,
ChannelBasicSection,
ChannelEditorLoadingState,
ChannelModelsSection,
} from './sections'
type ChannelMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: Channel | null
}
type ModelMappingGuardrail = {
invalidJson: boolean
entries: Array<{ source: string; target: string }>
missingSourceModels: string[]
exposedTargetModels: string[]
}
type ChannelEditorSectionStatus = 'complete' | 'configured' | 'error' | 'idle'
type ChannelEditorNavChildItem = {
id: string
title: string
configured?: boolean
}
type ChannelEditorNavItem = {
id: string
title: string
description?: string
statusLabel: string
status: ChannelEditorSectionStatus
icon: ReactNode
configured?: boolean
children?: ChannelEditorNavChildItem[]
}
// Helper functions
const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({
invalidJson: false,
entries: [],
missingSourceModels: [],
exposedTargetModels: [],
})
const formatModelNames = (models: string[]): string =>
models.map((model) => `"${model}"`).join(', ')
const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{
source: string
target: string
}> = [{ source: 'client-model', target: 'upstream-model' }]
const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded'
const CHANNEL_EDITOR_SECTION_IDS = {
identity: 'channel-section-identity',
credentials: 'channel-section-credentials',
models: 'channel-section-models',
advanced: 'channel-section-advanced',
} as const
const CHANNEL_EDITOR_MAIN_SECTION_IDS = [
CHANNEL_EDITOR_SECTION_IDS.identity,
CHANNEL_EDITOR_SECTION_IDS.credentials,
CHANNEL_EDITOR_SECTION_IDS.models,
CHANNEL_EDITOR_SECTION_IDS.advanced,
]
const ADVANCED_SETTINGS_SECTION_IDS = {
routingStrategy: 'channel-section-advanced-routing-strategy',
internalNotes: 'channel-section-advanced-internal-notes',
overrideRules: 'channel-section-advanced-override-rules',
extraSettings: 'channel-section-advanced-extra-settings',
fieldPassthrough: 'channel-section-advanced-field-passthrough',
upstreamModelDetection: 'channel-section-advanced-upstream-model-detection',
} as const
const ADVANCED_SETTINGS_CHILD_SECTION_IDS: string[] = Object.values(
ADVANCED_SETTINGS_SECTION_IDS
)
const ADVANCED_CUSTOM_ROUTE_TYPE_PREVIEW_LIMIT = 3
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8
const SENSITIVE_FORM_FIELDS = [
'type',
'base_url',
'key',
'openai_organization',
'other',
'key_mode',
'param_override',
'header_override',
'settings',
'setting',
'advanced_custom',
'is_enterprise_account',
'vertex_key_type',
'aws_key_type',
'azure_responses_version',
'force_format',
'thinking_to_content',
'proxy',
'pass_through_body_enabled',
'system_prompt',
'system_prompt_override',
'allow_service_tier',
'disable_store',
'allow_safety_identifier',
'allow_include_obfuscation',
'allow_inference_geo',
'allow_speed',
'claude_beta_query',
'disable_task_polling_sleep',
'upstream_model_update_check_enabled',
'upstream_model_update_auto_sync_enabled',
'upstream_model_update_ignored_models',
] satisfies (keyof ChannelFormValues)[]
function readAdvancedSettingsPreference(): boolean {
if (typeof window === 'undefined') return false
return window.localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true'
}
function hasConfiguredOverrideValue(value: unknown): boolean {
if (typeof value !== 'string') return false
const trimmed = value.trim()
if (!trimmed || trimmed === 'null') return false
try {
const parsed = JSON.parse(trimmed)
if (parsed === null) return false
if (Array.isArray(parsed)) return parsed.length > 0
if (typeof parsed === 'object') return Object.keys(parsed).length > 0
} catch {
return true
}
return true
}
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
return Boolean(
hasConfiguredOverrideValue(values.param_override) ||
hasConfiguredOverrideValue(values.header_override) ||
values.advanced_custom?.trim() ||
hasConfiguredOverrideValue(values.status_code_mapping) ||
values.tag?.trim() ||
values.remark?.trim() ||
values.priority ||
values.weight ||
values.proxy?.trim() ||
values.system_prompt?.trim() ||
values.force_format ||
values.thinking_to_content ||
values.pass_through_body_enabled ||
values.system_prompt_override ||
values.claude_beta_query ||
values.upstream_model_update_check_enabled ||
values.upstream_model_update_auto_sync_enabled ||
values.upstream_model_update_ignored_models?.trim()
)
}
function parseSettingsRecord(
settings: string | undefined
): Record<string, unknown> {
if (!settings?.trim()) return {}
try {
const parsed = JSON.parse(settings)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
return {}
}
return {}
}
function formatUnixTime(timestamp: unknown): string {
const seconds = Number(timestamp)
if (!Number.isFinite(seconds) || seconds <= 0) return '-'
return new Date(seconds * 1000).toLocaleString()
}
function CardHeading({ title, icon }: { title: string; icon?: ReactNode }) {
return (
<div className='flex items-center gap-3'>
{icon && (
<span className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md'>
{icon}
</span>
)}
<h3 className='text-sm font-semibold tracking-tight'>{title}</h3>
</div>
)
}
function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) {
return (
<div className='flex items-center gap-2'>
{icon && <span className='text-muted-foreground'>{icon}</span>}
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
{title}
</h4>
</div>
)
}
function configuredAdvancedSectionClassName(
className: string,
configured: boolean
) {
return cn(
className,
'border-border/60 rounded-lg border p-3 transition-colors',
configured && 'border-primary/35 ring-primary/20 ring-1'
)
}
function ChannelTypeLogo(props: {
type: number
size?: number
className?: string
}) {
const isKnownType = CHANNEL_TYPE_OPTIONS.some(
(option) => option.value === props.type
)
if (!isKnownType) {
return (
<Server
className={cn('text-muted-foreground shrink-0', props.className)}
style={{
width: props.size ?? 16,
height: props.size ?? 16,
}}
aria-hidden='true'
/>
)
}
return (
<span className={cn('inline-flex shrink-0', props.className)}>
{getLobeIcon(`${getChannelTypeIcon(props.type)}.Color`, props.size ?? 16)}
</span>
)
}
function getSectionStatusIcon(status: ChannelEditorSectionStatus): ReactNode {
if (status === 'error') {
return <AlertCircle className='h-3.5 w-3.5' aria-hidden='true' />
}
if (status === 'complete' || status === 'configured') {
return <CheckCircle2 className='h-3.5 w-3.5' aria-hidden='true' />
}
return <Circle className='h-3.5 w-3.5' aria-hidden='true' />
}
function getCompletionStatus(
hasErrors: boolean,
isComplete: boolean
): ChannelEditorSectionStatus {
if (hasErrors) return 'error'
if (isComplete) return 'complete'
return 'idle'
}
function getSectionStatusLabel(
status: ChannelEditorSectionStatus,
t: (key: string) => string
): string {
if (status === 'error') return t('Error')
if (status === 'complete' || status === 'configured') return t('Ready')
return t('Incomplete')
}
function ChannelEditorNav(props: {
providerLogo: ReactNode
providerLabel: string
statusLabel: string
progressLabel: string
navigationLabel: string
items: ChannelEditorNavItem[]
activeItemId?: string
expandedItemId?: string
onNavigate: (targetId: string) => void
}) {
return (
<aside className='hidden self-start lg:sticky lg:top-4 lg:z-20 lg:block'>
<div className='flex max-h-[calc(100dvh-12rem)] flex-col gap-3 overflow-y-auto overscroll-contain pr-1'>
<div className='border-border/60 bg-muted/20 rounded-lg border p-3'>
<div className='flex min-w-0 items-center gap-2'>
<span className='bg-background flex size-8 shrink-0 items-center justify-center rounded-md border'>
{props.providerLogo}
</span>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{props.providerLabel}
</p>
<p className='text-muted-foreground truncate text-xs'>
{props.statusLabel} · {props.progressLabel}
</p>
</div>
</div>
</div>
<nav
className='border-border/60 bg-background rounded-lg border p-1'
aria-label={props.navigationLabel}
>
{props.items.map((item) => {
const isError = item.status === 'error'
const isDone =
item.status === 'complete' || item.status === 'configured'
const isConfigured = Boolean(item.configured)
const isActive = props.activeItemId === item.id
const isExpanded = props.expandedItemId === item.id
return (
<div key={item.id}>
<button
type='button'
className={cn(
'hover:bg-muted/60 flex w-full items-start gap-2 rounded-md px-2 py-2 text-left transition-colors',
isActive && 'bg-muted/70',
isConfigured && !isError && 'text-primary',
isError && 'text-destructive hover:bg-destructive/10'
)}
onClick={() => props.onNavigate(item.id)}
aria-current={isActive ? 'true' : undefined}
>
<span
className={cn(
'bg-muted text-muted-foreground mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md',
isConfigured && !isError && 'bg-primary/10 text-primary',
isError && 'bg-destructive/10 text-destructive',
isDone && !isError && 'text-primary'
)}
>
{item.icon}
</span>
<span className='min-w-0 flex-1'>
<span className='block truncate text-sm font-medium'>
{item.title}
</span>
{item.description && (
<span className='text-muted-foreground block truncate text-xs'>
{item.description}
</span>
)}
</span>
<span
className={cn(
'text-muted-foreground mt-1 shrink-0',
isError && 'text-destructive',
isDone && !isError && 'text-primary',
isConfigured && !isError && 'pt-1.5'
)}
aria-label={item.statusLabel}
>
{isConfigured && !isError && !isDone ? (
<span
className='bg-success block size-2 rounded-full'
aria-hidden='true'
/>
) : (
getSectionStatusIcon(item.status)
)}
</span>
</button>
{item.children && isExpanded && (
<div className='border-border/60 ml-5 flex flex-col gap-0.5 border-l py-1 pl-3'>
{item.children.map((child) => (
<button
key={child.id}
type='button'
className={cn(
'text-muted-foreground hover:bg-muted/50 hover:text-foreground flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors',
child.configured && 'text-primary'
)}
onClick={() => props.onNavigate(child.id)}
>
<span className='min-w-0 flex-1 truncate'>
{child.title}
</span>
{child.configured && (
<span
className='bg-success size-1.5 shrink-0 rounded-full'
aria-hidden='true'
/>
)}
</button>
))}
</div>
)}
</div>
)
})}
</nav>
</div>
</aside>
)
}
export function ChannelMutateDrawer({
open,
onOpenChange,
currentRow,
}: ChannelMutateDrawerProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { setOpen } = useChannels()
const currentUser = useAuthStore((s) => s.auth.user)
const canEditSensitive = hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
const [channelKey, setChannelKey] = useState<string | null>(null)
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] =
useState(false)
const initialModelsRef = useRef<string[]>([])
const initialModelMappingRef = useRef<string>('')
const initialStatusCodeMappingRef = useRef<string>('')
const [statusCodeRiskOpen, setStatusCodeRiskOpen] = useState(false)
const [statusCodeRiskDetailItems, setStatusCodeRiskDetailItems] = useState<
string[]
>([])
const statusCodeRiskResolveRef = useRef<
((confirmed: boolean) => void) | null
>(null)
const [missingModelsDialogOpen, setMissingModelsDialogOpen] = useState(false)
const [missingModelsList, setMissingModelsList] = useState<string[]>([])
const missingModelsResolveRef = useRef<
((action: MissingModelsAction) => void) | null
>(null)
const channelFormRef = useRef<HTMLFormElement>(null)
const advancedNavScrollPendingRef = useRef(false)
const [activeEditorSectionId, setActiveEditorSectionId] = useState<string>(
CHANNEL_EDITOR_SECTION_IDS.identity
)
const [expandedEditorNavItemId, setExpandedEditorNavItemId] = useState<
string | undefined
>()
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false)
const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false)
const [advancedCustomEditorOpen, setAdvancedCustomEditorOpen] =
useState(false)
const isEditing = Boolean(currentRow)
const channelId = currentRow?.id ?? null
const sensitiveLocked = isEditing && !canEditSensitive
// Fetch channel details if editing
const { data: channelData, isLoading: isChannelLoading } = useQuery({
queryKey: channelsQueryKeys.detail(channelId || 0),
queryFn: () => getChannel(channelId || 0),
enabled: isEditing && Boolean(channelId),
})
// Fetch available groups
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
// Fetch all available models
const { data: allModelsData } = useQuery({
queryKey: ['channel_models'],
queryFn: getAllModels,
})
// Fetch prefill model groups
const { data: prefillGroupsData } = useQuery({
queryKey: ['prefill_groups', 'model'],
queryFn: () => getPrefillGroups('model'),
})
const { copyToClipboard } = useCopyToClipboard()
const {
open: verificationOpen,
methods: verificationMethods,
state: verificationState,
executeVerification,
withVerification,
cancel: cancelVerification,
setCode: setVerificationCode,
switchMethod: switchVerificationMethod,
} = useSecureVerification()
useEffect(() => {
if (!open) {
setChannelKey(null)
setIsChannelKeyLoading(false)
} else if (channelId) {
setChannelKey(null)
}
}, [open, channelId])
// Check if this is a multi-key channel
const isMultiKeyChannel =
isEditing && channelData?.data?.channel_info?.is_multi_key === true
// Form setup
const form = useForm<ChannelFormValues>({
resolver: zodResolver(channelFormSchema),
defaultValues: CHANNEL_FORM_DEFAULT_VALUES,
})
// Watch form values for conditional rendering
const multiKeyMode = form.watch('multi_key_mode')
const multiKeyType = form.watch('multi_key_type')
const keyMode = form.watch('key_mode')
const currentGroups = form.watch('group')
const currentType = form.watch('type')
const currentStatus = form.watch('status')
const currentBaseUrl = form.watch('base_url')
const currentKey = form.watch('key')
const currentOther = form.watch('other')
const currentModels = form.watch('models')
const currentName = form.watch('name')
const currentModelMapping = form.watch('model_mapping')
const awsKeyType = form.watch('aws_key_type')
const vertexKeyType = form.watch('vertex_key_type')
const upstreamModelUpdateCheckEnabled = form.watch(
'upstream_model_update_check_enabled'
)
const currentSettings = form.watch('settings')
const currentAdvancedCustom = form.watch('advanced_custom')
const currentPriority = form.watch('priority')
const currentWeight = form.watch('weight')
const currentTestModel = form.watch('test_model')
const currentAutoBan = form.watch('auto_ban')
const currentTag = form.watch('tag')
const currentRemark = form.watch('remark')
const currentStatusCodeMapping = form.watch('status_code_mapping')
const currentParamOverride = form.watch('param_override')
const currentHeaderOverride = form.watch('header_override')
const currentForceFormat = form.watch('force_format')
const currentThinkingToContent = form.watch('thinking_to_content')
const currentPassThroughBodyEnabled = form.watch('pass_through_body_enabled')
const currentDisableTaskPollingSleep = form.watch(
'disable_task_polling_sleep'
)
const currentProxy = form.watch('proxy')
const currentSystemPrompt = form.watch('system_prompt')
const currentSystemPromptOverride = form.watch('system_prompt_override')
const currentAllowServiceTier = form.watch('allow_service_tier')
const currentDisableStore = form.watch('disable_store')
const currentAllowSafetyIdentifier = form.watch('allow_safety_identifier')
const currentAllowIncludeObfuscation = form.watch('allow_include_obfuscation')
const currentAllowInferenceGeo = form.watch('allow_inference_geo')
const currentAllowSpeed = form.watch('allow_speed')
const currentClaudeBetaQuery = form.watch('claude_beta_query')
const currentUpstreamModelUpdateAutoSyncEnabled = form.watch(
'upstream_model_update_auto_sync_enabled'
)
const currentUpstreamModelUpdateIgnoredModels = form.watch(
'upstream_model_update_ignored_models'
)
const {
unlocked: doubaoApiEditUnlocked,
handleClick: handleApiConfigSecretClick,
reset: resetDoubaoApiUnlock,
} = useHiddenClickUnlock({
requiredClicks: 10,
disabled: currentType !== 45 || sensitiveLocked,
onUnlock: () => {
toast.info(t('Doubao custom API address editing unlocked'))
},
})
useEffect(() => {
if (!open) {
resetDoubaoApiUnlock()
}
}, [open, resetDoubaoApiUnlock])
// Helper computed values
const isBatchMode =
multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single'
const isChannelDetailLoading = isEditing && isChannelLoading
const supportsMultiKeyAddMode =
currentType !== 57 && !(currentType === 41 && vertexKeyType === 'api_key')
const addModeOptions = useMemo(
() =>
supportsMultiKeyAddMode
? ADD_MODE_OPTIONS
: ADD_MODE_OPTIONS.filter((option) => option.value === 'single'),
[supportsMultiKeyAddMode]
)
const advancedCustomStats = useMemo(
() => getAdvancedCustomStats(currentAdvancedCustom),
[currentAdvancedCustom]
)
const advancedCustomRouteTypeLabels =
advancedCustomStats.routeTypeLabels.slice(
0,
ADVANCED_CUSTOM_ROUTE_TYPE_PREVIEW_LIMIT
)
const hiddenAdvancedCustomRouteTypeCount =
advancedCustomStats.routeTypeLabels.length -
advancedCustomRouteTypeLabels.length
const advancedCustomRouteTypeTitle =
hiddenAdvancedCustomRouteTypeCount > 0
? advancedCustomStats.routeTypeLabels.join(', ')
: undefined
// Get all models list
const allModelsList = useMemo(
() => allModelsData?.data?.map((model) => model.id).filter(Boolean) || [],
[allModelsData]
)
// Get basic models for the current channel type
const basicModels = useMemo(() => {
if (!allModelsList.length) return []
// Filter models based on common patterns for specific types
if (currentType === 1) {
return allModelsList.filter(
(model) => model.startsWith('gpt-') || model.startsWith('text-')
)
}
return allModelsList
}, [allModelsList, currentType])
// Get prefill groups
const prefillGroups = useMemo(
() => prefillGroupsData?.data || [],
[prefillGroupsData]
)
// Transform groups to multi-select options
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
const allGroups = new Set([...groupsData.data, ...(currentGroups || [])])
return [...allGroups].map((group) => ({
value: group,
label: group,
}))
}, [groupsData, currentGroups])
// Parse current models as array
const currentModelsArray = useMemo(
() => parseModelsString(currentModels),
[currentModels]
)
const currentTypeLabel = useMemo(
() =>
CHANNEL_TYPE_OPTIONS.find((option) => option.value === currentType)
?.label || `#${currentType}`,
[currentType]
)
const channelTypeOptions = useMemo(() => {
const options = CHANNEL_TYPE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.label),
icon: <ChannelTypeLogo type={option.value} size={16} />,
}))
if (!options.some((option) => Number(option.value) === currentType)) {
options.push({
value: String(currentType),
label: `#${currentType}`,
icon: <ChannelTypeLogo type={currentType} size={16} />,
})
}
return options
}, [currentType, t])
const formErrors = form.formState.errors
const identityHasErrors = Boolean(
formErrors.name ||
formErrors.type ||
formErrors.status ||
formErrors.openai_organization
)
const credentialsHaveErrors = Boolean(
formErrors.key ||
formErrors.base_url ||
formErrors.other ||
formErrors.multi_key_mode ||
formErrors.multi_key_type ||
formErrors.key_mode ||
formErrors.vertex_key_type ||
formErrors.aws_key_type ||
formErrors.azure_responses_version
)
const modelsHaveErrors = Boolean(
formErrors.models || formErrors.group || formErrors.model_mapping
)
const advancedHaveErrors =
hasAdvancedSettingsErrors(formErrors) || Boolean(formErrors.advanced_custom)
const providerRequiresBaseUrl = [3, 8, 36, 45].includes(currentType)
const providerRequiresOther = [3, 18, 21, 39, 41, 49].includes(currentType)
const identityComplete = Boolean(currentName?.trim() && currentType > 0)
const credentialsComplete = Boolean(
(isEditing || currentKey?.trim()) &&
(!providerRequiresBaseUrl || currentBaseUrl?.trim()) &&
(!providerRequiresOther || currentOther?.trim())
)
const modelsComplete = Boolean(
currentModelsArray.length > 0 && currentGroups?.length
)
const requiredCompletedCount = [
identityComplete,
credentialsComplete,
modelsComplete,
].filter(Boolean).length
const currentStatusLabel =
CHANNEL_STATUS_LABELS[
currentStatus as keyof typeof CHANNEL_STATUS_LABELS
] || 'Unknown'
const progressLabel = `${requiredCompletedCount}/3`
const identityStatus = getCompletionStatus(
identityHasErrors,
identityComplete
)
const credentialsStatus = getCompletionStatus(
credentialsHaveErrors,
credentialsComplete
)
const modelsStatus = getCompletionStatus(modelsHaveErrors, modelsComplete)
const advancedStatus: ChannelEditorSectionStatus = advancedHaveErrors
? 'error'
: 'idle'
const advancedSummary = advancedHaveErrors ? t('Error') : undefined
const routingStrategyConfigured = Boolean(
currentPriority ||
currentWeight ||
currentTestModel?.trim() ||
(currentAutoBan ?? 1) !== 1
)
const internalNotesConfigured = Boolean(
currentTag?.trim() || currentRemark?.trim()
)
const overrideRulesConfigured = Boolean(
hasConfiguredOverrideValue(currentStatusCodeMapping) ||
hasConfiguredOverrideValue(currentParamOverride) ||
hasConfiguredOverrideValue(currentHeaderOverride)
)
const extraSettingsConfigured = Boolean(
currentForceFormat ||
currentThinkingToContent ||
currentPassThroughBodyEnabled ||
currentDisableTaskPollingSleep ||
currentProxy?.trim() ||
currentSystemPrompt?.trim() ||
currentSystemPromptOverride
)
let fieldPassthroughConfigured = false
if (currentType === 1) {
fieldPassthroughConfigured = Boolean(
currentAllowServiceTier ||
currentDisableStore ||
currentAllowSafetyIdentifier ||
currentAllowIncludeObfuscation ||
currentAllowInferenceGeo
)
} else if (currentType === 14) {
fieldPassthroughConfigured = Boolean(
currentAllowServiceTier ||
currentAllowInferenceGeo ||
currentAllowSpeed ||
currentClaudeBetaQuery
)
}
const upstreamModelDetectionConfigured = Boolean(
upstreamModelUpdateCheckEnabled ||
currentUpstreamModelUpdateAutoSyncEnabled ||
currentUpstreamModelUpdateIgnoredModels?.trim()
)
const advancedConfigured = Boolean(
routingStrategyConfigured ||
internalNotesConfigured ||
overrideRulesConfigured ||
extraSettingsConfigured ||
fieldPassthroughConfigured ||
upstreamModelDetectionConfigured
)
const advancedNavChildren: ChannelEditorNavChildItem[] = [
{
id: ADVANCED_SETTINGS_SECTION_IDS.routingStrategy,
title: t('Routing Strategy'),
configured: routingStrategyConfigured,
},
{
id: ADVANCED_SETTINGS_SECTION_IDS.internalNotes,
title: t('Internal Notes'),
configured: internalNotesConfigured,
},
{
id: ADVANCED_SETTINGS_SECTION_IDS.overrideRules,
title: t('Override Rules'),
configured: overrideRulesConfigured,
},
{
id: ADVANCED_SETTINGS_SECTION_IDS.extraSettings,
title: t('Channel Extra Settings'),
configured: extraSettingsConfigured,
},
]
if (currentType === 1 || currentType === 14) {
advancedNavChildren.push({
id: ADVANCED_SETTINGS_SECTION_IDS.fieldPassthrough,
title: t('Field passthrough controls'),
configured: fieldPassthroughConfigured,
})
}
if (MODEL_FETCHABLE_TYPES.has(currentType)) {
advancedNavChildren.push({
id: ADVANCED_SETTINGS_SECTION_IDS.upstreamModelDetection,
title: t('Upstream Model Detection Settings'),
configured: upstreamModelDetectionConfigured,
})
}
const editorNavItems: ChannelEditorNavItem[] = [
{
id: CHANNEL_EDITOR_SECTION_IDS.identity,
title: t('Basic Information'),
description: getSectionStatusLabel(identityStatus, t),
statusLabel: getSectionStatusLabel(identityStatus, t),
status: identityStatus,
icon: <Server className='h-4 w-4' aria-hidden='true' />,
},
{
id: CHANNEL_EDITOR_SECTION_IDS.credentials,
title: t('Credentials'),
description: getSectionStatusLabel(credentialsStatus, t),
statusLabel: getSectionStatusLabel(credentialsStatus, t),
status: credentialsStatus,
icon: <KeyRound className='h-4 w-4' aria-hidden='true' />,
},
{
id: CHANNEL_EDITOR_SECTION_IDS.models,
title: t('Models & Groups'),
description: getSectionStatusLabel(modelsStatus, t),
statusLabel: getSectionStatusLabel(modelsStatus, t),
status: modelsStatus,
icon: <Boxes className='h-4 w-4' aria-hidden='true' />,
},
{
id: CHANNEL_EDITOR_SECTION_IDS.advanced,
title: t('Advanced Settings'),
description: advancedSummary,
statusLabel: advancedSummary ?? t('Advanced Settings'),
status: advancedStatus,
icon: <Settings className='h-4 w-4' aria-hidden='true' />,
configured: advancedConfigured,
children: advancedNavChildren,
},
]
// Extract redirect models from model_mapping (target values)
const redirectModelList = useMemo(
() => extractRedirectModels(currentModelMapping || ''),
[currentModelMapping]
)
// Extract source keys from model_mapping (models being remapped FROM)
const redirectModelKeyList = useMemo(
() => extractMappingSourceModels(currentModelMapping || ''),
[currentModelMapping]
)
// Transform models to multi-select options
const modelOptions = useMemo(() => {
const allModels = new Set([...allModelsList, ...currentModelsArray])
return [...allModels].map((model) => ({
value: model,
label: model,
}))
}, [allModelsList, currentModelsArray])
const modelMappingGuardrail = useMemo<ModelMappingGuardrail>(() => {
if (!currentModelMapping?.trim()) {
return createEmptyModelMappingGuardrail()
}
try {
const parsed = JSON.parse(currentModelMapping)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ...createEmptyModelMappingGuardrail(), invalidJson: true }
}
const entries = Object.entries(parsed).reduce<
Array<{ source: string; target: string }>
>((acc, [rawSource, rawTarget]) => {
const source = String(rawSource).trim()
const target = String(rawTarget ?? '').trim()
if (!source || !target) {
return acc
}
acc.push({ source, target })
return acc
}, [])
const missingSourceModels = [
...new Set(
entries
.filter(
(entry) =>
Boolean(entry.source) &&
!currentModelsArray.includes(entry.source)
)
.map((entry) => entry.source)
),
]
const exposedTargetModels = [
...new Set(
entries
.filter(
(entry) =>
Boolean(entry.target) &&
currentModelsArray.includes(entry.target)
)
.map((entry) => entry.target)
),
]
return {
invalidJson: false,
entries,
missingSourceModels,
exposedTargetModels,
}
} catch {
return { ...createEmptyModelMappingGuardrail(), invalidJson: true }
}
}, [currentModelMapping, currentModelsArray])
const mappingPreviewPairs =
modelMappingGuardrail.entries.length > 0
? modelMappingGuardrail.entries.slice(0, 3)
: MODEL_MAPPING_PREVIEW_FALLBACK
const remainingMappingCount =
modelMappingGuardrail.entries.length > 3
? modelMappingGuardrail.entries.length - 3
: 0
const upstreamUpdateMeta = useMemo(() => {
const settings = parseSettingsRecord(currentSettings)
const detectedModels = Array.isArray(
settings.upstream_model_update_last_detected_models
)
? settings.upstream_model_update_last_detected_models
.map((model) => String(model || '').trim())
.filter(Boolean)
: []
return {
lastCheckTime: settings.upstream_model_update_last_check_time,
detectedModels: [...new Set(detectedModels)],
}
}, [currentSettings])
const upstreamDetectedModelsPreview = upstreamUpdateMeta.detectedModels.slice(
0,
UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT
)
const upstreamDetectedModelsOmittedCount =
upstreamUpdateMeta.detectedModels.length -
upstreamDetectedModelsPreview.length
// Load channel data into form when editing
useEffect(() => {
if (isEditing && channelData?.data) {
const defaults = transformChannelToFormDefaults(channelData.data)
form.reset(defaults)
setAdvancedSettingsOpen(
readAdvancedSettingsPreference() || hasAdvancedSettingsValues(defaults)
)
// Store initial values for comparison
initialModelsRef.current = parseModelsString(
channelData.data.models || ''
)
initialModelMappingRef.current = channelData.data.model_mapping || ''
initialStatusCodeMappingRef.current =
channelData.data.status_code_mapping || ''
} else if (!isEditing) {
form.reset(CHANNEL_FORM_DEFAULT_VALUES)
setAdvancedSettingsOpen(false)
initialModelsRef.current = []
initialModelMappingRef.current = ''
initialStatusCodeMappingRef.current = ''
}
}, [isEditing, channelData, form])
// Handle type change - set default values for specific types
useEffect(() => {
if (isEditing) return // Don't auto-set defaults when editing
// Type 45 (VolcEngine) - set default base_url
if (currentType === 45) {
const currentBaseUrlValue = form.getValues('base_url')
if (!currentBaseUrlValue || currentBaseUrlValue === '') {
form.setValue('base_url', 'https://ark.cn-beijing.volces.com')
}
}
// Type 18 (Xunfei) - set default other (version)
if (currentType === 18) {
const currentOther = form.getValues('other')
if (!currentOther || currentOther === '') {
form.setValue('other', 'v2.1')
}
}
}, [currentType, isEditing, form])
useEffect(() => {
if (currentType !== 45 || currentBaseUrl !== 'doubao-coding-plan') return
form.setValue('base_url', 'https://ark.cn-beijing.volces.com', {
shouldDirty: false,
shouldValidate: true,
})
}, [currentBaseUrl, currentType, form])
useEffect(() => {
if (isEditing || supportsMultiKeyAddMode) return
if (multiKeyMode && multiKeyMode !== 'single') {
form.setValue('multi_key_mode', 'single', {
shouldDirty: true,
shouldValidate: true,
})
}
}, [form, isEditing, multiKeyMode, supportsMultiKeyAddMode])
// Validate base_url - warn if it ends with /v1
useEffect(() => {
if (!currentBaseUrl || !currentBaseUrl.endsWith('/v1')) return
// Show warning toast
const timer = setTimeout(() => {
toast.warning(
t(
'Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.'
),
{ duration: 5000 }
)
}, 500)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentBaseUrl])
// Handle key deduplication
const handleDeduplicateKeys = () => {
const currentKey = form.getValues('key')
if (!currentKey || currentKey.trim() === '') {
toast.info(t('Please enter keys first'))
return
}
const result = deduplicateKeys(currentKey)
if (result.removedCount === 0) {
toast.info(t('No duplicate keys found'))
} else {
form.setValue('key', result.deduplicatedText)
toast.success(
t(
'Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}',
{
removed: result.removedCount,
before: result.beforeCount,
after: result.afterCount,
}
)
)
}
}
const fetchChannelKey = useCallback(async () => {
if (!channelId) {
throw new Error('Channel is not selected')
}
setIsChannelKeyLoading(true)
try {
const res = await getChannelKey(channelId)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch channel key'))
}
const keyValue = res.data?.key ?? ''
setChannelKey(keyValue)
toast.success(t('Channel key unlocked'))
return res
} finally {
setIsChannelKeyLoading(false)
}
}, [channelId, t])
const handleRevealKey = useCallback(async () => {
if (!channelId) return
try {
await withVerification(fetchChannelKey, {
preferredMethod: 'passkey',
title: 'Verify to view channel key',
description:
'Use Passkey or 2FA to confirm your identity before revealing this channel key.',
})
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
}
}, [channelId, withVerification, fetchChannelKey])
const handleRefreshCodexCredential = useCallback(async () => {
if (!channelId) return
setIsCodexCredentialRefreshing(true)
try {
const res = await refreshCodexCredential(channelId)
if (!res.success) {
throw new Error(res.message || t('Failed to refresh credential'))
}
toast.success(t('Credential refreshed'))
queryClient.invalidateQueries({
queryKey: channelsQueryKeys.detail(channelId),
})
} catch (error) {
toast.error(error instanceof Error ? error.message : t('Refresh failed'))
} finally {
setIsCodexCredentialRefreshing(false)
}
}, [channelId, queryClient, t])
// Unified function to update models
const updateModels = useCallback(
(newModels: string[], merge: boolean = false) => {
const finalModels = merge
? formatModelsArray([...currentModelsArray, ...newModels])
: formatModelsArray(newModels)
form.setValue('models', finalModels)
return newModels.length
},
[currentModelsArray, form]
)
// Handle fetching models from upstream
const handleFetchModels = useCallback(async () => {
const type = form.getValues('type')
if (!MODEL_FETCHABLE_TYPES.has(type)) {
toast.error(t('This channel type does not support fetching models'))
return
}
if (!isEditing && !canEditSensitive) {
toast.error(t("You don't have necessary permission"))
return
}
// For creation mode, validate key before opening dialog
if (!isEditing) {
const key = form.getValues('key')
if (!key?.trim()) {
toast.error(t('Please enter API key first'))
return
}
}
setFetchModelsDialogOpen(true)
}, [isEditing, canEditSensitive, form, t])
const createModeFetcher = useCallback(async (): Promise<string[]> => {
if (!canEditSensitive) {
throw new Error(t("You don't have necessary permission"))
}
const response = await fetchModels({
type: form.getValues('type'),
key: form.getValues('key'),
base_url: form.getValues('base_url') || '',
})
if (response.success && response.data) {
return response.data
}
throw new Error(response.message || 'No models fetched from upstream')
}, [canEditSensitive, form, t])
// Handle model operations
const handleFillRelatedModels = useCallback(() => {
if (!basicModels.length) {
toast.info(t('No related models available for this channel type'))
return
}
updateModels(basicModels)
toast.success(
t('Filled {{count}} related model(s)', { count: basicModels.length })
)
}, [basicModels, updateModels, t])
const handleFillAllModels = useCallback(() => {
if (!allModelsList.length) {
toast.info(t('No models available'))
return
}
updateModels(allModelsList)
toast.success(
t('Filled {{count}} model(s)', { count: allModelsList.length })
)
}, [allModelsList, updateModels, t])
const handleClearModels = useCallback(() => {
form.setValue('models', '')
toast.success(t('Cleared all models'))
}, [form, t])
const handleCopyModels = useCallback(async () => {
const models = form.getValues('models')
if (!models?.trim()) {
toast.info(t('No models to copy'))
return
}
await copyToClipboard(models)
}, [form, copyToClipboard, t])
// Handle adding prefill group models
const handleAddPrefillGroup = useCallback(
(group: { id: number; name: string; items: string | string[] }) => {
try {
const items = Array.isArray(group.items)
? group.items
: JSON.parse(group.items)
if (!Array.isArray(items)) {
throw new Error('Invalid items format')
}
const count = updateModels(items, true)
toast.success(
t('Added {{count}} models from "{{name}}"', {
count,
name: group.name,
})
)
} catch {
toast.error(t('Failed to parse group items'))
}
},
[updateModels, t]
)
// Handle model selection change from MultiSelect
const handleModelsChange = useCallback(
(selected: string[]) => {
form.setValue('models', selected.join(','))
},
[form]
)
// Handle successful submission
const handleSuccess = useCallback(() => {
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
if (channelId) {
queryClient.invalidateQueries({
queryKey: channelsQueryKeys.detail(channelId),
})
}
onOpenChange(false)
setOpen(null)
}, [channelId, queryClient, onOpenChange, setOpen])
// Show missing models confirmation dialog
const confirmMissingModelMappings = useCallback(
(missingModels: string[]): Promise<MissingModelsAction> => {
return new Promise((resolve) => {
setMissingModelsList(missingModels)
setMissingModelsDialogOpen(true)
missingModelsResolveRef.current = resolve
})
},
[]
)
// Handle missing models dialog action
const handleMissingModelsAction = useCallback(
(action: MissingModelsAction) => {
setMissingModelsDialogOpen(false)
if (missingModelsResolveRef.current) {
missingModelsResolveRef.current(action)
missingModelsResolveRef.current = null
}
},
[]
)
const confirmStatusCodeRisk = useCallback(
(detailItems: string[]): Promise<boolean> =>
new Promise((resolve) => {
statusCodeRiskResolveRef.current = resolve
setStatusCodeRiskDetailItems(detailItems)
setStatusCodeRiskOpen(true)
}),
[]
)
const handleStatusCodeRiskAction = useCallback((confirmed: boolean) => {
setStatusCodeRiskOpen(false)
setStatusCodeRiskDetailItems([])
if (statusCodeRiskResolveRef.current) {
statusCodeRiskResolveRef.current(confirmed)
statusCodeRiskResolveRef.current = null
}
}, [])
useEffect(() => {
return () => {
if (statusCodeRiskResolveRef.current) {
statusCodeRiskResolveRef.current(false)
statusCodeRiskResolveRef.current = null
}
}
}, [])
const channelMutation = useChannelMutateForm({
currentRow,
isEditing,
isMultiKeyChannel,
onSuccess: handleSuccess,
})
const isSubmitting = channelMutation.isPending
// Submit handler
const onSubmit = useCallback(
async (data: ChannelFormValues) => {
// Validate key is required when creating
if (!isEditing && !data.key?.trim()) {
form.setError('key', {
type: 'manual',
message: ERROR_MESSAGES.REQUIRED_KEY,
})
return
}
if (sensitiveLocked) {
const dirtyFields = form.formState.dirtyFields as Partial<
Record<keyof ChannelFormValues, unknown>
>
const hasSensitiveChanges = SENSITIVE_FORM_FIELDS.some((field) =>
Boolean(dirtyFields[field])
)
if (hasSensitiveChanges) {
toast.error(
t('You do not have permission to edit sensitive channel settings.')
)
return
}
}
// Validate status_code_mapping entries
if (data.status_code_mapping?.trim()) {
const invalidEntries = collectInvalidStatusCodeEntries(
data.status_code_mapping
)
if (invalidEntries.length > 0) {
toast.error(
t('Invalid status code mapping entries: {{entries}}', {
entries: invalidEntries.join(', '),
})
)
return
}
const riskyRedirects = collectNewDisallowedStatusCodeRedirects(
initialStatusCodeMappingRef.current,
data.status_code_mapping
)
if (riskyRedirects.length > 0) {
const confirmed = await confirmStatusCodeRisk(riskyRedirects)
if (!confirmed) return
}
}
// Validate model_mapping JSON format
const hasModelMapping =
typeof data.model_mapping === 'string' &&
data.model_mapping.trim() !== ''
const modelMappingValue = data.model_mapping || ''
if (hasModelMapping) {
const validation = validateModelMappingJson(modelMappingValue)
if (!validation.valid) {
toast.error(t(validation.error || 'Invalid model mapping'))
return
}
}
// Normalize models array
const normalizedModels = parseModelsString(data.models || '')
// Check for missing models in model_mapping
if (hasModelMapping) {
const missingModels = findMissingModelsInMapping(
modelMappingValue,
normalizedModels
)
const shouldPromptMissing =
missingModels.length > 0 &&
hasModelConfigChanged(
normalizedModels,
data.model_mapping || '',
initialModelsRef.current,
initialModelMappingRef.current
)
if (shouldPromptMissing) {
const confirmAction = await confirmMissingModelMappings(missingModels)
if (confirmAction === 'cancel') {
return
}
if (confirmAction === 'add') {
const updatedModels = [
...new Set([...normalizedModels, ...missingModels]),
]
data.models = formatModelsArray(updatedModels)
form.setValue('models', data.models)
}
}
}
await channelMutation.mutateAsync(data)
},
[
isEditing,
sensitiveLocked,
form,
confirmMissingModelMappings,
confirmStatusCodeRisk,
channelMutation,
t,
]
)
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) {
advancedNavScrollPendingRef.current = false
setExpandedEditorNavItemId(undefined)
}
setAdvancedSettingsOpen(nextOpen)
if (typeof window !== 'undefined') {
window.localStorage.setItem(
ADVANCED_SETTINGS_EXPANDED_KEY,
String(nextOpen)
)
}
}, [])
const handleEditorNavNavigate = useCallback(
(targetId: string) => {
const isAdvancedTarget =
targetId === CHANNEL_EDITOR_SECTION_IDS.advanced ||
ADVANCED_SETTINGS_CHILD_SECTION_IDS.includes(targetId)
if (isAdvancedTarget) {
advancedNavScrollPendingRef.current = true
handleAdvancedSettingsOpenChange(true)
setActiveEditorSectionId(CHANNEL_EDITOR_SECTION_IDS.advanced)
setExpandedEditorNavItemId(CHANNEL_EDITOR_SECTION_IDS.advanced)
} else {
advancedNavScrollPendingRef.current = false
setActiveEditorSectionId(targetId)
setExpandedEditorNavItemId(undefined)
}
const scrollTargetIntoView = () => {
document
.querySelector<HTMLElement>(`#${targetId}`)
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
if (isAdvancedTarget && !advancedSettingsOpen) {
window.requestAnimationFrame(scrollTargetIntoView)
return
}
scrollTargetIntoView()
},
[advancedSettingsOpen, handleAdvancedSettingsOpenChange]
)
const updateActiveEditorSection = useCallback(() => {
const formElement = channelFormRef.current
if (!formElement) return
const activationY = formElement.getBoundingClientRect().top + 80
let nextActiveSectionId: string = CHANNEL_EDITOR_SECTION_IDS.identity
for (const sectionId of CHANNEL_EDITOR_MAIN_SECTION_IDS) {
const sectionElement = document.querySelector<HTMLElement>(
`#${sectionId}`
)
if (!sectionElement) continue
if (sectionElement.getBoundingClientRect().top <= activationY) {
nextActiveSectionId = sectionId
} else {
break
}
}
setActiveEditorSectionId((current) =>
current === nextActiveSectionId ? current : nextActiveSectionId
)
if (nextActiveSectionId === CHANNEL_EDITOR_SECTION_IDS.advanced) {
advancedNavScrollPendingRef.current = false
setExpandedEditorNavItemId(CHANNEL_EDITOR_SECTION_IDS.advanced)
if (!advancedSettingsOpen) {
handleAdvancedSettingsOpenChange(true)
}
} else if (!advancedNavScrollPendingRef.current) {
setExpandedEditorNavItemId(undefined)
}
}, [advancedSettingsOpen, handleAdvancedSettingsOpenChange])
useEffect(() => {
if (!open || isChannelDetailLoading) return
const formElement = channelFormRef.current
if (!formElement) return
updateActiveEditorSection()
formElement.addEventListener('scroll', updateActiveEditorSection, {
passive: true,
})
window.addEventListener('resize', updateActiveEditorSection)
return () => {
formElement.removeEventListener('scroll', updateActiveEditorSection)
window.removeEventListener('resize', updateActiveEditorSection)
}
}, [isChannelDetailLoading, open, updateActiveEditorSection])
const onInvalid: SubmitErrorHandler<ChannelFormValues> = useCallback(
(errors) => {
if (hasAdvancedSettingsErrors(errors)) {
handleAdvancedSettingsOpenChange(true)
}
toast.error(t('Please fix the highlighted fields before saving'))
},
[handleAdvancedSettingsOpenChange, t]
)
// Handle drawer close
const handleOpenChange = useCallback(
(v: boolean) => {
onOpenChange(v)
if (!v) {
form.reset(CHANNEL_FORM_DEFAULT_VALUES)
advancedNavScrollPendingRef.current = false
setActiveEditorSectionId(CHANNEL_EDITOR_SECTION_IDS.identity)
setExpandedEditorNavItemId(undefined)
setAdvancedSettingsOpen(false)
}
},
[onOpenChange, form]
)
return (
<>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className={sideDrawerContentClassName('sm:max-w-5xl')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle className='flex items-center gap-3'>
<span className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-md'>
<ChannelTypeLogo type={currentType} size={22} />
</span>
<span>
{isEditing ? t('Edit Channel') : t('Create Channel')}
<span className='text-muted-foreground ml-2 text-sm font-normal'>
{t(currentTypeLabel)}
</span>
</span>
</SheetTitle>
<SheetDescription>
{isEditing
? t(
"Update channel configuration and click save when you're done."
)
: t(
'Add a new channel by providing the necessary information.'
)}
</SheetDescription>
</SheetHeader>
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t(
'Sensitive channel settings are read-only for your account.'
)}{' '}
{t(
'You can still edit non-sensitive operations fields such as models, groups, priority, and weight.'
)}
</AlertDescription>
</Alert>
)}
<Form {...form}>
<form
id='channel-form'
ref={channelFormRef}
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className={sideDrawerFormClassName('gap-5')}
>
{isChannelDetailLoading ? (
<ChannelEditorLoadingState />
) : (
<div className='grid gap-5 lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start'>
<ChannelEditorNav
providerLogo={
<ChannelTypeLogo type={currentType} size={18} />
}
providerLabel={t(currentTypeLabel)}
statusLabel={t(currentStatusLabel)}
progressLabel={progressLabel}
navigationLabel={t('Channels')}
items={editorNavItems}
activeItemId={activeEditorSectionId}
expandedItemId={expandedEditorNavItemId}
onNavigate={handleEditorNavNavigate}
/>
<div className='flex min-w-0 flex-col gap-5'>
{/* ── Basic Information ── */}
<div
id={CHANNEL_EDITOR_SECTION_IDS.identity}
className='scroll-mt-4'
>
<ChannelBasicSection>
<div className='grid gap-4 sm:grid-cols-2'>
<fieldset
disabled={sensitiveLocked}
className='min-w-0 disabled:opacity-60'
>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type *')}</FormLabel>
<FormControl>
<div className='relative'>
<span className='pointer-events-none absolute top-1/2 left-3 z-10 flex -translate-y-1/2'>
<ChannelTypeLogo
type={Number(field.value)}
size={18}
/>
</span>
<Combobox
options={channelTypeOptions}
value={String(field.value)}
onValueChange={(value) => {
const nextType = Number(value)
if (
Number.isInteger(nextType) &&
nextType > 0
) {
field.onChange(nextType)
}
}}
placeholder={t('Select channel type')}
searchPlaceholder={t(
'Search channel type...'
)}
emptyText={t('No channel type found.')}
className='pl-10'
allowCustomValue
openOnFocus={false}
/>
</div>
</FormControl>
{sensitiveLocked && (
<FormDescription>
{t(
'No permission to perform this action'
)}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t(FIELD_PLACEHOLDERS.NAME)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!isEditing && (
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem
className={sideDrawerSwitchItemClassName()}
>
<div className='flex flex-col gap-0.5'>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription className='text-xs'>
{t('Enable or disable this channel')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value === 1}
onCheckedChange={(checked) =>
field.onChange(checked ? 1 : 2)
}
/>
</FormControl>
</FormItem>
)}
/>
)}
{currentType === 1 && (
<fieldset
disabled={sensitiveLocked}
className='disabled:opacity-60'
>
<FormField
control={form.control}
name='openai_organization'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('OpenAI Organization')}
</FormLabel>
<FormControl>
<Input
placeholder={t('org-...')}
{...field}
/>
</FormControl>
<FormDescription>
{sensitiveLocked
? t(
'No permission to perform this action'
)
: t(FIELD_DESCRIPTIONS.OPENAI_ORG)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
)}
</ChannelBasicSection>
</div>
{/* ── API Access ── */}
<div
id={CHANNEL_EDITOR_SECTION_IDS.credentials}
className='scroll-mt-4'
>
<ChannelApiAccessSection>
{CHANNEL_TYPE_WARNINGS[currentType] && (
<Alert>
<AlertDescription>
{t(CHANNEL_TYPE_WARNINGS[currentType])}
</AlertDescription>
</Alert>
)}
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t('No permission to perform this action')}
</AlertDescription>
</Alert>
)}
<div className='border-border/60 bg-muted/10 rounded-lg border p-4'>
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
{/* Azure (type 3) */}
{currentType === 3 && (
<>
<FormField
control={form.control}
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('AZURE_OPENAI_ENDPOINT *')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., https://docs-test-001.openai.azure.com'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('Your Azure OpenAI endpoint URL')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Default API Version *')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., 2025-04-01-preview'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Default API version for this channel'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='azure_responses_version'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Responses API Version')}
</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., preview')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Default Responses API version, if empty, will use the API version above'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{/* Custom (type 8) */}
{currentType === 8 && (
<FormField
control={form.control}
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Full Base URL (supports')} {'{'}
{t('model')}
{'}'} {t('variable) *')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., https://api.openai.com/v1/chat/completions'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('Enter the complete URL, supports')}{' '}
{'{'}
{t('model')}
{'}'} {t('variable')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Xunfei/Spark (type 18) */}
{currentType === 18 && (
<FormField
control={form.control}
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Model Version *')}
</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., v2.1')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Spark model version, e.g., v2.1 (version number in API URL)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* OpenRouter (type 20) */}
{currentType === 20 && (
<FormField
control={form.control}
name='is_enterprise_account'
render={({ field }) => (
<FormItem className='flex items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel>
{t('Enterprise Account')}
</FormLabel>
<FormDescription>
{t(
'Enable if this is an OpenRouter enterprise account with special response format'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{/* AWS (type 33) */}
{currentType === 33 && (
<FormField
control={form.control}
name='aws_key_type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('AWS Key Format')}</FormLabel>
<Select
items={[
{
value: 'ak_sk',
label: t(
'AccessKey / SecretAccessKey'
),
},
{
value: 'api_key',
label: t('API Key'),
},
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Select key format')}
/>
</SelectTrigger>
</FormControl>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='ak_sk'>
{t('AccessKey / SecretAccessKey')}
</SelectItem>
<SelectItem value='api_key'>
{t('API Key')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{field.value === 'api_key'
? t('API Key mode: use APIKey|Region')
: t(
'AK/SK mode: use AccessKey|SecretAccessKey|Region'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* AI Proxy Library (type 21) */}
{currentType === 21 && (
<FormField
control={form.control}
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Knowledge Base ID *')}
</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., 123456')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Enter the knowledge base ID')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* FastGPT (type 22) */}
{currentType === 22 && (
<FormField
control={form.control}
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Private Deployment URL')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., https://fastgpt.run/api/openapi'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'For private deployments, format: https://fastgpt.run/api/openapi'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* SunoAPI (type 36) */}
{currentType === 36 && (
<FormField
control={form.control}
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
'API Base URL (Important: Not Chat API) *'
)}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., https://api.example.com (path before /suno)'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Enter the path before /suno, usually just the domain'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Cloudflare Workers AI (type 39) */}
{currentType === 39 && (
<FormField
control={form.control}
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Account ID *')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., d6b5da8hk1awo8nap34ube6gh'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('Your Cloudflare Account ID')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* SiliconFlow (type 40) */}
{currentType === 40 && (
<Alert>
<AlertDescription>
{t('Referral link:')}{' '}
<a
href='https://cloud.siliconflow.cn/i/hij0YNTZ'
target='_blank'
rel='noopener noreferrer'
className='text-primary underline'
>
{t(
'https://cloud.siliconflow.cn/i/hij0YNTZ'
)}
</a>
</AlertDescription>
</Alert>
)}
{/* Vertex AI (type 41) */}
{currentType === 41 && (
<>
<FormField
control={form.control}
name='vertex_key_type'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Vertex AI Key Format')}
</FormLabel>
<Select
items={[
{ value: 'json', label: t('JSON') },
{
value: 'api_key',
label: t('API Key'),
},
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='json'>
{t('JSON')}
</SelectItem>
<SelectItem value='api_key'>
{t('API Key')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{field.value === 'json'
? t(
'JSON format supports service account JSON files'
)
: t(
'API Key mode (does not support batch creation)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{vertexKeyType === 'json' && (
<FormItem>
<FormLabel>
{t('Service account JSON file(s)')}
</FormLabel>
<FormControl>
<Input
type='file'
accept='.json,application/json'
multiple={isBatchMode}
onChange={async (e) => {
const fileList = e.target.files
const files = fileList
? [...fileList]
: []
// allow re-selecting the same file
e.target.value = ''
if (files.length === 0) {
toast.info(
t('Please upload key file(s)')
)
return
}
const keys: unknown[] = []
for (const file of files) {
try {
const txt = await file.text()
keys.push(JSON.parse(txt))
} catch {
toast.error(
t(
'Failed to parse JSON file: {{name}}',
{
name: file.name,
}
)
)
return
}
}
if (keys.length === 0) {
toast.info(
t('Please upload key file(s)')
)
return
}
const keyValue = isBatchMode
? JSON.stringify(keys)
: JSON.stringify(keys[0])
form.setValue('key', keyValue, {
shouldDirty: true,
shouldValidate: true,
})
toast.success(
t(
'Parsed {{count}} service account file(s)',
{
count: keys.length,
}
)
)
}}
/>
</FormControl>
<FormDescription>
{isBatchMode
? t(
'Upload multiple JSON files in batch modes'
)
: t(
'Upload a single service account JSON file'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
<FormField
control={form.control}
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Deployment Region *')}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'e.g., us-central1 or JSON format for model-specific regions'
)}
rows={3}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Enter deployment region or JSON mapping:'
)}{' '}
{'{'}
{t(
'"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"'
)}
{'}'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{/* VolcEngine (type 45) */}
{currentType === 45 && !doubaoApiEditUnlocked && (
<FormField
control={form.control}
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel
className='cursor-pointer select-none'
onClick={handleApiConfigSecretClick}
>
{t('API Base URL *')}
</FormLabel>
<Select
items={[
{
value:
'https://ark.cn-beijing.volces.com',
label: t(
'https://ark.cn-beijing.volces.com'
),
},
{
value:
'https://ark.ap-southeast.bytepluses.com',
label: t(
'https://ark.ap-southeast.bytepluses.com'
),
},
]}
onValueChange={field.onChange}
value={
field.value === 'doubao-coding-plan'
? 'https://ark.cn-beijing.volces.com'
: field.value ||
'https://ark.cn-beijing.volces.com'
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='https://ark.cn-beijing.volces.com'>
{t(
'https://ark.cn-beijing.volces.com'
)}
</SelectItem>
<SelectItem value='https://ark.ap-southeast.bytepluses.com'>
{t(
'https://ark.ap-southeast.bytepluses.com'
)}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Select the API endpoint region')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* VolcEngine (type 45) - Custom API URL (unlocked) */}
{currentType === 45 && doubaoApiEditUnlocked && (
<FormField
control={form.control}
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API Base URL *')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., https://ark.cn-beijing.volces.com'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('Enter custom API endpoint URL')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Coze (type 49) */}
{currentType === 49 && (
<FormField
control={form.control}
name='other'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Agent ID *')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., 7342866812345')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Enter the Coze agent ID')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* General base_url for other types */}
{![3, 8, 22, 36, 45].includes(currentType) && (
<FormField
control={form.control}
name='base_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Base URL')}</FormLabel>
<FormControl>
<Input
placeholder={t(
FIELD_PLACEHOLDERS.BASE_URL
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{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>
{advancedCustomRouteTypeLabels.map(
(label) => (
<Badge
key={label}
variant='outline'
className='max-w-[12rem]'
title={label}
>
<span className='truncate'>
{label}
</span>
</Badge>
)
)}
{hiddenAdvancedCustomRouteTypeCount >
0 && (
<Badge
variant='outline'
title={
advancedCustomRouteTypeTitle
}
>
+
{
hiddenAdvancedCustomRouteTypeCount
}
</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
control={form.control}
name='multi_key_mode'
render={({ field }) => (
<FormItem className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<FormLabel className='text-muted-foreground text-xs font-medium'>
{t('Add Mode')}
</FormLabel>
<Select
items={addModeOptions.map((option) => ({
value: option.value,
label: t(option.label),
}))}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger
size='sm'
className='w-full sm:w-56'
>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
{addModeOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='key'
render={({ field }) => {
let keyPlaceholder = t(
getKeyPromptForType(currentType)
)
if (isEditing) {
keyPlaceholder = t(
'Leave empty to keep existing key'
)
} else if (
currentType === 33 &&
awsKeyType === 'api_key' &&
isBatchMode
) {
keyPlaceholder = t(
'Enter API Key, one per line, format: APIKey|Region'
)
} else if (
currentType === 33 &&
awsKeyType === 'api_key'
) {
keyPlaceholder = t(
'Enter API Key, format: APIKey|Region'
)
} else if (
currentType === 33 &&
isBatchMode
) {
keyPlaceholder = t(
'Enter key, one per line, format: AccessKey|SecretAccessKey|Region'
)
} else if (currentType === 33) {
keyPlaceholder = t(
'Enter key, format: AccessKey|SecretAccessKey|Region'
)
} else if (isBatchMode) {
keyPlaceholder = t(
'Enter one key per line for batch creation'
)
}
let keyDescription: ReactNode = t(
FIELD_DESCRIPTIONS.KEY
)
if (isEditing) {
let keyModeDescription = t(
'Append mode: New keys will be added to the end of the existing key list'
)
if (keyMode === 'replace') {
keyModeDescription = t(
'Replace mode: Will completely replace all existing keys'
)
}
keyDescription = (
<>
{t(
'Enter new key to update, or leave empty to keep current key'
)}
{isMultiKeyChannel && (
<span className='text-warning mt-1 block'>
{keyModeDescription}
</span>
)}
</>
)
} else if (isBatchMode) {
keyDescription = t(
'Enter one API key per line for batch creation'
)
}
return (
<FormItem>
<FormLabel>{t('API Key *')}</FormLabel>
<FormControl>
<Textarea
placeholder={keyPlaceholder}
rows={isBatchMode ? 8 : 4}
{...field}
/>
</FormControl>
<FormDescription>
<div className='flex flex-col gap-2'>
<span>{keyDescription}</span>
{isBatchMode && (
<Button
type='button'
variant='outline'
size='sm'
onClick={handleDeduplicateKeys}
className='w-fit'
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Remove Duplicates')}
</Button>
)}
</div>
</FormDescription>
{isEditing && canRevealChannelKey && (
<div className='border-border/60 mt-4 flex flex-col gap-3 border-y border-dashed py-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>
{t('Current key')}
</p>
<p className='text-muted-foreground text-xs'>
{t(
'Verification required to reveal the saved key.'
)}
</p>
</div>
<div className='flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleRevealKey}
disabled={
isChannelKeyLoading ||
verificationState.loading
}
>
{isChannelKeyLoading ||
verificationState.loading ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<Eye className='mr-2 h-4 w-4' />
)}
{t('Reveal key')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={async () => {
if (channelKey) {
await copyToClipboard(
channelKey
)
}
}}
disabled={!channelKey}
>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
</div>
</div>
<Input
readOnly
value={channelKey ?? ''}
placeholder={t(
'Hidden — verify to reveal'
)}
className='font-mono'
/>
</div>
)}
<FormMessage />
</FormItem>
)
}}
/>
{currentType === 57 && (
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground text-xs'>
{t(
'Codex channels use an OAuth JSON credential as the key.'
)}
</div>
<div className='flex flex-wrap items-center gap-2'>
{isEditing && channelId && (
<Button
type='button'
variant='outline'
size='sm'
onClick={handleRefreshCodexCredential}
disabled={
sensitiveLocked ||
isCodexCredentialRefreshing
}
>
{isCodexCredentialRefreshing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{isCodexCredentialRefreshing
? t('Refreshing...')
: t('Refresh credential')}
</Button>
)}
</div>
</div>
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t(
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel."
)}
</AlertDescription>
</Alert>
</div>
)}
{isEditing && isMultiKeyChannel && (
<FormField
control={form.control}
name='key_mode'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Key Update Mode')}
</FormLabel>
<Select
items={[
{
value: 'append',
label: t('Append to existing keys'),
},
{
value: 'replace',
label: t(
'Replace all existing keys'
),
},
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='append'>
{t('Append to existing keys')}
</SelectItem>
<SelectItem value='replace'>
{t('Replace all existing keys')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{field.value === 'replace'
? t(
'Replace mode: Will completely replace all existing keys'
)
: t(
'Append mode: New keys will be added to the end of the existing key list'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!isEditing &&
multiKeyMode === 'multi_to_single' && (
<FormField
control={form.control}
name='multi_key_type'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Multi-Key Strategy')}
</FormLabel>
<Select
items={[
{
value: 'random',
label: t('Random'),
},
{
value: 'polling',
label: t('Polling'),
},
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent
alignItemWithTrigger={false}
>
<SelectGroup>
<SelectItem value='random'>
{t('Random')}
</SelectItem>
<SelectItem value='polling'>
{t('Polling')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{multiKeyType === 'polling' ? (
<span className='text-warning'>
{t(
'Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded'
)}
</span>
) : (
t(
'Randomly select a key from the pool for each request'
)
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</ChannelAuthSection>
</fieldset>
</div>
</ChannelApiAccessSection>
</div>
{/* ── Models & Groups ── */}
<div
id={CHANNEL_EDITOR_SECTION_IDS.models}
className='scroll-mt-4'
>
<ChannelModelsSection>
<div className='space-y-5'>
<div className='border-border/60 bg-muted/10 rounded-lg border p-4'>
<FormField
control={form.control}
name='models'
render={() => (
<FormItem className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<FormLabel>{t('Models *')}</FormLabel>
<FormDescription>
{t(FIELD_DESCRIPTIONS.MODELS)}
</FormDescription>
</div>
<Badge variant='outline' className='w-fit'>
{t('Selected {{count}}', {
count: currentModelsArray.length,
})}
</Badge>
</div>
<FormControl>
<MultiSelect
options={modelOptions}
selected={currentModelsArray}
onChange={handleModelsChange}
placeholder={t(
'Select models or add custom ones'
)}
allowCreate
createLabel='Add custom model "{{value}}"'
maxVisibleChips={8}
copyChipOnClick
/>
</FormControl>
{modelMappingGuardrail.exposedTargetModels
.length > 0 && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<span>
{t('The mapped upstream model(s)')}{' '}
{formatModelNames(
modelMappingGuardrail.exposedTargetModels
)}{' '}
{t(
'are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.'
)}
</span>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
const hiddenTargets = new Set(
modelMappingGuardrail.exposedTargetModels
)
updateModels(
currentModelsArray.filter(
(model) =>
!hiddenTargets.has(model)
)
)
}}
>
{t('Remove mapped targets')}
</Button>
</AlertDescription>
</Alert>
)}
<FormMessage />
</FormItem>
)}
/>
<Separator className='my-4' />
<div className='space-y-3'>
<div>
<p className='text-sm font-medium'>
{t('Quick actions')}
</p>
<p className='text-muted-foreground text-xs'>
{t(
'Use presets or upstream discovery to populate the model list faster.'
)}
</p>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFillRelatedModels}
disabled={!basicModels.length}
>
<FileText
className='mr-2 h-4 w-4'
aria-hidden='true'
/>
{t('Fill Related Models')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFillAllModels}
disabled={!allModelsList.length}
>
<Plus
className='mr-2 h-4 w-4'
aria-hidden='true'
/>
{t('Fill All Models')}
</Button>
{MODEL_FETCHABLE_TYPES.has(currentType) && (
<>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFetchModels}
disabled={!isEditing && !canEditSensitive}
>
<Sparkles
className='mr-2 h-4 w-4'
aria-hidden='true'
/>
{t('Fetch from Upstream')}
</Button>
{!isEditing && !canEditSensitive && (
<span className='text-muted-foreground basis-full text-xs'>
{t(
'No permission to perform this action'
)}
</span>
)}
</>
)}
<Button
type='button'
variant='outline'
size='sm'
onClick={handleCopyModels}
disabled={currentModelsArray.length === 0}
>
<Copy
className='mr-2 h-4 w-4'
aria-hidden='true'
/>
{t('Copy All')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={handleClearModels}
disabled={currentModelsArray.length === 0}
>
<Eraser
className='mr-2 h-4 w-4'
aria-hidden='true'
/>
{t('Clear All')}
</Button>
</div>
{prefillGroups.length > 0 && (
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{t('Preset groups')}:
</span>
{prefillGroups.map((group) => (
<Button
key={group.id}
type='button'
variant='secondary'
size='sm'
onClick={() =>
handleAddPrefillGroup(group)
}
>
{group.name}
</Button>
))}
</div>
)}
</div>
</div>
<div className='border-border/60 rounded-lg border p-4'>
<FormField
control={form.control}
name='model_mapping'
render={({ field }) => (
<FormItem className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<FormLabel className='mb-0'>
{t('Model Mapping')}
</FormLabel>
<Tooltip>
<TooltipTrigger
render={
<Button
type='button'
variant='ghost'
size='icon-sm'
className='text-muted-foreground hover:text-foreground size-auto p-0'
aria-label={t(
'How model mapping works'
)}
/>
}
>
<HelpCircle
className='h-4 w-4'
aria-hidden='true'
/>
</TooltipTrigger>
<TooltipContent
side='top'
align='start'
className='max-w-xs space-y-2 text-left'
>
<p className='text-xs font-semibold tracking-wide uppercase'>
{t('Request flow')}
</p>
<div className='space-y-1 font-mono text-xs'>
{mappingPreviewPairs.map(
(pair) => (
<div
key={`${pair.source}-${pair.target}`}
className='flex items-center gap-1'
>
<span>{pair.source}</span>
<ArrowRight
className='h-3.5 w-3.5 opacity-70'
aria-hidden='true'
/>
<span>{pair.target}</span>
</div>
)
)}
{remainingMappingCount > 0 && (
<div className='text-[11px] opacity-70'>
+{remainingMappingCount}{' '}
{t('more mapping')}
{remainingMappingCount > 1
? 's'
: ''}
</div>
)}
</div>
<p className='text-[11px] leading-relaxed opacity-80'>
{t(
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
)}
</p>
</TooltipContent>
</Tooltip>
</div>
<FormDescription>
{t(FIELD_DESCRIPTIONS.MODEL_MAPPING)}
</FormDescription>
</div>
</div>
<FormControl>
<ModelMappingEditor
value={field.value || ''}
onChange={field.onChange}
disabled={isSubmitting}
sourceModelOptions={currentModelsArray}
targetModelOptions={modelOptions.map(
(option) => option.value
)}
/>
</FormControl>
{modelMappingGuardrail.invalidJson && (
<Alert variant='destructive'>
<AlertDescription>
{t(
'Model Mapping must be a JSON object like'
)}{' '}
<code className='font-mono'>
{'{"gpt-4":"Azure-GPT4"}'}
</code>
{t(
'. Please fix the JSON before saving.'
)}
</AlertDescription>
</Alert>
)}
{modelMappingGuardrail.missingSourceModels
.length > 0 && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<span>
{t('Add')}{' '}
{formatModelNames(
modelMappingGuardrail.missingSourceModels
)}{' '}
{t(
'to the Models list so users can use them before the mapping sends traffic upstream.'
)}
</span>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
updateModels([
...currentModelsArray,
...modelMappingGuardrail.missingSourceModels,
])
}}
>
{t('Add missing models')}
</Button>
</AlertDescription>
</Alert>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='border-border/60 rounded-lg border p-4'>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem className='space-y-3'>
<div className='space-y-1'>
<FormLabel>{t('Groups *')}</FormLabel>
<FormDescription>
{t(FIELD_DESCRIPTIONS.GROUP)}
</FormDescription>
</div>
<FormControl>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={field.value}
onChange={field.onChange}
placeholder={t(
FIELD_PLACEHOLDERS.GROUP
)}
/>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</ChannelModelsSection>
</div>
<div
id={CHANNEL_EDITOR_SECTION_IDS.advanced}
className='scroll-mt-4'
>
<ChannelAdvancedSection
open={advancedSettingsOpen}
onOpenChange={handleAdvancedSettingsOpenChange}
summary={advancedSummary}
>
{/* ── Routing & Overrides ── */}
<div className={sideDrawerSectionClassName()}>
<CardHeading
title={t('Routing & Overrides')}
icon={<Route className='h-4 w-4' />}
/>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.routingStrategy}
className={configuredAdvancedSectionClassName(
'flex scroll-mt-4 flex-col gap-4',
routingStrategyConfigured
)}
>
<SubHeading
title={t('Routing Strategy')}
icon={<Route className='h-3.5 w-3.5' />}
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='priority'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Priority')}</FormLabel>
<FormControl>
<Input
type='number'
placeholder='0'
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormDescription>
{t(FIELD_DESCRIPTIONS.PRIORITY)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='weight'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Weight')}</FormLabel>
<FormControl>
<Input
type='number'
placeholder='0'
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormDescription>
{t(FIELD_DESCRIPTIONS.WEIGHT)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='test_model'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Test Model')}</FormLabel>
<FormControl>
<Input
placeholder={t(
FIELD_PLACEHOLDERS.TEST_MODEL
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(FIELD_DESCRIPTIONS.TEST_MODEL)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='auto_ban'
render={({ field }) => (
<FormItem className='flex items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel>{t('Auto Ban')}</FormLabel>
<FormDescription>
{t(FIELD_DESCRIPTIONS.AUTO_BAN)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value === 1}
onCheckedChange={(checked) =>
field.onChange(checked ? 1 : 0)
}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.internalNotes}
className={configuredAdvancedSectionClassName(
'flex scroll-mt-4 flex-col gap-4 border-t pt-4',
internalNotesConfigured
)}
>
<SubHeading
title={t('Internal Notes')}
icon={<FileText className='h-3.5 w-3.5' />}
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='tag'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Tag')}</FormLabel>
<FormControl>
<Input
placeholder={t(FIELD_PLACEHOLDERS.TAG)}
{...field}
/>
</FormControl>
<FormDescription>
{t(FIELD_DESCRIPTIONS.TAG)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='remark'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Remark')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
FIELD_PLACEHOLDERS.REMARK
)}
rows={2}
{...field}
/>
</FormControl>
<FormDescription>
{t(FIELD_DESCRIPTIONS.REMARK)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div
id={ADVANCED_SETTINGS_SECTION_IDS.overrideRules}
className={configuredAdvancedSectionClassName(
'flex scroll-mt-4 flex-col gap-4 border-t pt-4',
overrideRulesConfigured
)}
>
<SubHeading
title={t('Override Rules')}
icon={<Code className='h-3.5 w-3.5' />}
/>
<FormField
control={form.control}
name='status_code_mapping'
render={({ field }) => (
<FormItem className='space-y-3'>
<div className='space-y-1'>
<FormLabel>
{t('Status Code Mapping')}
</FormLabel>
<FormDescription>
{t(
'Map upstream status codes to different codes'
)}
</FormDescription>
</div>
<FormControl>
<JsonEditor
value={field.value || ''}
onChange={field.onChange}
disabled={isSubmitting}
keyPlaceholder='400'
valuePlaceholder='500'
keyLabel='Original Code'
valueLabel='Mapped Code'
emptyMessage={t(
'No status code mappings configured.'
)}
template={{ '400': '500', '429': '503' }}
valueType='string'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{sensitiveLocked && (
<p className='text-muted-foreground text-xs'>
{t('No permission to perform this action')}
</p>
)}
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
<FormField
control={form.control}
name='param_override'
render={({ field }) => (
<FormItem className='space-y-3 border-t pt-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1'>
<FormLabel>
{t('Parameter Override')}
</FormLabel>
<FormDescription>
{t(
'Override request parameters. Cannot override stream parameter.'
)}
</FormDescription>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setParamOverrideEditorOpen(true)
}
>
<Wand2 className='mr-2 h-4 w-4' />
{t('Visual edit')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
field.onChange(
JSON.stringify(
{
operations: [
{
path: 'temperature',
mode: 'set',
value: 0.7,
conditions: [
{
path: 'model',
mode: 'prefix',
value: 'gpt',
},
],
logic: 'AND',
},
],
},
null,
2
)
)
}}
>
<Code className='mr-2 h-4 w-4' />
{t('New Format Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => field.onChange('')}
>
{t('Clear')}
</Button>
</div>
</div>
<FormControl>
<Textarea
value={field.value || ''}
onChange={field.onChange}
disabled={
sensitiveLocked || isSubmitting
}
rows={8}
placeholder={t(
'Override request parameters. Cannot override stream parameter.'
)}
className='max-h-72 min-h-40 resize-y overflow-auto font-mono text-xs'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='header_override'
render={({ field }) => (
<FormItem className='space-y-3 border-t pt-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1'>
<FormLabel>
{t('Request Header Override')}
</FormLabel>
<FormDescription>
{t('Override request headers')}
</FormDescription>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
field.onChange(
JSON.stringify(
{
'*': true,
're:^X-Trace-.*$': true,
'X-Foo':
'{client_header:X-Foo}',
Authorization:
'Bearer {api_key}',
},
null,
2
)
)
}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
field.onChange(
JSON.stringify(
{ '*': true },
null,
2
)
)
}
>
{t('Passthrough Template')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
try {
const parsed = JSON.parse(
field.value || '{}'
)
field.onChange(
JSON.stringify(parsed, null, 2)
)
} catch {
/* ignore invalid JSON */
}
}}
>
{t('Format')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => field.onChange('')}
>
{t('Clear')}
</Button>
</div>
</div>
<FormControl>
<Textarea
className='font-mono text-sm'
rows={6}
value={field.value || ''}
onChange={field.onChange}
disabled={
sensitiveLocked || isSubmitting
}
placeholder={t(
'Enter JSON to override request headers'
)}
/>
</FormControl>
<FormDescription className='text-xs'>
{t('Supported variables')}:{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{'{api_key}'}
</code>{' '}
{t('Channel key')},{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{'{client_header:NAME}'}
</code>{' '}
{t('Client header value')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
</div>
{/* ── Extra Settings ── */}
<div
id={ADVANCED_SETTINGS_SECTION_IDS.extraSettings}
className={sideDrawerSectionClassName(
configuredAdvancedSectionClassName(
'scroll-mt-4',
extraSettingsConfigured
)
)}
>
<CardHeading
title={t('Channel Extra Settings')}
icon={<Settings className='h-4 w-4' />}
/>
{sensitiveLocked && (
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
<AlertDescription>
{t('No permission to perform this action')}
</AlertDescription>
</Alert>
)}
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
<div className='divide-border space-y-0 divide-y border-y'>
{currentType === 1 && (
<FormField
control={form.control}
name='force_format'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Force Format')}
</FormLabel>
<FormDescription>
{t(
'Force format response to OpenAI standard (OpenAI channel only)'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='thinking_to_content'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Thinking to Content')}
</FormLabel>
<FormDescription>
{t(
'Convert reasoning_content to <think> tag in content'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='pass_through_body_enabled'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Pass Through Body')}
</FormLabel>
<FormDescription>
{t(
'Pass request body directly to upstream'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='disable_task_polling_sleep'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Skip async task polling delay')}
</FormLabel>
<FormDescription>
{t(
'Do not wait one second between polling async tasks for this channel'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='proxy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Proxy Address')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'socks5://user:pass@host:port'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Network proxy for this channel (supports socks5 protocol)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='system_prompt'
render={({ field }) => (
<FormItem>
<FormLabel>{t('System Prompt')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter system prompt (user prompt takes priority)'
)}
rows={3}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Default system prompt for this channel'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='system_prompt_override'
render={({ field }) => (
<FormItem className='flex items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel>
{t('System Prompt Concatenation')}
</FormLabel>
<FormDescription>
{t(
'Concatenate channel system prompt with user&apos;s prompt'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</fieldset>
</div>
{(currentType === 1 || currentType === 14) && (
<div
id={ADVANCED_SETTINGS_SECTION_IDS.fieldPassthrough}
className={sideDrawerSectionClassName(
configuredAdvancedSectionClassName(
'scroll-mt-4',
fieldPassthroughConfigured
)
)}
>
<CardHeading
title={t('Field passthrough controls')}
icon={<SlidersHorizontal className='h-4 w-4' />}
/>
<fieldset
disabled={sensitiveLocked}
className='disabled:opacity-60'
>
<div className='divide-border space-y-0 divide-y border-y'>
<FormField
control={form.control}
name='allow_service_tier'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Allow service_tier passthrough')}
</FormLabel>
<FormDescription>
{t(
'Pass through the service_tier field'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{currentType === 1 && (
<>
<FormField
control={form.control}
name='disable_store'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Disable store passthrough')}
</FormLabel>
<FormDescription>
{t(
'When enabled, the store field will be blocked'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_safety_identifier'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t(
'Allow safety_identifier passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the safety_identifier field'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_include_obfuscation'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t(
'Allow include usage obfuscation passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the include field for usage obfuscation'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_inference_geo'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t(
'Allow inference geography passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the inference_geo field for geographic routing'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{currentType === 14 && (
<>
<FormField
control={form.control}
name='allow_inference_geo'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t(
'Allow inference_geo passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the inference_geo field for Claude data residency region control'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_speed'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Allow speed passthrough')}
</FormLabel>
<FormDescription>
{t(
'Pass through the speed field for Claude inference speed mode control'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='claude_beta_query'
render={({ field }) => (
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t(
'Allow Claude beta query passthrough'
)}
</FormLabel>
<FormDescription>
{t(
'Pass through the anthropic-beta header for beta features'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
</fieldset>
</div>
)}
{MODEL_FETCHABLE_TYPES.has(currentType) && (
<div
id={
ADVANCED_SETTINGS_SECTION_IDS.upstreamModelDetection
}
className={sideDrawerSectionClassName(
configuredAdvancedSectionClassName(
'scroll-mt-4',
upstreamModelDetectionConfigured
)
)}
>
<CardHeading
title={t('Upstream Model Detection Settings')}
icon={<RefreshCw className='h-4 w-4' />}
/>
<fieldset
disabled={sensitiveLocked}
className='space-y-4 disabled:opacity-60'
>
<div className='divide-border space-y-0 divide-y border-y'>
<FormField
control={form.control}
name='upstream_model_update_check_enabled'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Upstream Model Update Check')}
</FormLabel>
<FormDescription>
{t(
'Periodically check for upstream model changes'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='upstream_model_update_auto_sync_enabled'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>
{t('Auto Sync Upstream Models')}
</FormLabel>
<FormDescription>
{t(
'Automatically sync model list when upstream changes are detected'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
disabled={
!upstreamModelUpdateCheckEnabled
}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='upstream_model_update_ignored_models'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Ignored upstream models')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Comma-separated exact model names. Prefix with regex: to ignore by regular expression.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='text-muted-foreground space-y-2 border-t pt-3 text-xs'>
<div>
<span className='text-foreground font-medium'>
{t('Last check time')}:
</span>{' '}
{formatUnixTime(
upstreamUpdateMeta.lastCheckTime
)}
</div>
<div>
<span className='text-foreground font-medium'>
{t('Last detected addable models')}:
</span>{' '}
{upstreamUpdateMeta.detectedModels.length ===
0 ? (
t('None')
) : (
<>
<span className='break-all'>
{upstreamDetectedModelsPreview.join(
', '
)}
</span>
{upstreamDetectedModelsOmittedCount >
0 && (
<span className='ml-1'>
{t(
'({{total}} total, {{omit}} omitted)',
{
total:
upstreamUpdateMeta
.detectedModels.length,
omit: upstreamDetectedModelsOmittedCount,
}
)}
</span>
)}
</>
)}
</div>
</div>
</fieldset>
</div>
)}
</ChannelAdvancedSection>
</div>
</div>
</div>
)}
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose
render={<Button variant='outline' disabled={isSubmitting} />}
>
{t('Cancel')}
</SheetClose>
<Button form='channel-form' type='submit' disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{isEditing ? t('Update Channel') : t('Save changes')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{paramOverrideEditorOpen && !sensitiveLocked && (
<ParamOverrideEditorDialog
open={paramOverrideEditorOpen}
value={form.watch('param_override') || ''}
onOpenChange={setParamOverrideEditorOpen}
onSave={(nextValue) => {
form.setValue('param_override', nextValue, {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
)}
{advancedCustomEditorOpen && !sensitiveLocked && (
<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}
onOpenChange={setFetchModelsDialogOpen}
onModelsSelected={(models) => {
form.setValue('models', formatModelsArray(models))
}}
redirectModels={redirectModelList}
redirectSourceModels={redirectModelKeyList}
customFetcher={!isEditing ? createModeFetcher : undefined}
channelName={!isEditing ? currentName?.trim() : undefined}
existingModelsOverride={
!isEditing
? parseModelsString(form.getValues('models') || '')
: undefined
}
/>
<SecureVerificationDialog
open={verificationOpen}
onOpenChange={(open) => {
if (!open) {
cancelVerification()
}
}}
methods={verificationMethods}
state={verificationState}
onVerify={async (method, code) => {
await executeVerification(method, code)
}}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodChange={switchVerificationMethod}
/>
{/* Missing Models Confirmation Dialog */}
<MissingModelsConfirmationDialog
open={missingModelsDialogOpen}
missingModels={missingModelsList}
onConfirm={handleMissingModelsAction}
onOpenChange={setMissingModelsDialogOpen}
/>
<StatusCodeRiskDialog
open={statusCodeRiskOpen}
onOpenChange={(v) => {
if (!v) handleStatusCodeRiskAction(false)
}}
detailItems={statusCodeRiskDetailItems}
onConfirm={() => handleStatusCodeRiskAction(true)}
/>
</>
)
}