import { useEffect, useState, useCallback, useMemo } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ChevronDown, Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, 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 { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { JsonEditor } from '@/components/json-editor' import { TagInput } from '@/components/tag-input' import { useSystemOptions, getOptionValue, } from '@/features/system-settings/hooks/use-system-options' import { useUpdateOption } from '@/features/system-settings/hooks/use-update-option' import { normalizeJsonString } from '@/features/system-settings/models/utils' import type { ModelSettings } from '@/features/system-settings/types' import { safeJsonParse } from '@/features/system-settings/utils/json-parser' import { createModel, updateModel, getModel, getVendors } from '../../api' import { getNameRuleOptions, ENDPOINT_TEMPLATES } from '../../constants' import { modelsQueryKeys, vendorsQueryKeys, parseModelTags } from '../../lib' import type { Model } from '../../types' // Extended schema for ratio configuration (internal form state only) const extendedModelFormSchema = z.object({ id: z.number().optional(), model_name: z.string().min(1, 'Model name is required'), description: z.string(), icon: z.string(), tags: z.array(z.string()), vendor_id: z.number().optional(), endpoints: z.string(), name_rule: z.number(), status: z.boolean(), sync_official: z.boolean(), price: z.string().optional(), ratio: z.string().optional(), cacheRatio: z.string().optional(), completionRatio: z.string().optional(), imageRatio: z.string().optional(), audioRatio: z.string().optional(), audioCompletionRatio: z.string().optional(), }) type ExtendedModelFormValues = z.infer type PricingMode = 'per-token' | 'per-request' type PricingSubMode = 'ratio' | 'price' type ModelMutateDrawerProps = { open: boolean onOpenChange: (open: boolean) => void currentRow?: Model | null } export function ModelMutateDrawer({ open, onOpenChange, currentRow, }: ModelMutateDrawerProps) { const { t } = useTranslation() const queryClient = useQueryClient() const isEditing = Boolean(currentRow?.id) const [isSubmitting, setIsSubmitting] = useState(false) const [pricingMode, setPricingMode] = useState('per-token') const [pricingSubMode, setPricingSubMode] = useState('ratio') const [advancedOpen, setAdvancedOpen] = useState(false) const [promptPrice, setPromptPrice] = useState('') const [completionPrice, setCompletionPrice] = useState('') const [oldModelName, setOldModelName] = useState('') // Fetch vendors for dropdown const { data: vendorsData } = useQuery({ queryKey: vendorsQueryKeys.list(), queryFn: () => getVendors({ page_size: 1000 }), enabled: open, }) const vendors = vendorsData?.data?.items || [] // Fetch model detail if editing const { data: modelData } = useQuery({ queryKey: modelsQueryKeys.detail(currentRow?.id || 0), queryFn: () => getModel(currentRow!.id), enabled: open && isEditing, }) // Fetch system options for ratio configuration const { data: systemOptionsData } = useSystemOptions() const updateOption = useUpdateOption() // Get model settings from system options const modelSettings = useMemo(() => { if (!systemOptionsData?.data) return null const defaultModelSettings: ModelSettings = { 'global.pass_through_request_enabled': false, 'global.thinking_model_blacklist': '[]', 'global.chat_completions_to_responses_policy': '{}', 'general_setting.ping_interval_enabled': false, 'general_setting.ping_interval_seconds': 60, 'gemini.safety_settings': '', 'gemini.version_settings': '', 'gemini.supported_imagine_models': '', 'gemini.thinking_adapter_enabled': false, 'gemini.thinking_adapter_budget_tokens_percentage': 0.6, 'gemini.function_call_thought_signature_enabled': false, 'gemini.remove_function_response_id_enabled': true, 'claude.model_headers_settings': '', 'claude.default_max_tokens': '', 'claude.thinking_adapter_enabled': true, 'claude.thinking_adapter_budget_tokens_percentage': 0.8, ModelPrice: '', ModelRatio: '', CacheRatio: '', CompletionRatio: '', ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', ExposeRatioEnabled: false, 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', UserUsableGroups: '', GroupGroupRatio: '', AutoGroups: '', DefaultUseAutoGroup: false, CreateCacheRatio: '', 'group_ratio_setting.group_special_usable_group': '{}', 'grok.violation_deduction_enabled': false, 'grok.violation_deduction_amount': 0, 'channel_affinity_setting.enabled': false, 'channel_affinity_setting.switch_on_success': true, 'channel_affinity_setting.max_entries': 100000, 'channel_affinity_setting.default_ttl_seconds': 3600, 'channel_affinity_setting.rules': '[]', 'model_deployment.ionet.api_key': '', 'model_deployment.ionet.enabled': false, } return getOptionValue(systemOptionsData.data, defaultModelSettings) }, [systemOptionsData]) const form = useForm({ resolver: zodResolver(extendedModelFormSchema), defaultValues: { model_name: '', description: '', icon: '', tags: [], vendor_id: undefined, endpoints: '', name_rule: 0, status: true, sync_official: true, price: '', ratio: '', cacheRatio: '', completionRatio: '', imageRatio: '', audioRatio: '', audioCompletionRatio: '', }, }) const validateNumber = (value: string) => { if (value === '') return true return !isNaN(parseFloat(value)) } const handlePromptPriceChange = (value: string) => { setPromptPrice(value) if (value && !isNaN(parseFloat(value))) { const ratio = parseFloat(value) / 2 form.setValue('ratio', ratio.toString()) } else { form.setValue('ratio', '') } } const handleCompletionPriceChange = (value: string) => { setCompletionPrice(value) if ( value && !isNaN(parseFloat(value)) && promptPrice && !isNaN(parseFloat(promptPrice)) && parseFloat(promptPrice) > 0 ) { const completionRatio = parseFloat(value) / parseFloat(promptPrice) form.setValue('completionRatio', completionRatio.toString()) } else { form.setValue('completionRatio', '') } } // Load model data for editing and ratio configuration useEffect(() => { if (open && isEditing && modelData?.data) { const model = modelData.data setOldModelName(model.model_name) // Base model data reset const baseModelData = { id: model.id, model_name: model.model_name, description: model.description || '', icon: model.icon || '', tags: parseModelTags(model.tags), vendor_id: model.vendor_id, endpoints: model.endpoints || '', name_rule: model.name_rule || 0, status: model.status === 1, sync_official: model.sync_official === 1, price: '', ratio: '', cacheRatio: '', completionRatio: '', imageRatio: '', audioRatio: '', audioCompletionRatio: '', } // Parse ratio configurations from system settings if available if (modelSettings) { const priceMap = safeJsonParse>( modelSettings.ModelPrice, { fallback: {}, silent: true } ) const ratioMap = safeJsonParse>( modelSettings.ModelRatio, { fallback: {}, silent: true } ) const cacheMap = safeJsonParse>( modelSettings.CacheRatio, { fallback: {}, silent: true } ) const completionMap = safeJsonParse>( modelSettings.CompletionRatio, { fallback: {}, silent: true } ) const imageMap = safeJsonParse>( modelSettings.ImageRatio, { fallback: {}, silent: true } ) const audioMap = safeJsonParse>( modelSettings.AudioRatio, { fallback: {}, silent: true } ) const audioCompletionMap = safeJsonParse>( modelSettings.AudioCompletionRatio, { fallback: {}, silent: true } ) // Extract ratio config for this model const modelName = model.model_name const price = priceMap[modelName] const ratio = ratioMap[modelName] const cacheRatio = cacheMap[modelName] const completionRatio = completionMap[modelName] const imageRatio = imageMap[modelName] const audioRatio = audioMap[modelName] const audioCompletionRatio = audioCompletionMap[modelName] // Determine pricing mode if (price !== undefined && price !== null) { setPricingMode('per-request') form.reset({ ...baseModelData, price: price.toString(), }) } else { setPricingMode('per-token') if (ratio !== undefined && ratio !== null) { const tokenPrice = ratio * 2 setPromptPrice(tokenPrice.toString()) if (completionRatio !== undefined && completionRatio !== null) { const compPrice = tokenPrice * completionRatio setCompletionPrice(compPrice.toString()) } } form.reset({ ...baseModelData, ratio: ratio?.toString() || '', cacheRatio: cacheRatio?.toString() || '', completionRatio: completionRatio?.toString() || '', imageRatio: imageRatio?.toString() || '', audioRatio: audioRatio?.toString() || '', audioCompletionRatio: audioCompletionRatio?.toString() || '', }) setAdvancedOpen( !!(cacheRatio || imageRatio || audioRatio || audioCompletionRatio) ) } } else { // If system settings not loaded yet, just load base model data setPricingMode('per-token') form.reset(baseModelData) setAdvancedOpen(false) } } else if (open && !isEditing) { // Pre-fill model name if passed from missing models setOldModelName('') setPricingMode('per-token') setPricingSubMode('ratio') setPromptPrice('') setCompletionPrice('') setAdvancedOpen(false) form.reset({ model_name: currentRow?.model_name || '', description: '', icon: '', tags: [], vendor_id: undefined, endpoints: '', name_rule: 0, status: true, sync_official: true, price: '', ratio: '', cacheRatio: '', completionRatio: '', imageRatio: '', audioRatio: '', audioCompletionRatio: '', }) } }, [open, isEditing, modelData, currentRow, form, modelSettings]) const onSubmit = useCallback( async (values: ExtendedModelFormValues): Promise => { setIsSubmitting(true) try { const submitData = { ...values, id: isEditing ? currentRow!.id : undefined, tags: Array.isArray(values.tags) ? values.tags.join(',') : '', status: values.status ? 1 : 0, sync_official: values.sync_official ? 1 : 0, } // Remove ratio fields from model data (they're stored in system settings) const { price, ratio, cacheRatio, completionRatio, imageRatio, audioRatio, audioCompletionRatio, ...modelData } = submitData const response = isEditing ? await updateModel({ ...modelData, id: currentRow!.id }) : await createModel(modelData) if (response.success) { // Handle ratio configuration updates in system settings const finalModelName = values.model_name const hasRatioConfig = (pricingMode === 'per-request' && values.price && values.price !== '') || (pricingMode === 'per-token' && (values.ratio || values.cacheRatio || values.completionRatio || values.imageRatio || values.audioRatio || values.audioCompletionRatio)) // Always process system settings updates if we have modelSettings // This ensures we can remove stale entries even when clearing all pricing fields if (modelSettings) { // Read existing configurations const priceMap = safeJsonParse>( modelSettings.ModelPrice, { fallback: {}, silent: true } ) const ratioMap = safeJsonParse>( modelSettings.ModelRatio, { fallback: {}, silent: true } ) const cacheMap = safeJsonParse>( modelSettings.CacheRatio, { fallback: {}, silent: true } ) const completionMap = safeJsonParse>( modelSettings.CompletionRatio, { fallback: {}, silent: true } ) const imageMap = safeJsonParse>( modelSettings.ImageRatio, { fallback: {}, silent: true } ) const audioMap = safeJsonParse>( modelSettings.AudioRatio, { fallback: {}, silent: true } ) const audioCompletionMap = safeJsonParse>( modelSettings.AudioCompletionRatio, { fallback: {}, silent: true } ) // Remove old model name entries if model name changed (always, even if no new config) if (isEditing && oldModelName && oldModelName !== finalModelName) { delete priceMap[oldModelName] delete ratioMap[oldModelName] delete cacheMap[oldModelName] delete completionMap[oldModelName] delete imageMap[oldModelName] delete audioMap[oldModelName] delete audioCompletionMap[oldModelName] } // Remove current model name from all maps first (always, to handle mode switches or clearing) // This ensures stale entries are removed even when user clears all fields delete priceMap[finalModelName] delete ratioMap[finalModelName] delete cacheMap[finalModelName] delete completionMap[finalModelName] delete imageMap[finalModelName] delete audioMap[finalModelName] delete audioCompletionMap[finalModelName] // Only add new entries if user provided new configuration if (hasRatioConfig) { if ( pricingMode === 'per-request' && values.price && values.price !== '' ) { priceMap[finalModelName] = parseFloat(values.price) } else if (pricingMode === 'per-token') { if (values.ratio && values.ratio !== '') { ratioMap[finalModelName] = parseFloat(values.ratio) } if (values.cacheRatio && values.cacheRatio !== '') { cacheMap[finalModelName] = parseFloat(values.cacheRatio) } if (values.completionRatio && values.completionRatio !== '') { completionMap[finalModelName] = parseFloat( values.completionRatio ) } if (values.imageRatio && values.imageRatio !== '') { imageMap[finalModelName] = parseFloat(values.imageRatio) } if (values.audioRatio && values.audioRatio !== '') { audioMap[finalModelName] = parseFloat(values.audioRatio) } if ( values.audioCompletionRatio && values.audioCompletionRatio !== '' ) { audioCompletionMap[finalModelName] = parseFloat( values.audioCompletionRatio ) } } } // Update system options if there are changes const updates: Array<{ key: string; value: string }> = [] const newModelPrice = normalizeJsonString(JSON.stringify(priceMap)) if ( newModelPrice !== normalizeJsonString(modelSettings.ModelPrice) ) { updates.push({ key: 'ModelPrice', value: newModelPrice }) } const newModelRatio = normalizeJsonString(JSON.stringify(ratioMap)) if ( newModelRatio !== normalizeJsonString(modelSettings.ModelRatio) ) { updates.push({ key: 'ModelRatio', value: newModelRatio }) } const newCacheRatio = normalizeJsonString(JSON.stringify(cacheMap)) if ( newCacheRatio !== normalizeJsonString(modelSettings.CacheRatio) ) { updates.push({ key: 'CacheRatio', value: newCacheRatio }) } const newCompletionRatio = normalizeJsonString( JSON.stringify(completionMap) ) if ( newCompletionRatio !== normalizeJsonString(modelSettings.CompletionRatio) ) { updates.push({ key: 'CompletionRatio', value: newCompletionRatio, }) } const newImageRatio = normalizeJsonString(JSON.stringify(imageMap)) if ( newImageRatio !== normalizeJsonString(modelSettings.ImageRatio) ) { updates.push({ key: 'ImageRatio', value: newImageRatio }) } const newAudioRatio = normalizeJsonString(JSON.stringify(audioMap)) if ( newAudioRatio !== normalizeJsonString(modelSettings.AudioRatio) ) { updates.push({ key: 'AudioRatio', value: newAudioRatio }) } const newAudioCompletionRatio = normalizeJsonString( JSON.stringify(audioCompletionMap) ) if ( newAudioCompletionRatio !== normalizeJsonString(modelSettings.AudioCompletionRatio) ) { updates.push({ key: 'AudioCompletionRatio', value: newAudioCompletionRatio, }) } // Apply all updates (including deletions when clearing fields) for (const update of updates) { await updateOption.mutateAsync(update) } } toast.success( isEditing ? 'Model updated successfully' : 'Model created successfully' ) queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() }) queryClient.invalidateQueries({ queryKey: ['system-options'] }) onOpenChange(false) } else { toast.error(response.message || 'Operation failed') } } catch (error: unknown) { toast.error((error as Error)?.message || 'Operation failed') } finally { setIsSubmitting(false) } }, [ isEditing, currentRow, queryClient, onOpenChange, pricingMode, oldModelName, modelSettings, updateOption, ] ) const handleFillEndpointTemplate = (templateKey: string) => { const template = ENDPOINT_TEMPLATES[templateKey] if (template) { const templateJson = JSON.stringify({ [templateKey]: template }, null, 2) form.setValue('endpoints', templateJson) } } return ( {isEditing ? t('Edit Model') : t('Create Model')} {isEditing ? t("Update model configuration and click save when you're done.") : t( 'Add a new model to the system by providing the necessary information.' )}
[0] )} className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4' > {/* Basic Information */}

{t('Basic Information')}

( {t('Model Name *')} {t('The unique identifier for this model')} )} /> ( {t('Description')}