CaIon 0f9f094a48
feat(default): reorganize system settings pricing UI
Refine the default system settings structure and model pricing editor so pricing configuration is easier to scan and edit.
2026-05-06 16:29:45 +08:00

1263 lines
47 KiB
TypeScript
Vendored

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<typeof extendedModelFormSchema>
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<PricingMode>('per-token')
const [pricingSubMode, setPricingSubMode] = useState<PricingSubMode>('ratio')
const [advancedOpen, setAdvancedOpen] = useState(false)
const [promptPrice, setPromptPrice] = useState('')
const [completionPrice, setCompletionPrice] = useState('')
const [oldModelName, setOldModelName] = useState<string>('')
// 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<ExtendedModelFormValues>({
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<Record<string, number>>(
modelSettings.ModelPrice,
{ fallback: {}, silent: true }
)
const ratioMap = safeJsonParse<Record<string, number>>(
modelSettings.ModelRatio,
{ fallback: {}, silent: true }
)
const cacheMap = safeJsonParse<Record<string, number>>(
modelSettings.CacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
modelSettings.CompletionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(
modelSettings.ImageRatio,
{ fallback: {}, silent: true }
)
const audioMap = safeJsonParse<Record<string, number>>(
modelSettings.AudioRatio,
{ fallback: {}, silent: true }
)
const audioCompletionMap = safeJsonParse<Record<string, number>>(
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<void> => {
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<Record<string, number>>(
modelSettings.ModelPrice,
{ fallback: {}, silent: true }
)
const ratioMap = safeJsonParse<Record<string, number>>(
modelSettings.ModelRatio,
{ fallback: {}, silent: true }
)
const cacheMap = safeJsonParse<Record<string, number>>(
modelSettings.CacheRatio,
{ fallback: {}, silent: true }
)
const completionMap = safeJsonParse<Record<string, number>>(
modelSettings.CompletionRatio,
{ fallback: {}, silent: true }
)
const imageMap = safeJsonParse<Record<string, number>>(
modelSettings.ImageRatio,
{ fallback: {}, silent: true }
)
const audioMap = safeJsonParse<Record<string, number>>(
modelSettings.AudioRatio,
{ fallback: {}, silent: true }
)
const audioCompletionMap = safeJsonParse<Record<string, number>>(
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle>
{isEditing ? t('Edit Model') : t('Create Model')}
</SheetTitle>
<SheetDescription>
{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.'
)}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='model-form'
onSubmit={form.handleSubmit(
onSubmit as Parameters<typeof form.handleSubmit>[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 */}
<div className='space-y-4'>
<h3 className='text-sm font-semibold'>
{t('Basic Information')}
</h3>
<FormField
control={form.control}
name='model_name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('gpt-4, claude-3-opus, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique identifier for this model')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this model...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription className='text-xs'>
{t('@lobehub/icons key')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='vendor_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor')}</FormLabel>
<Select
onValueChange={(value) =>
field.onChange(value ? parseInt(value) : undefined)
}
value={field.value ? String(field.value) : undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select vendor')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={String(vendor.id)}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tags'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Tags')}</FormLabel>
<FormControl>
<TagInput
value={field.value || []}
onChange={field.onChange}
placeholder={t('Add tags...')}
/>
</FormControl>
<FormDescription>
{t('Press Enter or comma to add tags')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Matching Configuration */}
<div className='space-y-4'>
<h3 className='text-sm font-semibold'>{t('Matching Rules')}</h3>
<FormField
control={form.control}
name='name_rule'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name Rule')}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) =>
field.onChange(parseInt(value))
}
value={String(field.value)}
className='grid grid-cols-2 gap-4'
>
{getNameRuleOptions(t).map((option) => (
<div
key={option.value}
className='flex items-center space-x-2'
>
<RadioGroupItem
value={String(option.value)}
id={`rule-${option.value}`}
/>
<Label
htmlFor={`rule-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</FormControl>
<FormDescription>
{t('How this model name should match requests')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Endpoints Configuration */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h3 className='text-sm font-semibold'>{t('Endpoints')}</h3>
<Select<string>
onValueChange={(v) =>
v !== null && handleFillEndpointTemplate(v)
}
>
<SelectTrigger size='sm' className='w-[200px]'>
<SelectValue placeholder={t('Load template...')} />
</SelectTrigger>
<SelectContent>
{Object.keys(ENDPOINT_TEMPLATES).map((key) => (
<SelectItem key={key} value={key}>
{key}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FormField
control={form.control}
name='endpoints'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Endpoint Configuration')}</FormLabel>
<FormControl>
<JsonEditor
value={field.value || ''}
onChange={field.onChange}
keyPlaceholder='endpoint_type'
valuePlaceholder='{"path": "/v1/...", "method": "POST"}'
keyLabel='Endpoint Type'
valueLabel='Configuration'
valueType='any'
emptyMessage={t(
'No endpoints configured. Switch to JSON mode or add rows to define endpoints.'
)}
/>
</FormControl>
<FormDescription>
{t('Define API endpoints for this model (JSON format)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Pricing Configuration */}
<div className='space-y-4'>
<h3 className='text-sm font-semibold'>
{t('Pricing Configuration')}
</h3>
<div className='space-y-4'>
<Label>{t('Pricing mode')}</Label>
<RadioGroup
value={pricingMode}
onValueChange={(value) =>
setPricingMode(value as PricingMode)
}
>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='per-token' id='per-token' />
<Label htmlFor='per-token' className='font-normal'>
{t('Per-token (ratio based)')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='per-request' id='per-request' />
<Label htmlFor='per-request' className='font-normal'>
{t('Per-request (fixed price)')}
</Label>
</div>
</RadioGroup>
</div>
{pricingMode === 'per-request' ? (
<FormField
control={form.control}
name='price'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Fixed price (USD)')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='0.01'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t(
'Cost in USD per request, regardless of tokens used.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<>
<div className='space-y-4'>
<Label>{t('Input mode')}</Label>
<RadioGroup
value={pricingSubMode}
onValueChange={(value) =>
setPricingSubMode(value as PricingSubMode)
}
>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='ratio' id='ratio' />
<Label htmlFor='ratio' className='font-normal'>
{t('Ratio mode')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='price' id='price' />
<Label htmlFor='price' className='font-normal'>
{t('Price mode (USD per 1M tokens)')}
</Label>
</div>
</RadioGroup>
</div>
{pricingSubMode === 'ratio' ? (
<>
<FormField
control={form.control}
name='ratio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
if (value) {
setPromptPrice(
(parseFloat(value) * 2).toString()
)
} else {
setPromptPrice('')
}
}
}}
/>
</FormControl>
<FormDescription>
{field.value && !isNaN(parseFloat(field.value))
? `Calculated price: $${(parseFloat(field.value) * 2).toFixed(4)} per 1M tokens`
: t('Multiplier for prompt tokens.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='completionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Completion ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
const ratio = form.getValues('ratio')
if (value && ratio) {
const compPrice =
parseFloat(ratio) *
2 *
parseFloat(value)
setCompletionPrice(compPrice.toString())
} else {
setCompletionPrice('')
}
}
}}
/>
</FormControl>
<FormDescription>
{field.value &&
!isNaN(parseFloat(field.value)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice))
? `Calculated price: $${(parseFloat(promptPrice) * parseFloat(field.value)).toFixed(4)} per 1M tokens`
: t('Multiplier for completion tokens.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Prompt price ($/1M tokens)')}</Label>
<Input
type='text'
placeholder='2.0'
value={promptPrice}
onChange={(e) =>
handlePromptPriceChange(e.target.value)
}
/>
<p className='text-muted-foreground text-sm'>
{promptPrice && !isNaN(parseFloat(promptPrice))
? `Calculated ratio: ${(parseFloat(promptPrice) / 2).toFixed(4)}`
: t('Enter Input price to calculate ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Completion price ($/1M tokens)')}</Label>
<Input
type='text'
placeholder='4.0'
value={completionPrice}
onChange={(e) =>
handleCompletionPriceChange(e.target.value)
}
/>
<p className='text-muted-foreground text-sm'>
{completionPrice &&
!isNaN(parseFloat(completionPrice)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice)) &&
parseFloat(promptPrice) > 0
? `Calculated ratio: ${(parseFloat(completionPrice) / parseFloat(promptPrice)).toFixed(4)}`
: t('Enter Completion price to calculate ratio')}
</p>
</div>
</div>
</>
)}
<Collapsible
open={advancedOpen}
onOpenChange={setAdvancedOpen}
>
<CollapsibleTrigger
render={
<Button
type='button'
variant='outline'
className='flex w-full items-center justify-between'
/>
}
>
{t('Advanced options')}
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
advancedOpen ? 'rotate-180' : ''
}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className='space-y-6 pt-6'>
<FormField
control={form.control}
name='cacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Cache ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='0.1'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Discount ratio for cache hits.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='imageRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for image processing.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='audioRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for audio inputs.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='audioCompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio completion ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for audio outputs.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
<Separator />
{/* Status & Sync */}
<div className='space-y-4'>
<h3 className='text-sm font-semibold'>{t('Status & Sync')}</h3>
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem className='flex items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Enabled')}
</FormLabel>
<FormDescription>
{t('Enable or disable this model')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='sync_official'
render={({ field }) => (
<FormItem className='flex items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Official Sync')}
</FormLabel>
<FormDescription>
{t('Sync this model with official upstream')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetClose
render={<Button variant='outline' disabled={isSubmitting} />}
>
{t('Cancel')}
</SheetClose>
<Button form='model-form' type='submit' disabled={isSubmitting}>
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isEditing ? t('Update Model') : t('Save changes')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}