/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { 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 { if (!settings?.trim()) return {} try { const parsed = JSON.parse(settings) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } } 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 ( {icon && ( {icon} )} {title} ) } function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) { return ( {icon && {icon}} {title} ) } 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 ( ) } return ( {getLobeIcon(`${getChannelTypeIcon(props.type)}.Color`, props.size ?? 16)} ) } function getSectionStatusIcon(status: ChannelEditorSectionStatus): ReactNode { if (status === 'error') { return } if (status === 'complete' || status === 'configured') { return } return } 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 ( ) } 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(null) const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false) const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] = useState(false) const initialModelsRef = useRef([]) const initialModelMappingRef = useRef('') const initialStatusCodeMappingRef = useRef('') 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([]) const missingModelsResolveRef = useRef< ((action: MissingModelsAction) => void) | null >(null) const channelFormRef = useRef(null) const advancedNavScrollPendingRef = useRef(false) const [activeEditorSectionId, setActiveEditorSectionId] = useState( 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({ 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: , })) if (!options.some((option) => Number(option.value) === currentType)) { options.push({ value: String(currentType), label: `#${currentType}`, icon: , }) } 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: , }, { id: CHANNEL_EDITOR_SECTION_IDS.credentials, title: t('Credentials'), description: getSectionStatusLabel(credentialsStatus, t), statusLabel: getSectionStatusLabel(credentialsStatus, t), status: credentialsStatus, icon: , }, { id: CHANNEL_EDITOR_SECTION_IDS.models, title: t('Models & Groups'), description: getSectionStatusLabel(modelsStatus, t), statusLabel: getSectionStatusLabel(modelsStatus, t), status: modelsStatus, icon: , }, { id: CHANNEL_EDITOR_SECTION_IDS.advanced, title: t('Advanced Settings'), description: advancedSummary, statusLabel: advancedSummary ?? t('Advanced Settings'), status: advancedStatus, icon: , 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(() => { 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 => { 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 => { 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 => 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 > 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(`#${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( `#${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 = 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 ( <> {isEditing ? t('Edit Channel') : t('Create Channel')} {t(currentTypeLabel)} {isEditing ? t( "Update channel configuration and click save when you're done." ) : t( 'Add a new channel by providing the necessary information.' )} {sensitiveLocked && ( {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.' )} )} {isChannelDetailLoading ? ( ) : ( } providerLabel={t(currentTypeLabel)} statusLabel={t(currentStatusLabel)} progressLabel={progressLabel} navigationLabel={t('Channels')} items={editorNavItems} activeItemId={activeEditorSectionId} expandedItemId={expandedEditorNavItemId} onNavigate={handleEditorNavNavigate} /> {/* ── Basic Information ── */} ( {t('Type *')} { 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} /> {sensitiveLocked && ( {t( 'No permission to perform this action' )} )} )} /> ( {t('Name *')} )} /> {!isEditing && ( ( {t('Enabled')} {t('Enable or disable this channel')} field.onChange(checked ? 1 : 2) } /> )} /> )} {currentType === 1 && ( ( {t('OpenAI Organization')} {sensitiveLocked ? t( 'No permission to perform this action' ) : t(FIELD_DESCRIPTIONS.OPENAI_ORG)} )} /> )} {/* ── API Access ── */} {CHANNEL_TYPE_WARNINGS[currentType] && ( {t(CHANNEL_TYPE_WARNINGS[currentType])} )} {sensitiveLocked && ( {t('No permission to perform this action')} )} {/* Azure (type 3) */} {currentType === 3 && ( <> ( {t('AZURE_OPENAI_ENDPOINT *')} {t('Your Azure OpenAI endpoint URL')} )} /> ( {t('Default API Version *')} {t( 'Default API version for this channel' )} )} /> ( {t('Responses API Version')} {t( 'Default Responses API version, if empty, will use the API version above' )} )} /> > )} {/* Custom (type 8) */} {currentType === 8 && ( ( {t('Full Base URL (supports')} {'{'} {t('model')} {'}'} {t('variable) *')} {t('Enter the complete URL, supports')}{' '} {'{'} {t('model')} {'}'} {t('variable')} )} /> )} {/* Xunfei/Spark (type 18) */} {currentType === 18 && ( ( {t('Model Version *')} {t( 'Spark model version, e.g., v2.1 (version number in API URL)' )} )} /> )} {/* OpenRouter (type 20) */} {currentType === 20 && ( ( {t('Enterprise Account')} {t( 'Enable if this is an OpenRouter enterprise account with special response format' )} )} /> )} {/* AWS (type 33) */} {currentType === 33 && ( ( {t('AWS Key Format')} {t('AccessKey / SecretAccessKey')} {t('API Key')} {field.value === 'api_key' ? t('API Key mode: use APIKey|Region') : t( 'AK/SK mode: use AccessKey|SecretAccessKey|Region' )} )} /> )} {/* AI Proxy Library (type 21) */} {currentType === 21 && ( ( {t('Knowledge Base ID *')} {t('Enter the knowledge base ID')} )} /> )} {/* FastGPT (type 22) */} {currentType === 22 && ( ( {t('Private Deployment URL')} {t( 'For private deployments, format: https://fastgpt.run/api/openapi' )} )} /> )} {/* SunoAPI (type 36) */} {currentType === 36 && ( ( {t( 'API Base URL (Important: Not Chat API) *' )} {t( 'Enter the path before /suno, usually just the domain' )} )} /> )} {/* Cloudflare Workers AI (type 39) */} {currentType === 39 && ( ( {t('Account ID *')} {t('Your Cloudflare Account ID')} )} /> )} {/* SiliconFlow (type 40) */} {currentType === 40 && ( {t('Referral link:')}{' '} {t( 'https://cloud.siliconflow.cn/i/hij0YNTZ' )} )} {/* Vertex AI (type 41) */} {currentType === 41 && ( <> ( {t('Vertex AI Key Format')} {t('JSON')} {t('API Key')} {field.value === 'json' ? t( 'JSON format supports service account JSON files' ) : t( 'API Key mode (does not support batch creation)' )} )} /> {vertexKeyType === 'json' && ( {t('Service account JSON file(s)')} { 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, } ) ) }} /> {isBatchMode ? t( 'Upload multiple JSON files in batch modes' ) : t( 'Upload a single service account JSON file' )} )} ( {t('Deployment Region *')} {t( 'Enter deployment region or JSON mapping:' )}{' '} {'{'} {t( '"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"' )} {'}'} )} /> > )} {/* VolcEngine (type 45) */} {currentType === 45 && !doubaoApiEditUnlocked && ( ( {t('API Base URL *')} {t( 'https://ark.cn-beijing.volces.com' )} {t( 'https://ark.ap-southeast.bytepluses.com' )} {t('Select the API endpoint region')} )} /> )} {/* VolcEngine (type 45) - Custom API URL (unlocked) */} {currentType === 45 && doubaoApiEditUnlocked && ( ( {t('API Base URL *')} {t('Enter custom API endpoint URL')} )} /> )} {/* Coze (type 49) */} {currentType === 49 && ( ( {t('Agent ID *')} {t('Enter the Coze agent ID')} )} /> )} {/* General base_url for other types */} {![3, 8, 22, 36, 45].includes(currentType) && ( ( {t('Base URL')} {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.' )} )} /> )} {currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && ( ( {t('Advanced Custom Routes')} {t('Routes')}:{' '} {advancedCustomStats.routeCount} {advancedCustomRouteTypeLabels.map( (label) => ( {label} ) )} {hiddenAdvancedCustomRouteTypeCount > 0 && ( + { hiddenAdvancedCustomRouteTypeCount } )} {!advancedCustomStats.valid && ( {t('Incomplete')} )} setAdvancedCustomEditorOpen(true) } > {t('Configure routes')} )} /> )} {!isEditing && ( ( {t('Add Mode')} ({ value: option.value, label: t(option.label), }))} onValueChange={field.onChange} value={field.value} > {addModeOptions.map((option) => ( {t(option.label)} ))} )} /> )} { 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 && ( {keyModeDescription} )} > ) } else if (isBatchMode) { keyDescription = t( 'Enter one API key per line for batch creation' ) } return ( {t('API Key *')} {keyDescription} {isBatchMode && ( {t('Remove Duplicates')} )} {isEditing && canRevealChannelKey && ( {t('Current key')} {t( 'Verification required to reveal the saved key.' )} {isChannelKeyLoading || verificationState.loading ? ( ) : ( )} {t('Reveal key')} { if (channelKey) { await copyToClipboard( channelKey ) } }} disabled={!channelKey} > {t('Copy')} )} ) }} /> {currentType === 57 && ( {t( 'Codex channels use an OAuth JSON credential as the key.' )} {isEditing && channelId && ( {isCodexCredentialRefreshing ? ( ) : ( )} {isCodexCredentialRefreshing ? t('Refreshing...') : t('Refresh credential')} )} {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." )} )} {isEditing && isMultiKeyChannel && ( ( {t('Key Update Mode')} {t('Append to existing keys')} {t('Replace all existing keys')} {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' )} )} /> )} {!isEditing && multiKeyMode === 'multi_to_single' && ( ( {t('Multi-Key Strategy')} {t('Random')} {t('Polling')} {multiKeyType === 'polling' ? ( {t( 'Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded' )} ) : ( t( 'Randomly select a key from the pool for each request' ) )} )} /> )} {/* ── Models & Groups ── */} ( {t('Models *')} {t(FIELD_DESCRIPTIONS.MODELS)} {t('Selected {{count}}', { count: currentModelsArray.length, })} {modelMappingGuardrail.exposedTargetModels .length > 0 && ( {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.' )} { const hiddenTargets = new Set( modelMappingGuardrail.exposedTargetModels ) updateModels( currentModelsArray.filter( (model) => !hiddenTargets.has(model) ) ) }} > {t('Remove mapped targets')} )} )} /> {t('Quick actions')} {t( 'Use presets or upstream discovery to populate the model list faster.' )} {t('Fill Related Models')} {t('Fill All Models')} {MODEL_FETCHABLE_TYPES.has(currentType) && ( <> {t('Fetch from Upstream')} {!isEditing && !canEditSensitive && ( {t( 'No permission to perform this action' )} )} > )} {t('Copy All')} {t('Clear All')} {prefillGroups.length > 0 && ( {t('Preset groups')}: {prefillGroups.map((group) => ( handleAddPrefillGroup(group) } > {group.name} ))} )} ( {t('Model Mapping')} } > {t('Request flow')} {mappingPreviewPairs.map( (pair) => ( {pair.source} {pair.target} ) )} {remainingMappingCount > 0 && ( +{remainingMappingCount}{' '} {t('more mapping')} {remainingMappingCount > 1 ? 's' : ''} )} {t( 'Users call the model on the left. The platform forwards the request to the upstream model on the right.' )} {t(FIELD_DESCRIPTIONS.MODEL_MAPPING)} option.value )} /> {modelMappingGuardrail.invalidJson && ( {t( 'Model Mapping must be a JSON object like' )}{' '} {'{"gpt-4":"Azure-GPT4"}'} {t( '. Please fix the JSON before saving.' )} )} {modelMappingGuardrail.missingSourceModels .length > 0 && ( {t('Add')}{' '} {formatModelNames( modelMappingGuardrail.missingSourceModels )}{' '} {t( 'to the Models list so users can use them before the mapping sends traffic upstream.' )} { updateModels([ ...currentModelsArray, ...modelMappingGuardrail.missingSourceModels, ]) }} > {t('Add missing models')} )} )} /> ( {t('Groups *')} {t(FIELD_DESCRIPTIONS.GROUP)} {isLoadingGroups ? ( ) : ( )} )} /> {/* ── Routing & Overrides ── */} } /> } /> ( {t('Priority')} field.onChange(Number(e.target.value)) } /> {t(FIELD_DESCRIPTIONS.PRIORITY)} )} /> ( {t('Weight')} field.onChange(Number(e.target.value)) } /> {t(FIELD_DESCRIPTIONS.WEIGHT)} )} /> ( {t('Test Model')} {t(FIELD_DESCRIPTIONS.TEST_MODEL)} )} /> ( {t('Auto Ban')} {t(FIELD_DESCRIPTIONS.AUTO_BAN)} field.onChange(checked ? 1 : 0) } /> )} /> } /> ( {t('Tag')} {t(FIELD_DESCRIPTIONS.TAG)} )} /> ( {t('Remark')} {t(FIELD_DESCRIPTIONS.REMARK)} )} /> } /> ( {t('Status Code Mapping')} {t( 'Map upstream status codes to different codes' )} )} /> {sensitiveLocked && ( {t('No permission to perform this action')} )} ( {t('Parameter Override')} {t( 'Override request parameters. Cannot override stream parameter.' )} setParamOverrideEditorOpen(true) } > {t('Visual edit')} { field.onChange( JSON.stringify( { operations: [ { path: 'temperature', mode: 'set', value: 0.7, conditions: [ { path: 'model', mode: 'prefix', value: 'gpt', }, ], logic: 'AND', }, ], }, null, 2 ) ) }} > {t('New Format Template')} field.onChange('')} > {t('Clear')} )} /> ( {t('Request Header Override')} {t('Override request headers')} field.onChange( JSON.stringify( { '*': true, 're:^X-Trace-.*$': true, 'X-Foo': '{client_header:X-Foo}', Authorization: 'Bearer {api_key}', }, null, 2 ) ) } > {t('Fill Template')} field.onChange( JSON.stringify( { '*': true }, null, 2 ) ) } > {t('Passthrough Template')} { try { const parsed = JSON.parse( field.value || '{}' ) field.onChange( JSON.stringify(parsed, null, 2) ) } catch { /* ignore invalid JSON */ } }} > {t('Format')} field.onChange('')} > {t('Clear')} {t('Supported variables')}:{' '} {'{api_key}'} {' '} — {t('Channel key')},{' '} {'{client_header:NAME}'} {' '} — {t('Client header value')} )} /> {/* ── Extra Settings ── */} } /> {sensitiveLocked && ( {t('No permission to perform this action')} )} {currentType === 1 && ( ( {t('Force Format')} {t( 'Force format response to OpenAI standard (OpenAI channel only)' )} )} /> )} ( {t('Thinking to Content')} {t( 'Convert reasoning_content to tag in content' )} )} /> ( {t('Pass Through Body')} {t( 'Pass request body directly to upstream' )} )} /> ( {t('Skip async task polling delay')} {t( 'Do not wait one second between polling async tasks for this channel' )} )} /> ( {t('Proxy Address')} {t( 'Network proxy for this channel (supports socks5 protocol)' )} )} /> ( {t('System Prompt')} {t( 'Default system prompt for this channel' )} )} /> ( {t('System Prompt Concatenation')} {t( 'Concatenate channel system prompt with user's prompt' )} )} /> {(currentType === 1 || currentType === 14) && ( } /> ( {t('Allow service_tier passthrough')} {t( 'Pass through the service_tier field' )} )} /> {currentType === 1 && ( <> ( {t('Disable store passthrough')} {t( 'When enabled, the store field will be blocked' )} )} /> ( {t( 'Allow safety_identifier passthrough' )} {t( 'Pass through the safety_identifier field' )} )} /> ( {t( 'Allow include usage obfuscation passthrough' )} {t( 'Pass through the include field for usage obfuscation' )} )} /> ( {t( 'Allow inference geography passthrough' )} {t( 'Pass through the inference_geo field for geographic routing' )} )} /> > )} {currentType === 14 && ( <> ( {t( 'Allow inference_geo passthrough' )} {t( 'Pass through the inference_geo field for Claude data residency region control' )} )} /> ( {t('Allow speed passthrough')} {t( 'Pass through the speed field for Claude inference speed mode control' )} )} /> ( {t( 'Allow Claude beta query passthrough' )} {t( 'Pass through the anthropic-beta header for beta features' )} )} /> > )} )} {MODEL_FETCHABLE_TYPES.has(currentType) && ( } /> ( {t('Upstream Model Update Check')} {t( 'Periodically check for upstream model changes' )} )} /> ( {t('Auto Sync Upstream Models')} {t( 'Automatically sync model list when upstream changes are detected' )} )} /> ( {t('Ignored upstream models')} {t( 'Comma-separated exact model names. Prefix with regex: to ignore by regular expression.' )} )} /> {t('Last check time')}: {' '} {formatUnixTime( upstreamUpdateMeta.lastCheckTime )} {t('Last detected addable models')}: {' '} {upstreamUpdateMeta.detectedModels.length === 0 ? ( t('None') ) : ( <> {upstreamDetectedModelsPreview.join( ', ' )} {upstreamDetectedModelsOmittedCount > 0 && ( {t( '({{total}} total, {{omit}} omitted)', { total: upstreamUpdateMeta .detectedModels.length, omit: upstreamDetectedModelsOmittedCount, } )} )} > )} )} )} } > {t('Cancel')} {isSubmitting && ( )} {isEditing ? t('Update Channel') : t('Save changes')} {paramOverrideEditorOpen && !sensitiveLocked && ( { form.setValue('param_override', nextValue, { shouldDirty: true, shouldValidate: true, }) }} /> )} {advancedCustomEditorOpen && !sensitiveLocked && ( { form.setValue('advanced_custom', nextValue, { shouldDirty: true, shouldValidate: true, }) }} /> )} {/* Fetch Models Dialog */} { 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 } /> { 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 */} { if (!v) handleStatusCodeRiskAction(false) }} detailItems={statusCodeRiskDetailItems} onConfirm={() => handleStatusCodeRiskAction(true)} /> > ) }
{t('Current key')}
{t( 'Verification required to reveal the saved key.' )}
{t('Quick actions')}
{t( 'Use presets or upstream discovery to populate the model list faster.' )}
{t('Request flow')}
{t( 'Users call the model on the left. The platform forwards the request to the upstream model on the right.' )}
{'{"gpt-4":"Azure-GPT4"}'}
{t('No permission to perform this action')}
{'{api_key}'}
{'{client_header:NAME}'}