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.
This commit is contained in:
@@ -43,6 +43,13 @@ const defaultModelSettings: ModelSettings = {
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
'group_ratio_setting.group_special_usable_group': '{}',
|
||||
'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,
|
||||
}
|
||||
|
||||
export function ModelSettings() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,656 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
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 { TieredPricingEditor } from './tiered-pricing-editor'
|
||||
|
||||
const createModelDialogSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('Model name is required')),
|
||||
price: z.string().optional(),
|
||||
ratio: z.string().optional(),
|
||||
cacheRatio: z.string().optional(),
|
||||
createCacheRatio: z.string().optional(),
|
||||
completionRatio: z.string().optional(),
|
||||
imageRatio: z.string().optional(),
|
||||
audioRatio: z.string().optional(),
|
||||
audioCompletionRatio: z.string().optional(),
|
||||
})
|
||||
|
||||
type ModelDialogFormValues = z.infer<ReturnType<typeof createModelDialogSchema>>
|
||||
|
||||
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
||||
type PricingSubMode = 'ratio' | 'price'
|
||||
|
||||
export type ModelRatioData = {
|
||||
name: string
|
||||
price?: string
|
||||
ratio?: string
|
||||
cacheRatio?: string
|
||||
createCacheRatio?: string
|
||||
completionRatio?: string
|
||||
imageRatio?: string
|
||||
audioRatio?: string
|
||||
audioCompletionRatio?: string
|
||||
billingMode?: 'per-token' | 'per-request' | 'tiered_expr'
|
||||
billingExpr?: string
|
||||
requestRuleExpr?: string
|
||||
}
|
||||
|
||||
type ModelRatioDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (data: ModelRatioData) => void
|
||||
editData?: ModelRatioData | null
|
||||
}
|
||||
|
||||
export function ModelRatioDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editData,
|
||||
}: ModelRatioDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
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 [billingExpr, setBillingExpr] = useState('')
|
||||
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
||||
const isEditMode = !!editData
|
||||
|
||||
const form = useForm<ModelDialogFormValues>({
|
||||
resolver: zodResolver(createModelDialogSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
price: '',
|
||||
ratio: '',
|
||||
cacheRatio: '',
|
||||
createCacheRatio: '',
|
||||
completionRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
form.reset(editData)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBillingExpr(editData.billingExpr || '')
|
||||
setRequestRuleExpr(editData.requestRuleExpr || '')
|
||||
|
||||
if (editData.billingMode === 'tiered_expr') {
|
||||
setPricingMode('tiered_expr')
|
||||
} else if (editData.price && editData.price !== '') {
|
||||
setPricingMode('per-request')
|
||||
} else {
|
||||
setPricingMode('per-token')
|
||||
if (editData.ratio) {
|
||||
const tokenPrice = parseFloat(editData.ratio) * 2
|
||||
setPromptPrice(tokenPrice.toString())
|
||||
if (editData.completionRatio) {
|
||||
const compPrice = tokenPrice * parseFloat(editData.completionRatio)
|
||||
setCompletionPrice(compPrice.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
price: '',
|
||||
ratio: '',
|
||||
cacheRatio: '',
|
||||
createCacheRatio: '',
|
||||
completionRatio: '',
|
||||
imageRatio: '',
|
||||
audioRatio: '',
|
||||
audioCompletionRatio: '',
|
||||
})
|
||||
setPricingMode('per-token')
|
||||
setPricingSubMode('ratio')
|
||||
setPromptPrice('')
|
||||
setCompletionPrice('')
|
||||
setBillingExpr('')
|
||||
setRequestRuleExpr('')
|
||||
setAdvancedOpen(false)
|
||||
}
|
||||
}, [editData, form, open])
|
||||
|
||||
const handleSubmit = (values: ModelDialogFormValues) => {
|
||||
// Always pass through every field. The visual editor decides what to
|
||||
// persist based on `billingMode`, and tiered_expr models also keep the
|
||||
// ratio/price values as fallback during multi-instance sync delays
|
||||
// (the backend's ModelPriceHelper checks billing_mode first, so these
|
||||
// fallbacks only kick in when billing_setting hasn't propagated yet).
|
||||
const data: ModelRatioData = {
|
||||
name: values.name,
|
||||
billingMode: pricingMode,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
cacheRatio: values.cacheRatio || '',
|
||||
createCacheRatio: values.createCacheRatio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
imageRatio: values.imageRatio || '',
|
||||
audioRatio: values.audioRatio || '',
|
||||
audioCompletionRatio: values.audioCompletionRatio || '',
|
||||
}
|
||||
|
||||
if (pricingMode === 'tiered_expr') {
|
||||
data.billingExpr = billingExpr
|
||||
data.requestRuleExpr = requestRuleExpr
|
||||
}
|
||||
|
||||
onSave(data)
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
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', '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[680px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditMode ? t('Edit model') : t('Add model')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Configure pricing ratios for a specific model.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='space-y-6'
|
||||
autoComplete='off'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Model name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('gpt-4')}
|
||||
{...field}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The exact model identifier as used in API requests.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value='tiered_expr' id='tiered_expr' />
|
||||
<Label htmlFor='tiered_expr' className='font-normal'>
|
||||
{t('Tiered (billing expression)')}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{pricingMode === 'tiered_expr' ? (
|
||||
<TieredPricingEditor
|
||||
modelName={form.getValues('name')}
|
||||
billingExpr={billingExpr}
|
||||
requestRuleExpr={requestRuleExpr}
|
||||
onBillingExprChange={setBillingExpr}
|
||||
onRequestRuleExprChange={setRequestRuleExpr}
|
||||
/>
|
||||
) : 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))
|
||||
? t(
|
||||
'Calculated price: ${{price}} per 1M tokens',
|
||||
{
|
||||
price: (
|
||||
parseFloat(field.value) * 2
|
||||
).toFixed(4),
|
||||
}
|
||||
)
|
||||
: 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))
|
||||
? t(
|
||||
'Calculated price: ${{price}} per 1M tokens',
|
||||
{
|
||||
price: (
|
||||
parseFloat(promptPrice) *
|
||||
parseFloat(field.value)
|
||||
).toFixed(4),
|
||||
}
|
||||
)
|
||||
: 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))
|
||||
? t('Calculated ratio: {{ratio}}', {
|
||||
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
|
||||
? t('Calculated ratio: {{ratio}}', {
|
||||
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='createCacheRatio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Create cache ratio')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='1.25'
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (validateNumber(value)) {
|
||||
field.onChange(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Ratio applied when creating cache entries for supported models.'
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit'>
|
||||
{isEditMode ? t('Update') : t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+478
-236
@@ -1,19 +1,27 @@
|
||||
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -23,6 +31,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableBulkActions,
|
||||
DataTableColumnHeader,
|
||||
DataTableToolbar,
|
||||
DataTablePagination,
|
||||
@@ -33,7 +42,11 @@ import {
|
||||
splitBillingExprAndRequestRules,
|
||||
} from '@/features/pricing/lib/billing-expr'
|
||||
import { safeJsonParse } from '../utils/json-parser'
|
||||
import { ModelRatioDialog, type ModelRatioData } from './model-ratio-dialog'
|
||||
import {
|
||||
ModelPricingEditorPanel,
|
||||
ModelPricingSheet,
|
||||
type ModelRatioData,
|
||||
} from './model-pricing-sheet'
|
||||
|
||||
type ModelRatioVisualEditorProps = {
|
||||
modelPrice: string
|
||||
@@ -67,9 +80,101 @@ type ModelRow = {
|
||||
|
||||
const STORAGE_KEY = 'model-ratio-column-visibility'
|
||||
|
||||
const formatValue = (value?: string) => {
|
||||
if (!value || value === '') return '—'
|
||||
return value
|
||||
const hasValue = (value?: string) => value !== undefined && value !== ''
|
||||
|
||||
const toNumberOrNull = (value?: string) => {
|
||||
if (!hasValue(value)) return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const formatPrice = (value: number) => {
|
||||
return Number.parseFloat(value.toFixed(12)).toString()
|
||||
}
|
||||
|
||||
const ratioToPrice = (ratio?: string, denominator?: string) => {
|
||||
const ratioNumber = toNumberOrNull(ratio)
|
||||
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
|
||||
if (ratioNumber === null || denominatorNumber === null) return ''
|
||||
return formatPrice(ratioNumber * denominatorNumber)
|
||||
}
|
||||
|
||||
const filterBySelectedValues = (
|
||||
rowValue: unknown,
|
||||
filterValue: unknown
|
||||
): boolean => {
|
||||
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
|
||||
return filterValue.includes(String(rowValue))
|
||||
}
|
||||
|
||||
const getModeLabel = (mode?: string) => {
|
||||
if (mode === 'per-request') return 'Per-request'
|
||||
if (mode === 'tiered_expr') return 'Expression'
|
||||
return 'Per-token'
|
||||
}
|
||||
|
||||
const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => {
|
||||
if (mode === 'per-request') return 'warning'
|
||||
if (mode === 'tiered_expr') return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => {
|
||||
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
|
||||
if (tierCount > 0) {
|
||||
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
|
||||
}
|
||||
return t('Expression pricing')
|
||||
}
|
||||
|
||||
const getPriceSummary = (row: ModelRow, t: (key: string) => string) => {
|
||||
if (row.billingMode === 'tiered_expr') {
|
||||
return getExpressionSummary(row, t)
|
||||
}
|
||||
if (row.billingMode === 'per-request') {
|
||||
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
|
||||
}
|
||||
|
||||
const inputPrice = ratioToPrice(row.ratio)
|
||||
if (!inputPrice) return t('Unset price')
|
||||
|
||||
const extraCount = [
|
||||
row.completionRatio,
|
||||
row.cacheRatio,
|
||||
row.createCacheRatio,
|
||||
row.imageRatio,
|
||||
row.audioRatio,
|
||||
row.audioCompletionRatio,
|
||||
].filter(hasValue).length
|
||||
|
||||
return extraCount > 0
|
||||
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
|
||||
: `${t('Input')} $${inputPrice}`
|
||||
}
|
||||
|
||||
const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
|
||||
if (row.billingMode === 'tiered_expr') {
|
||||
return row.requestRuleExpr
|
||||
? t('Includes request rules')
|
||||
: t('Expression based')
|
||||
}
|
||||
if (row.billingMode === 'per-request') {
|
||||
return t('Fixed request price')
|
||||
}
|
||||
|
||||
const inputPrice = ratioToPrice(row.ratio)
|
||||
if (!inputPrice) return t('No base input price')
|
||||
|
||||
const details = [
|
||||
row.completionRatio &&
|
||||
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
|
||||
row.cacheRatio &&
|
||||
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
|
||||
row.createCacheRatio &&
|
||||
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
|
||||
].filter(Boolean)
|
||||
|
||||
return details.length > 0 ? details.join(' · ') : t('Base input price only')
|
||||
}
|
||||
|
||||
export const ModelRatioVisualEditor = memo(
|
||||
@@ -87,12 +192,17 @@ export const ModelRatioVisualEditor = memo(
|
||||
onChange,
|
||||
}: ModelRatioVisualEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editData, setEditData] = useState<ModelRatioData | null>(null)
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [globalFilter, setGlobalFilter] = useState('')
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
() => {
|
||||
@@ -102,6 +212,7 @@ export const ModelRatioVisualEditor = memo(
|
||||
return safeJsonParse<VisibilityState>(saved, {
|
||||
fallback: {
|
||||
cacheRatio: false,
|
||||
createCacheRatio: false,
|
||||
imageRatio: false,
|
||||
audioRatio: false,
|
||||
audioCompletionRatio: false,
|
||||
@@ -265,34 +376,82 @@ export const ModelRatioVisualEditor = memo(
|
||||
billingExpr,
|
||||
])
|
||||
|
||||
const handleEdit = useCallback((model: ModelRow) => {
|
||||
setEditData({
|
||||
name: model.name,
|
||||
price: model.price,
|
||||
ratio: model.ratio,
|
||||
cacheRatio: model.cacheRatio,
|
||||
createCacheRatio: model.createCacheRatio,
|
||||
completionRatio: model.completionRatio,
|
||||
imageRatio: model.imageRatio,
|
||||
audioRatio: model.audioRatio,
|
||||
audioCompletionRatio: model.audioCompletionRatio,
|
||||
billingMode:
|
||||
model.billingMode === 'tiered_expr'
|
||||
? 'tiered_expr'
|
||||
: model.price && model.price !== ''
|
||||
? 'per-request'
|
||||
: 'per-token',
|
||||
billingExpr: model.billingExpr,
|
||||
requestRuleExpr: model.requestRuleExpr,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}, [])
|
||||
const modeCounts = useMemo(
|
||||
() =>
|
||||
models.reduce(
|
||||
(acc, model) => {
|
||||
const mode =
|
||||
model.billingMode === 'per-request' ||
|
||||
model.billingMode === 'tiered_expr'
|
||||
? model.billingMode
|
||||
: 'per-token'
|
||||
acc[mode] += 1
|
||||
return acc
|
||||
},
|
||||
{
|
||||
'per-token': 0,
|
||||
'per-request': 0,
|
||||
tiered_expr: 0,
|
||||
} as Record<'per-token' | 'per-request' | 'tiered_expr', number>
|
||||
),
|
||||
[models]
|
||||
)
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(model: ModelRow) => {
|
||||
setEditData({
|
||||
name: model.name,
|
||||
price: model.price,
|
||||
ratio: model.ratio,
|
||||
cacheRatio: model.cacheRatio,
|
||||
createCacheRatio: model.createCacheRatio,
|
||||
completionRatio: model.completionRatio,
|
||||
imageRatio: model.imageRatio,
|
||||
audioRatio: model.audioRatio,
|
||||
audioCompletionRatio: model.audioCompletionRatio,
|
||||
billingMode:
|
||||
model.billingMode === 'tiered_expr'
|
||||
? 'tiered_expr'
|
||||
: model.price && model.price !== ''
|
||||
? 'per-request'
|
||||
: 'per-token',
|
||||
billingExpr: model.billingExpr,
|
||||
requestRuleExpr: model.requestRuleExpr,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
if (isMobile) setSheetOpen(true)
|
||||
},
|
||||
[isMobile]
|
||||
)
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditData(null)
|
||||
setDialogOpen(true)
|
||||
setEditorOpen(true)
|
||||
if (isMobile) setSheetOpen(true)
|
||||
}, [isMobile])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditData(null)
|
||||
setEditorOpen(false)
|
||||
setSheetOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
|
||||
(updater) => {
|
||||
setGlobalFilter((previous) => {
|
||||
const next =
|
||||
typeof updater === 'function' ? updater(previous) : updater
|
||||
if (next !== previous) {
|
||||
setEditData(null)
|
||||
setEditorOpen(false)
|
||||
setSheetOpen(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(name: string) => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
@@ -383,15 +542,32 @@ export const ModelRatioVisualEditor = memo(
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
|
||||
// Ratio fields are not the primary pricing when a per-request fixed
|
||||
// price is set, or when the model is in tiered_expr mode (the
|
||||
// expression is primary; ratios are fallback during sync delays).
|
||||
const isFallbackRow = (row: ModelRow) =>
|
||||
row.billingMode === 'tiered_expr' || !!row.price
|
||||
const fallbackClass = (row: ModelRow) =>
|
||||
isFallbackRow(row) ? 'text-muted-foreground' : ''
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected()}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label={t('Select all')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t('Select row')}
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
@@ -419,106 +595,41 @@ export const ModelRatioVisualEditor = memo(
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
accessorKey: 'billingMode',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Fixed price')} />
|
||||
<DataTableColumnHeader column={column} title={t('Mode')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={
|
||||
row.original.billingMode === 'tiered_expr'
|
||||
? 'text-muted-foreground'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{formatValue(row.getValue('price'))}
|
||||
</span>
|
||||
<StatusBadge
|
||||
label={t(getModeLabel(row.original.billingMode))}
|
||||
variant={getModeVariant(row.original.billingMode)}
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
meta: { label: 'Fixed price' },
|
||||
filterFn: (row, id, value) =>
|
||||
filterBySelectedValues(row.getValue(id), value),
|
||||
meta: { label: t('Mode') },
|
||||
},
|
||||
{
|
||||
accessorKey: 'ratio',
|
||||
id: 'priceSummary',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Ratio')} />
|
||||
<DataTableColumnHeader column={column} title={t('Price summary')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('ratio'))}
|
||||
</span>
|
||||
<div className='flex min-w-[180px] flex-col gap-1'>
|
||||
<span className='font-medium'>
|
||||
{getPriceSummary(row.original, t)}
|
||||
</span>
|
||||
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
|
||||
{getPriceDetail(row.original, t)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: { label: 'Ratio' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'completionRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Completion')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('completionRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Completion' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'cacheRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Cache')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('cacheRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Cache' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'createCacheRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Create cache')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('createCacheRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Create cache' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'imageRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Image')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('imageRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Image' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'audioRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Audio')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('audioRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Audio' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'audioCompletionRatio',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Audio comp.')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className={fallbackClass(row.original)}>
|
||||
{formatValue(row.getValue('audioCompletionRatio'))}
|
||||
</span>
|
||||
),
|
||||
meta: { label: 'Audio comp.' },
|
||||
sortingFn: (rowA, rowB) =>
|
||||
getPriceSummary(rowA.original, t).localeCompare(
|
||||
getPriceSummary(rowB.original, t)
|
||||
),
|
||||
meta: { label: t('Price summary') },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -529,14 +640,14 @@ export const ModelRatioVisualEditor = memo(
|
||||
size='sm'
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleDelete(row.original.name)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
@@ -550,25 +661,34 @@ export const ModelRatioVisualEditor = memo(
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: handleGlobalFilterChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
autoResetPageIndex: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
return row.original.name.toLowerCase().includes(searchValue)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = useCallback(
|
||||
(data: ModelRatioData) => {
|
||||
const persistPricingData = useCallback(
|
||||
(data: ModelRatioData, targetNames: string[] = [data.name]) => {
|
||||
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
|
||||
fallback: {},
|
||||
silent: true,
|
||||
@@ -610,58 +730,61 @@ export const ModelRatioVisualEditor = memo(
|
||||
{ fallback: {}, silent: true }
|
||||
)
|
||||
|
||||
delete priceMap[data.name]
|
||||
delete ratioMap[data.name]
|
||||
delete cacheMap[data.name]
|
||||
delete createCacheMap[data.name]
|
||||
delete completionMap[data.name]
|
||||
delete imageMap[data.name]
|
||||
delete audioMap[data.name]
|
||||
delete audioCompletionMap[data.name]
|
||||
delete billingModeMap[data.name]
|
||||
delete billingExprMap[data.name]
|
||||
|
||||
const setIfPresent = (
|
||||
target: Record<string, number>,
|
||||
name: string,
|
||||
value: string | undefined
|
||||
) => {
|
||||
if (!value || value === '') return
|
||||
const parsed = parseFloat(value)
|
||||
if (Number.isFinite(parsed)) target[data.name] = parsed
|
||||
if (Number.isFinite(parsed)) target[name] = parsed
|
||||
}
|
||||
|
||||
if (data.billingMode === 'tiered_expr') {
|
||||
const combined = combineBillingExpr(
|
||||
data.billingExpr || '',
|
||||
data.requestRuleExpr || ''
|
||||
)
|
||||
if (combined) {
|
||||
billingModeMap[data.name] = 'tiered_expr'
|
||||
billingExprMap[data.name] = combined
|
||||
targetNames.forEach((name) => {
|
||||
delete priceMap[name]
|
||||
delete ratioMap[name]
|
||||
delete cacheMap[name]
|
||||
delete createCacheMap[name]
|
||||
delete completionMap[name]
|
||||
delete imageMap[name]
|
||||
delete audioMap[name]
|
||||
delete audioCompletionMap[name]
|
||||
delete billingModeMap[name]
|
||||
delete billingExprMap[name]
|
||||
|
||||
if (data.billingMode === 'tiered_expr') {
|
||||
const combined = combineBillingExpr(
|
||||
data.billingExpr || '',
|
||||
data.requestRuleExpr || ''
|
||||
)
|
||||
if (combined) {
|
||||
billingModeMap[name] = 'tiered_expr'
|
||||
billingExprMap[name] = combined
|
||||
}
|
||||
// Always serialize ratio/price values for tiered_expr models so they
|
||||
// serve as fallback during multi-instance sync delays. The backend's
|
||||
// ModelPriceHelper checks billing_mode first, so these values are
|
||||
// only consulted when billing_setting hasn't propagated yet.
|
||||
setIfPresent(priceMap, name, data.price)
|
||||
setIfPresent(ratioMap, name, data.ratio)
|
||||
setIfPresent(cacheMap, name, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, name, data.createCacheRatio)
|
||||
setIfPresent(completionMap, name, data.completionRatio)
|
||||
setIfPresent(imageMap, name, data.imageRatio)
|
||||
setIfPresent(audioMap, name, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
|
||||
} else if (data.price && data.price !== '') {
|
||||
setIfPresent(priceMap, name, data.price)
|
||||
} else {
|
||||
setIfPresent(ratioMap, name, data.ratio)
|
||||
setIfPresent(cacheMap, name, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, name, data.createCacheRatio)
|
||||
setIfPresent(completionMap, name, data.completionRatio)
|
||||
setIfPresent(imageMap, name, data.imageRatio)
|
||||
setIfPresent(audioMap, name, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
|
||||
}
|
||||
// Always serialize ratio/price values for tiered_expr models so they
|
||||
// serve as fallback during multi-instance sync delays. The backend's
|
||||
// ModelPriceHelper checks billing_mode first, so these values are
|
||||
// only consulted when billing_setting hasn't propagated yet.
|
||||
setIfPresent(priceMap, data.price)
|
||||
setIfPresent(ratioMap, data.ratio)
|
||||
setIfPresent(cacheMap, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, data.createCacheRatio)
|
||||
setIfPresent(completionMap, data.completionRatio)
|
||||
setIfPresent(imageMap, data.imageRatio)
|
||||
setIfPresent(audioMap, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
|
||||
} else if (data.price && data.price !== '') {
|
||||
setIfPresent(priceMap, data.price)
|
||||
} else {
|
||||
setIfPresent(ratioMap, data.ratio)
|
||||
setIfPresent(cacheMap, data.cacheRatio)
|
||||
setIfPresent(createCacheMap, data.createCacheRatio)
|
||||
setIfPresent(completionMap, data.completionRatio)
|
||||
setIfPresent(imageMap, data.imageRatio)
|
||||
setIfPresent(audioMap, data.audioRatio)
|
||||
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
|
||||
}
|
||||
})
|
||||
|
||||
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
|
||||
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
|
||||
@@ -698,72 +821,191 @@ export const ModelRatioVisualEditor = memo(
|
||||
]
|
||||
)
|
||||
|
||||
const handleSave = useCallback(
|
||||
(data: ModelRatioData) => {
|
||||
persistPricingData(data)
|
||||
setEditData(data)
|
||||
setEditorOpen(true)
|
||||
},
|
||||
[persistPricingData]
|
||||
)
|
||||
|
||||
const handleBatchCopy = useCallback(() => {
|
||||
if (!editData) {
|
||||
toast.error(t('Open a source model first'))
|
||||
return
|
||||
}
|
||||
|
||||
const targetNames = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original.name)
|
||||
|
||||
if (targetNames.length === 0) {
|
||||
toast.error(t('Select at least one target model'))
|
||||
return
|
||||
}
|
||||
|
||||
persistPricingData(editData, targetNames)
|
||||
table.resetRowSelection()
|
||||
toast.success(
|
||||
t('Applied {{name}} pricing to {{count}} models', {
|
||||
name: editData.name,
|
||||
count: targetNames.length,
|
||||
})
|
||||
)
|
||||
}, [editData, persistPricingData, t, table])
|
||||
|
||||
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search models...')}
|
||||
/>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid min-h-0 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(420px,0.82fr)] xl:grid-cols-[minmax(0,1.1fr)_minmax(520px,0.9fr)]'>
|
||||
<div className='flex min-w-0 flex-col gap-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search models...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'billingMode',
|
||||
title: t('Mode'),
|
||||
options: [
|
||||
{
|
||||
label: 'Per-token',
|
||||
value: 'per-token',
|
||||
count: modeCounts['per-token'],
|
||||
},
|
||||
{
|
||||
label: 'Per-request',
|
||||
value: 'per-request',
|
||||
count: modeCounts['per-request'],
|
||||
},
|
||||
{
|
||||
label: 'Expression',
|
||||
value: 'tiered_expr',
|
||||
count: modeCounts.tiered_expr,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
preActions={
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: t('No models configured. Use Add model to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() ? 'selected' : undefined
|
||||
}
|
||||
className={
|
||||
editData?.name === row.original.name
|
||||
? 'bg-muted/45'
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, [role="checkbox"]'))
|
||||
return
|
||||
handleEdit(row.original)
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTablePagination table={table} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hidden min-w-0 md:block'>
|
||||
{editorOpen ? (
|
||||
<ModelPricingEditorPanel
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
editData={editData}
|
||||
selectedTargetCount={selectedTargetCount}
|
||||
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-card text-muted-foreground sticky top-4 flex h-[calc(100vh-8rem)] min-h-[420px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
|
||||
<div className='text-foreground text-base font-medium'>
|
||||
{t('Select a model to edit pricing')}
|
||||
</div>
|
||||
<p className='max-w-sm text-sm'>
|
||||
{t(
|
||||
'Use the full-width table to scan prices, then select a row to edit it here.'
|
||||
)}
|
||||
</p>
|
||||
<Button variant='outline' onClick={handleAdd}>
|
||||
<Plus data-icon='inline-start' />
|
||||
{t('Add model')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
{table.getState().globalFilter
|
||||
? t('No models match your search')
|
||||
: t('No models configured. Click "Add model" to get started.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<DataTableBulkActions table={table} entityName={t('model')}>
|
||||
<Button size='sm' disabled={!editData} onClick={handleBatchCopy}>
|
||||
<Copy data-icon='inline-start' />
|
||||
{editData
|
||||
? t('Copy {{name}} pricing', { name: editData.name })
|
||||
: t('Open a source model first')}
|
||||
</Button>
|
||||
</DataTableBulkActions>
|
||||
|
||||
{table.getRowModel().rows.length > 0 && (
|
||||
<DataTablePagination table={table} />
|
||||
{isMobile && (
|
||||
<ModelPricingSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
editData={editData}
|
||||
selectedTargetCount={selectedTargetCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelRatioDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSave}
|
||||
editData={editData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -179,17 +179,24 @@ const groupSchema = z.object({
|
||||
|
||||
type ModelFormValues = z.infer<typeof modelSchema>
|
||||
type GroupFormValues = z.infer<typeof groupSchema>
|
||||
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
|
||||
|
||||
type RatioSettingsCardProps = {
|
||||
modelDefaults: ModelFormValues
|
||||
groupDefaults: GroupFormValues
|
||||
toolPricesDefault: string
|
||||
titleKey?: string
|
||||
descriptionKey?: string
|
||||
visibleTabs?: RatioTabId[]
|
||||
}
|
||||
|
||||
export function RatioSettingsCard({
|
||||
modelDefaults,
|
||||
groupDefaults,
|
||||
toolPricesDefault,
|
||||
titleKey = 'Pricing Ratios',
|
||||
descriptionKey = 'Configure model, caching, and group ratios used for billing',
|
||||
visibleTabs = ['models', 'groups', 'tool-prices', 'upstream-sync'],
|
||||
}: RatioSettingsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
@@ -414,61 +421,79 @@ export function RatioSettingsCard({
|
||||
resetMutate()
|
||||
}, [resetMutate])
|
||||
|
||||
const tabLabels: Record<RatioTabId, string> = {
|
||||
models: 'Model ratios',
|
||||
groups: 'Group ratios',
|
||||
'tool-prices': 'Tool prices',
|
||||
'upstream-sync': 'Upstream price sync',
|
||||
}
|
||||
const tabsGridClass =
|
||||
{
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
}[visibleTabs.length] ?? 'grid-cols-4'
|
||||
const defaultTab = visibleTabs[0] ?? 'models'
|
||||
|
||||
const renderTabContent = (tab: RatioTabId) => {
|
||||
if (tab === 'models') {
|
||||
return (
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
onSave={saveModelRatios}
|
||||
onReset={handleResetRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (tab === 'groups') {
|
||||
return (
|
||||
<GroupRatioForm
|
||||
form={groupForm}
|
||||
onSave={saveGroupRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (tab === 'tool-prices') {
|
||||
return <ToolPriceSettings defaultValue={toolPricesDefault} />
|
||||
}
|
||||
return (
|
||||
<UpstreamRatioSync
|
||||
modelRatios={{
|
||||
ModelPrice: modelDefaults.ModelPrice,
|
||||
ModelRatio: modelDefaults.ModelRatio,
|
||||
CompletionRatio: modelDefaults.CompletionRatio,
|
||||
CacheRatio: modelDefaults.CacheRatio,
|
||||
CreateCacheRatio: modelDefaults.CreateCacheRatio,
|
||||
ImageRatio: modelDefaults.ImageRatio,
|
||||
AudioRatio: modelDefaults.AudioRatio,
|
||||
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
|
||||
'billing_setting.billing_mode': modelDefaults.BillingMode,
|
||||
'billing_setting.billing_expr': modelDefaults.BillingExpr,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Pricing Ratios')}
|
||||
description={t(
|
||||
'Configure model, caching, and group ratios used for billing'
|
||||
)}
|
||||
>
|
||||
<Tabs defaultValue='models' className='space-y-6'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='models'>{t('Model ratios')}</TabsTrigger>
|
||||
<TabsTrigger value='groups'>{t('Group ratios')}</TabsTrigger>
|
||||
<TabsTrigger value='tool-prices'>{t('Tool prices')}</TabsTrigger>
|
||||
<TabsTrigger value='upstream-sync'>
|
||||
{t('Upstream price sync')}
|
||||
</TabsTrigger>
|
||||
<SettingsSection title={t(titleKey)} description={t(descriptionKey)}>
|
||||
<Tabs defaultValue={defaultTab} className='space-y-6'>
|
||||
<TabsList className={`grid w-full ${tabsGridClass}`}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{t(tabLabels[tab])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='models'>
|
||||
<ModelRatioForm
|
||||
form={modelForm}
|
||||
onSave={saveModelRatios}
|
||||
onReset={handleResetRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='groups'>
|
||||
<GroupRatioForm
|
||||
form={groupForm}
|
||||
onSave={saveGroupRatios}
|
||||
isSaving={updateOption.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='tool-prices'>
|
||||
<ToolPriceSettings defaultValue={toolPricesDefault} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='upstream-sync'>
|
||||
<UpstreamRatioSync
|
||||
modelRatios={{
|
||||
ModelPrice: modelDefaults.ModelPrice,
|
||||
ModelRatio: modelDefaults.ModelRatio,
|
||||
CompletionRatio: modelDefaults.CompletionRatio,
|
||||
CacheRatio: modelDefaults.CacheRatio,
|
||||
CreateCacheRatio: modelDefaults.CreateCacheRatio,
|
||||
ImageRatio: modelDefaults.ImageRatio,
|
||||
AudioRatio: modelDefaults.AudioRatio,
|
||||
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
|
||||
'billing_setting.billing_mode': modelDefaults.BillingMode,
|
||||
'billing_setting.billing_expr': modelDefaults.BillingExpr,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -4,7 +4,8 @@ import { ClaudeSettingsCard } from './claude-settings-card'
|
||||
import { GeminiSettingsCard } from './gemini-settings-card'
|
||||
import { GlobalSettingsCard } from './global-settings-card'
|
||||
import { GrokSettingsCard } from './grok-settings-card'
|
||||
import { RatioSettingsCard } from './ratio-settings-card'
|
||||
import { ChannelAffinitySection } from '../general/channel-affinity'
|
||||
import { IoNetDeploymentSettingsSection } from '../integrations/ionet-deployment-settings-section'
|
||||
|
||||
function formatJsonForEditor(value: string, fallback: string) {
|
||||
const raw = (value ?? '').toString().trim()
|
||||
@@ -106,34 +107,35 @@ const MODELS_SECTIONS = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
titleKey: 'Pricing Ratios',
|
||||
descriptionKey: 'Configure model pricing and ratio settings',
|
||||
id: 'channel-affinity',
|
||||
titleKey: 'Channel Affinity',
|
||||
descriptionKey: 'Configure channel affinity (sticky routing) rules',
|
||||
build: (settings: ModelSettings) => (
|
||||
<RatioSettingsCard
|
||||
modelDefaults={{
|
||||
ModelPrice: settings.ModelPrice,
|
||||
ModelRatio: settings.ModelRatio,
|
||||
CacheRatio: settings.CacheRatio,
|
||||
CreateCacheRatio: settings.CreateCacheRatio,
|
||||
CompletionRatio: settings.CompletionRatio,
|
||||
ImageRatio: settings.ImageRatio,
|
||||
AudioRatio: settings.AudioRatio,
|
||||
AudioCompletionRatio: settings.AudioCompletionRatio,
|
||||
ExposeRatioEnabled: settings.ExposeRatioEnabled,
|
||||
BillingMode: settings['billing_setting.billing_mode'],
|
||||
BillingExpr: settings['billing_setting.billing_expr'],
|
||||
<ChannelAffinitySection
|
||||
defaultValues={{
|
||||
'channel_affinity_setting.enabled':
|
||||
settings['channel_affinity_setting.enabled'],
|
||||
'channel_affinity_setting.switch_on_success':
|
||||
settings['channel_affinity_setting.switch_on_success'],
|
||||
'channel_affinity_setting.max_entries':
|
||||
settings['channel_affinity_setting.max_entries'],
|
||||
'channel_affinity_setting.default_ttl_seconds':
|
||||
settings['channel_affinity_setting.default_ttl_seconds'],
|
||||
'channel_affinity_setting.rules':
|
||||
settings['channel_affinity_setting.rules'],
|
||||
}}
|
||||
toolPricesDefault={settings['tool_price_setting.prices']}
|
||||
groupDefaults={{
|
||||
TopupGroupRatio: settings.TopupGroupRatio,
|
||||
GroupRatio: settings.GroupRatio,
|
||||
UserUsableGroups: settings.UserUsableGroups,
|
||||
GroupGroupRatio: settings.GroupGroupRatio,
|
||||
AutoGroups: settings.AutoGroups,
|
||||
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
|
||||
GroupSpecialUsableGroup:
|
||||
settings['group_ratio_setting.group_special_usable_group'],
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'model-deployment',
|
||||
titleKey: 'Model Deployment',
|
||||
descriptionKey: 'Configure model deployment provider settings',
|
||||
build: (settings: ModelSettings) => (
|
||||
<IoNetDeploymentSettingsSection
|
||||
defaultValues={{
|
||||
enabled: settings['model_deployment.ionet.enabled'],
|
||||
apiKey: settings['model_deployment.ionet.api_key'],
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
+246
-150
@@ -10,7 +10,7 @@ import {
|
||||
type InputHTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react'
|
||||
import { Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
BILLING_EXTRA_VARS,
|
||||
@@ -38,6 +39,10 @@ import {
|
||||
MATCH_CONTAINS,
|
||||
MATCH_EQ,
|
||||
MATCH_EXISTS,
|
||||
MATCH_GT,
|
||||
MATCH_GTE,
|
||||
MATCH_LT,
|
||||
MATCH_LTE,
|
||||
MATCH_RANGE,
|
||||
SOURCE_HEADER,
|
||||
SOURCE_PARAM,
|
||||
@@ -48,7 +53,6 @@ import {
|
||||
createEmptyCondition,
|
||||
createEmptyRuleGroup,
|
||||
createEmptyTimeCondition,
|
||||
createEmptyTimeRuleGroup,
|
||||
getRequestRuleMatchOptions,
|
||||
splitBillingExprAndRequestRules,
|
||||
tryParseRequestRuleExpr,
|
||||
@@ -77,11 +81,20 @@ import {
|
||||
} from '@/features/pricing/lib/tier-expr'
|
||||
|
||||
const PRICE_SUFFIX = '$/1M tokens'
|
||||
const CACHE_PRICE_VARS = BILLING_EXTRA_VARS.filter(
|
||||
(variable) => variable.group === 'cache'
|
||||
)
|
||||
const MEDIA_PRICE_VARS = BILLING_EXTRA_VARS.filter(
|
||||
(variable) => variable.group === 'media'
|
||||
)
|
||||
|
||||
const VAR_OPTIONS: { value: TierConditionInput['var']; label: string }[] = [
|
||||
{ value: 'len', label: 'len (input length)' },
|
||||
{ value: 'p', label: 'p (input)' },
|
||||
{ value: 'c', label: 'c (output)' },
|
||||
const CONDITION_INPUT_OPTIONS: {
|
||||
value: TierConditionInput['var']
|
||||
labelKey: string
|
||||
}[] = [
|
||||
{ value: 'len', labelKey: 'Full input length' },
|
||||
{ value: 'p', labelKey: 'Billable input tokens' },
|
||||
{ value: 'c', labelKey: 'Billable output tokens' },
|
||||
]
|
||||
const OPS: TierConditionInput['op'][] = ['<', '<=', '>', '>=']
|
||||
|
||||
@@ -394,6 +407,11 @@ type ConditionRowProps = {
|
||||
}
|
||||
|
||||
function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const currentInputOption = CONDITION_INPUT_OPTIONS.find(
|
||||
(option) => option.value === condition.var
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Select
|
||||
@@ -403,12 +421,16 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{currentInputOption
|
||||
? t(currentInputOption.labelKey)
|
||||
: condition.var}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAR_OPTIONS.map((option) => (
|
||||
{CONDITION_INPUT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -462,31 +484,19 @@ type PriceFieldProps = {
|
||||
hint?: string
|
||||
value: number
|
||||
onChange: (next: number) => void
|
||||
showSuffix?: boolean
|
||||
}
|
||||
|
||||
function PriceField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
showSuffix = true,
|
||||
}: PriceFieldProps) {
|
||||
function PriceField({ label, hint, value, onChange }: PriceFieldProps) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{label}</Label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
onValueChange={onChange}
|
||||
className='w-32'
|
||||
/>
|
||||
{showSuffix && (
|
||||
<span className='text-muted-foreground text-xs'>{PRICE_SUFFIX}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='w-36 space-y-0.5'>
|
||||
<Label className='text-muted-foreground text-xs'>{label}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
onValueChange={onChange}
|
||||
className='h-8 w-full'
|
||||
/>
|
||||
{hint && <p className='text-muted-foreground text-xs'>{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
@@ -547,21 +557,49 @@ function VisualTierCard({
|
||||
|
||||
const inputUnitPrice = unitCostToPrice(tier.input_unit_cost)
|
||||
const outputUnitPrice = unitCostToPrice(tier.output_unit_cost)
|
||||
const hasMediaPricing = MEDIA_PRICE_VARS.some((variable) => {
|
||||
const fieldKey = variable.tierField as keyof VisualTier
|
||||
return unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0) > 0
|
||||
})
|
||||
const [mediaOpen, setMediaOpen] = useState(hasMediaPricing)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMediaPricing) setMediaOpen(true)
|
||||
}, [hasMediaPricing])
|
||||
|
||||
const renderPriceVariable = (
|
||||
variable: (typeof BILLING_EXTRA_VARS)[number]
|
||||
) => {
|
||||
const fieldKey = variable.tierField as keyof VisualTier
|
||||
const value = unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0)
|
||||
|
||||
return (
|
||||
<PriceField
|
||||
key={variable.key}
|
||||
label={t(variable.label)}
|
||||
value={value}
|
||||
onChange={(next) => handlePriceChange(fieldKey, priceToUnitCost(next))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='space-y-3 rounded-lg border p-3'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant='outline'>
|
||||
{t('Tier')} {index + 1} / {total}
|
||||
</Badge>
|
||||
{tier.conditions.length === 0 && (
|
||||
<Badge variant='secondary'>{t('Fallback tier')}</Badge>
|
||||
)}
|
||||
<Input
|
||||
value={tier.label}
|
||||
onChange={(event) =>
|
||||
onChange({ ...tier, label: event.target.value })
|
||||
}
|
||||
placeholder={t('Tier name')}
|
||||
className='h-8 w-40'
|
||||
className='h-7 w-36'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -575,9 +613,10 @@ function VisualTierCard({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-xs'>{t('Conditions (AND)')}</Label>
|
||||
{/* Conditions */}
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex h-7 items-center justify-between'>
|
||||
<Label className='text-xs font-medium'>{t('Tier conditions')}</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
@@ -605,67 +644,90 @@ function VisualTierCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
<PriceField
|
||||
label={t('Input price')}
|
||||
hint={`${inputUnitPrice} × p`}
|
||||
value={inputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('input_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
<PriceField
|
||||
label={t('Output price')}
|
||||
hint={`${outputUnitPrice} × c`}
|
||||
value={outputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('output_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Label className='text-sm font-semibold'>{t('Token prices')}</Label>
|
||||
<span className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs'>
|
||||
{PRICE_SUFFIX}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-2'>
|
||||
<PriceField
|
||||
label={t('Input price')}
|
||||
value={inputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('input_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
<PriceField
|
||||
label={t('Output price')}
|
||||
value={outputUnitPrice}
|
||||
onChange={(value) =>
|
||||
handlePriceChange('output_unit_cost', priceToUnitCost(value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex h-7 items-center'>
|
||||
<Tabs
|
||||
value={cacheMode}
|
||||
onValueChange={(value) =>
|
||||
value !== null && handleCacheModeChange(value as CacheMode)
|
||||
}
|
||||
>
|
||||
<TabsList className='h-8'>
|
||||
<TabsTrigger
|
||||
value={CACHE_MODE_GENERIC}
|
||||
className='px-2 text-xs'
|
||||
>
|
||||
{t('Generic cache')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={CACHE_MODE_TIMED}
|
||||
className='px-2 text-xs'
|
||||
>
|
||||
{t('Time-sliced cache (Claude)')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-2'>
|
||||
{CACHE_PRICE_VARS.map((variable) => {
|
||||
if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) {
|
||||
return null
|
||||
}
|
||||
return renderPriceVariable(variable)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-xs'>{t('Cache mode')}</Label>
|
||||
<Select
|
||||
value={cacheMode}
|
||||
onValueChange={(value) => handleCacheModeChange(value as CacheMode)}
|
||||
>
|
||||
<SelectTrigger className='w-44' size='sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CACHE_MODE_GENERIC}>
|
||||
{t('Generic cache')}
|
||||
</SelectItem>
|
||||
<SelectItem value={CACHE_MODE_TIMED}>
|
||||
{t('Timed cache (1h)')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
{BILLING_EXTRA_VARS.map((variable) => {
|
||||
if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) {
|
||||
return null
|
||||
}
|
||||
const fieldKey = variable.tierField as keyof VisualTier
|
||||
const value = unitCostToPrice(
|
||||
(tier[fieldKey] as number | undefined) ?? 0
|
||||
)
|
||||
return (
|
||||
<PriceField
|
||||
key={variable.key}
|
||||
label={variable.label}
|
||||
hint={`${value} × ${variable.key}`}
|
||||
value={value}
|
||||
onChange={(next) =>
|
||||
handlePriceChange(fieldKey, priceToUnitCost(next))
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Media prices */}
|
||||
<div className='space-y-1.5'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 px-2 text-xs'
|
||||
onClick={() => setMediaOpen((prev) => !prev)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mr-1 h-3 w-3 transition-transform',
|
||||
mediaOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
{t('Media pricing')}
|
||||
</Button>
|
||||
{mediaOpen && (
|
||||
<div className='flex flex-wrap gap-x-4 gap-y-2'>
|
||||
{MEDIA_PRICE_VARS.map(renderPriceVariable)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -747,14 +809,12 @@ function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<Alert>
|
||||
<AlertDescription className='text-xs'>
|
||||
{t(
|
||||
'Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Each tier supports up to 2 conditions. The last tier without conditions is the fallback.'
|
||||
)}
|
||||
</p>
|
||||
{config.tiers.map((tier, index) => (
|
||||
<VisualTierCard
|
||||
key={index}
|
||||
@@ -766,7 +826,12 @@ function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
|
||||
onAddCondition={() => handleAddCondition(index)}
|
||||
/>
|
||||
))}
|
||||
<Button variant='outline' size='sm' onClick={handleAddTier}>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 w-36 justify-center'
|
||||
onClick={handleAddTier}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add tier')}
|
||||
</Button>
|
||||
@@ -832,6 +897,50 @@ function RuleConditionRow({
|
||||
}: RuleConditionRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const matchOptions = getRequestRuleMatchOptions(condition.source)
|
||||
const getMatchLabel = (mode: string) => {
|
||||
switch (mode) {
|
||||
case MATCH_EQ:
|
||||
return t('Equals')
|
||||
case MATCH_CONTAINS:
|
||||
return t('Contains')
|
||||
case MATCH_EXISTS:
|
||||
return t('Exists')
|
||||
case MATCH_GT:
|
||||
return t('Greater than')
|
||||
case MATCH_GTE:
|
||||
return t('Greater than or equal')
|
||||
case MATCH_LT:
|
||||
return t('Less than')
|
||||
case MATCH_LTE:
|
||||
return t('Less than or equal')
|
||||
case MATCH_RANGE:
|
||||
return t('Overnight range')
|
||||
default:
|
||||
return mode
|
||||
}
|
||||
}
|
||||
const getTimeFuncLabel = (timeFunc: TimeFunc) => {
|
||||
switch (timeFunc) {
|
||||
case 'hour':
|
||||
return t('Hour of day')
|
||||
case 'minute':
|
||||
return t('Minute')
|
||||
case 'weekday':
|
||||
return t('Weekday')
|
||||
case 'month':
|
||||
return t('Month number')
|
||||
case 'day':
|
||||
return t('Day of month')
|
||||
default:
|
||||
return timeFunc
|
||||
}
|
||||
}
|
||||
const sourceLabel =
|
||||
condition.source === SOURCE_PARAM
|
||||
? t('Body param')
|
||||
: condition.source === SOURCE_HEADER
|
||||
? t('Header')
|
||||
: t('Time')
|
||||
|
||||
const handleSourceChange = (source: string) => {
|
||||
if (source === SOURCE_TIME) {
|
||||
@@ -857,12 +966,12 @@ function RuleConditionRow({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{getTimeFuncLabel(timeCond.timeFunc)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_FUNCS.map((fn) => (
|
||||
<SelectItem key={fn} value={fn}>
|
||||
{fn}
|
||||
{getTimeFuncLabel(fn)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -874,7 +983,10 @@ function RuleConditionRow({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-56' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{COMMON_TIMEZONES.find((tz) => tz.value === timeCond.timezone)
|
||||
?.label ?? timeCond.timezone}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
@@ -889,12 +1001,12 @@ function RuleConditionRow({
|
||||
onValueChange={(v) => v !== null && handleModeChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{getMatchLabel(timeCond.mode)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{matchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
{getMatchLabel(option.value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -906,7 +1018,7 @@ function RuleConditionRow({
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, rangeStart: String(value) })
|
||||
}
|
||||
placeholder='start'
|
||||
placeholder={t('Start')}
|
||||
className='w-20'
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>~</span>
|
||||
@@ -915,7 +1027,7 @@ function RuleConditionRow({
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, rangeEnd: String(value) })
|
||||
}
|
||||
placeholder='end'
|
||||
placeholder={t('End')}
|
||||
className='w-20'
|
||||
/>
|
||||
</>
|
||||
@@ -925,7 +1037,7 @@ function RuleConditionRow({
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, value: String(value) })
|
||||
}
|
||||
placeholder='value'
|
||||
placeholder={t('Value')}
|
||||
className='w-24'
|
||||
/>
|
||||
)}
|
||||
@@ -947,12 +1059,12 @@ function RuleConditionRow({
|
||||
onValueChange={(v) => v !== null && handleModeChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-32' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{getMatchLabel(phCond.mode)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{matchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
{getMatchLabel(option.value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -977,7 +1089,7 @@ function RuleConditionRow({
|
||||
onValueChange={(v) => v !== null && handleSourceChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-28' size='sm'>
|
||||
<SelectValue />
|
||||
<SelectValue>{sourceLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SOURCE_PARAM}>{t('Body param')}</SelectItem>
|
||||
@@ -1214,7 +1326,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{t('Input tokens')} (p)</Label>
|
||||
<Label className='text-xs'>{t('Input tokens')}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={promptTokens}
|
||||
@@ -1222,7 +1334,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{t('Output tokens')} (c)</Label>
|
||||
<Label className='text-xs'>{t('Output tokens')}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={completionTokens}
|
||||
@@ -1243,9 +1355,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
) as keyof ExtraTokenValues
|
||||
return (
|
||||
<div key={variable.key} className='space-y-1'>
|
||||
<Label className='text-xs'>
|
||||
{variable.shortLabel} ({variable.key})
|
||||
</Label>
|
||||
<Label className='text-xs'>{t(variable.shortLabel)}</Label>
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={extras[stateKey]}
|
||||
@@ -1612,7 +1722,7 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
<div className='flex-1'>
|
||||
<PresetSection applyPreset={applyPreset} />
|
||||
</div>
|
||||
<LlmPromptHelper modelName={modelName} />
|
||||
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
|
||||
@@ -1665,34 +1775,20 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleRuleGroupsChange([
|
||||
...requestRuleGroups,
|
||||
createEmptyRuleGroup(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add rule group')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleRuleGroupsChange([
|
||||
...requestRuleGroups,
|
||||
createEmptyTimeRuleGroup(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add time rule group')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-9 w-36 justify-center'
|
||||
onClick={() =>
|
||||
handleRuleGroupsChange([
|
||||
...requestRuleGroups,
|
||||
createEmptyRuleGroup(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add rule group')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user