1033 lines
29 KiB
TypeScript
1033 lines
29 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from 'react'
|
||
|
|
import * as z from 'zod'
|
||
|
|
import { useForm } from 'react-hook-form'
|
||
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||
|
|
import { AlertTriangle, ChevronDown } from 'lucide-react'
|
||
|
|
import { useTranslation } from 'react-i18next'
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import {
|
||
|
|
Collapsible,
|
||
|
|
CollapsibleContent,
|
||
|
|
CollapsibleTrigger,
|
||
|
|
} from '@/components/ui/collapsible'
|
||
|
|
import {
|
||
|
|
Field,
|
||
|
|
FieldContent,
|
||
|
|
FieldDescription,
|
||
|
|
FieldGroup,
|
||
|
|
FieldLabel,
|
||
|
|
FieldTitle,
|
||
|
|
} from '@/components/ui/field'
|
||
|
|
import {
|
||
|
|
Form,
|
||
|
|
FormControl,
|
||
|
|
FormDescription,
|
||
|
|
FormField,
|
||
|
|
FormItem,
|
||
|
|
FormLabel,
|
||
|
|
FormMessage,
|
||
|
|
} from '@/components/ui/form'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import {
|
||
|
|
InputGroup,
|
||
|
|
InputGroupAddon,
|
||
|
|
InputGroupInput,
|
||
|
|
} from '@/components/ui/input-group'
|
||
|
|
import {
|
||
|
|
Sheet,
|
||
|
|
SheetContent,
|
||
|
|
SheetDescription,
|
||
|
|
SheetFooter,
|
||
|
|
SheetHeader,
|
||
|
|
SheetTitle,
|
||
|
|
} from '@/components/ui/sheet'
|
||
|
|
import { Switch } from '@/components/ui/switch'
|
||
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
|
|
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||
|
|
import { TieredPricingEditor } from './tiered-pricing-editor'
|
||
|
|
|
||
|
|
const createModelPricingSchema = (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 ModelPricingFormValues = z.infer<
|
||
|
|
ReturnType<typeof createModelPricingSchema>
|
||
|
|
>
|
||
|
|
|
||
|
|
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
|
||
|
|
type LaneKey =
|
||
|
|
| 'completion'
|
||
|
|
| 'cache'
|
||
|
|
| 'createCache'
|
||
|
|
| 'image'
|
||
|
|
| 'audioInput'
|
||
|
|
| 'audioOutput'
|
||
|
|
|
||
|
|
export type ModelRatioData = {
|
||
|
|
name: string
|
||
|
|
price?: string
|
||
|
|
ratio?: string
|
||
|
|
cacheRatio?: string
|
||
|
|
createCacheRatio?: string
|
||
|
|
completionRatio?: string
|
||
|
|
imageRatio?: string
|
||
|
|
audioRatio?: string
|
||
|
|
audioCompletionRatio?: string
|
||
|
|
billingMode?: PricingMode
|
||
|
|
billingExpr?: string
|
||
|
|
requestRuleExpr?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
type ModelPricingSheetProps = {
|
||
|
|
open: boolean
|
||
|
|
onOpenChange: (open: boolean) => void
|
||
|
|
onSave: (data: ModelRatioData) => void
|
||
|
|
onCancel?: () => void
|
||
|
|
editData?: ModelRatioData | null
|
||
|
|
selectedTargetCount?: number
|
||
|
|
}
|
||
|
|
|
||
|
|
type ModelPricingEditorPanelProps = Omit<
|
||
|
|
ModelPricingSheetProps,
|
||
|
|
'open' | 'onOpenChange'
|
||
|
|
> & {
|
||
|
|
className?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
type PreviewRow = {
|
||
|
|
key: string
|
||
|
|
label: string
|
||
|
|
value: string
|
||
|
|
multiline?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
|
||
|
|
|
||
|
|
const EMPTY_LANE_PRICES: Record<LaneKey, string> = {
|
||
|
|
completion: '',
|
||
|
|
cache: '',
|
||
|
|
createCache: '',
|
||
|
|
image: '',
|
||
|
|
audioInput: '',
|
||
|
|
audioOutput: '',
|
||
|
|
}
|
||
|
|
|
||
|
|
const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
|
||
|
|
completion: false,
|
||
|
|
cache: false,
|
||
|
|
createCache: false,
|
||
|
|
image: false,
|
||
|
|
audioInput: false,
|
||
|
|
audioOutput: false,
|
||
|
|
}
|
||
|
|
|
||
|
|
const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
|
||
|
|
completion: 'completionRatio',
|
||
|
|
cache: 'cacheRatio',
|
||
|
|
createCache: 'createCacheRatio',
|
||
|
|
image: 'imageRatio',
|
||
|
|
audioInput: 'audioRatio',
|
||
|
|
audioOutput: 'audioCompletionRatio',
|
||
|
|
}
|
||
|
|
|
||
|
|
const laneConfigs: Array<{
|
||
|
|
key: LaneKey
|
||
|
|
titleKey: string
|
||
|
|
descriptionKey: string
|
||
|
|
placeholder: string
|
||
|
|
}> = [
|
||
|
|
{
|
||
|
|
key: 'completion',
|
||
|
|
titleKey: 'Completion price',
|
||
|
|
descriptionKey: 'Output token price for generated tokens.',
|
||
|
|
placeholder: '15',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'cache',
|
||
|
|
titleKey: 'Cache read price',
|
||
|
|
descriptionKey: 'Token price for cache reads.',
|
||
|
|
placeholder: '0.3',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'createCache',
|
||
|
|
titleKey: 'Cache write price',
|
||
|
|
descriptionKey: 'Token price for creating cache entries.',
|
||
|
|
placeholder: '3.75',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'image',
|
||
|
|
titleKey: 'Image input price',
|
||
|
|
descriptionKey: 'Token price for image input.',
|
||
|
|
placeholder: '2.5',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'audioInput',
|
||
|
|
titleKey: 'Audio input price',
|
||
|
|
descriptionKey: 'Token price for audio input.',
|
||
|
|
placeholder: '3.81',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'audioOutput',
|
||
|
|
titleKey: 'Audio output price',
|
||
|
|
descriptionKey: 'Token price for audio output.',
|
||
|
|
placeholder: '15.11',
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
function hasValue(value: unknown): boolean {
|
||
|
|
return (
|
||
|
|
value !== '' && value !== null && value !== undefined && value !== false
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function toNumberOrNull(value: unknown): number | null {
|
||
|
|
if (!hasValue(value) && value !== 0) return null
|
||
|
|
const num = Number(value)
|
||
|
|
return Number.isFinite(num) ? num : null
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatNumber(value: unknown): string {
|
||
|
|
const num = toNumberOrNull(value)
|
||
|
|
if (num === null) return ''
|
||
|
|
return Number.parseFloat(num.toFixed(12)).toString()
|
||
|
|
}
|
||
|
|
|
||
|
|
function ratioToBasePrice(ratio: unknown): string {
|
||
|
|
const num = toNumberOrNull(ratio)
|
||
|
|
if (num === null) return ''
|
||
|
|
return formatNumber(num * 2)
|
||
|
|
}
|
||
|
|
|
||
|
|
function deriveLanePrice(
|
||
|
|
ratio: unknown,
|
||
|
|
denominator: unknown,
|
||
|
|
fallback = ''
|
||
|
|
): string {
|
||
|
|
const ratioNumber = toNumberOrNull(ratio)
|
||
|
|
const denominatorNumber = toNumberOrNull(denominator)
|
||
|
|
if (ratioNumber === null || denominatorNumber === null) return fallback
|
||
|
|
return formatNumber(ratioNumber * denominatorNumber)
|
||
|
|
}
|
||
|
|
|
||
|
|
function createInitialLaneState(data?: ModelRatioData | null) {
|
||
|
|
if (!data) {
|
||
|
|
return {
|
||
|
|
promptPrice: '',
|
||
|
|
prices: { ...EMPTY_LANE_PRICES },
|
||
|
|
enabled: { ...EMPTY_LANE_ENABLED },
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const promptPrice = ratioToBasePrice(data.ratio)
|
||
|
|
const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
|
||
|
|
const prices: Record<LaneKey, string> = {
|
||
|
|
completion: deriveLanePrice(data.completionRatio, promptPrice),
|
||
|
|
cache: deriveLanePrice(data.cacheRatio, promptPrice),
|
||
|
|
createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
|
||
|
|
image: deriveLanePrice(data.imageRatio, promptPrice),
|
||
|
|
audioInput: audioInputPrice,
|
||
|
|
audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
promptPrice,
|
||
|
|
prices,
|
||
|
|
enabled: {
|
||
|
|
completion: hasValue(data.completionRatio),
|
||
|
|
cache: hasValue(data.cacheRatio),
|
||
|
|
createCache: hasValue(data.createCacheRatio),
|
||
|
|
image: hasValue(data.imageRatio),
|
||
|
|
audioInput: hasValue(data.audioRatio),
|
||
|
|
audioOutput: hasValue(data.audioCompletionRatio),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getModeLabel(mode: PricingMode) {
|
||
|
|
if (mode === 'per-request') return 'Per-request'
|
||
|
|
if (mode === 'tiered_expr') return 'Expression'
|
||
|
|
return 'Per-token'
|
||
|
|
}
|
||
|
|
|
||
|
|
function getModeBadgeVariant(
|
||
|
|
mode: PricingMode
|
||
|
|
): 'default' | 'secondary' | 'outline' {
|
||
|
|
if (mode === 'per-request') return 'secondary'
|
||
|
|
if (mode === 'tiered_expr') return 'default'
|
||
|
|
return 'outline'
|
||
|
|
}
|
||
|
|
|
||
|
|
function truncateExpr(value: string) {
|
||
|
|
if (!value) return ''
|
||
|
|
return value.length > 110 ? `${value.slice(0, 110)}...` : value
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildPreviewRows(
|
||
|
|
values: ModelPricingFormValues,
|
||
|
|
mode: PricingMode,
|
||
|
|
billingExpr: string,
|
||
|
|
requestRuleExpr: string,
|
||
|
|
promptPrice: string,
|
||
|
|
lanePrices: Record<LaneKey, string>,
|
||
|
|
laneEnabled: Record<LaneKey, boolean>,
|
||
|
|
t: (key: string) => string
|
||
|
|
): PreviewRow[] {
|
||
|
|
if (mode === 'tiered_expr') {
|
||
|
|
const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
|
||
|
|
return [
|
||
|
|
{ key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
|
||
|
|
{
|
||
|
|
key: 'expr',
|
||
|
|
label: t('Expression'),
|
||
|
|
value: effectiveExpr || t('Empty'),
|
||
|
|
multiline: true,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
if (mode === 'per-request') {
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
key: 'price',
|
||
|
|
label: 'ModelPrice',
|
||
|
|
value: values.price || t('Empty'),
|
||
|
|
},
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
key: 'inputPrice',
|
||
|
|
label: t('Input price'),
|
||
|
|
value: promptPrice ? `$${promptPrice}` : t('Empty'),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'completion',
|
||
|
|
label: t('Completion price'),
|
||
|
|
value:
|
||
|
|
laneEnabled.completion && lanePrices.completion
|
||
|
|
? `$${lanePrices.completion}`
|
||
|
|
: t('Empty'),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'cache',
|
||
|
|
label: t('Cache read price'),
|
||
|
|
value:
|
||
|
|
laneEnabled.cache && lanePrices.cache
|
||
|
|
? `$${lanePrices.cache}`
|
||
|
|
: t('Empty'),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'createCache',
|
||
|
|
label: t('Cache write price'),
|
||
|
|
value:
|
||
|
|
laneEnabled.createCache && lanePrices.createCache
|
||
|
|
? `$${lanePrices.createCache}`
|
||
|
|
: t('Empty'),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'image',
|
||
|
|
label: t('Image input price'),
|
||
|
|
value:
|
||
|
|
laneEnabled.image && lanePrices.image
|
||
|
|
? `$${lanePrices.image}`
|
||
|
|
: t('Empty'),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'audio',
|
||
|
|
label: t('Audio input price'),
|
||
|
|
value:
|
||
|
|
laneEnabled.audioInput && lanePrices.audioInput
|
||
|
|
? `$${lanePrices.audioInput}`
|
||
|
|
: t('Empty'),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'audioCompletion',
|
||
|
|
label: t('Audio output price'),
|
||
|
|
value:
|
||
|
|
laneEnabled.audioOutput && lanePrices.audioOutput
|
||
|
|
? `$${lanePrices.audioOutput}`
|
||
|
|
: t('Empty'),
|
||
|
|
},
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ModelPricingSheet({
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
onSave,
|
||
|
|
onCancel,
|
||
|
|
editData,
|
||
|
|
selectedTargetCount = 0,
|
||
|
|
}: ModelPricingSheetProps) {
|
||
|
|
const { t } = useTranslation()
|
||
|
|
const title = editData ? t('Edit model pricing') : t('Add model pricing')
|
||
|
|
const description = editData?.name || t('New model')
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
|
|
<SheetContent side='right' className='w-full gap-0 p-0 sm:max-w-2xl'>
|
||
|
|
<SheetHeader className='sr-only'>
|
||
|
|
<SheetTitle>{title}</SheetTitle>
|
||
|
|
<SheetDescription>{description}</SheetDescription>
|
||
|
|
</SheetHeader>
|
||
|
|
<ModelPricingEditorPanel
|
||
|
|
onSave={onSave}
|
||
|
|
editData={editData}
|
||
|
|
selectedTargetCount={selectedTargetCount}
|
||
|
|
onCancel={() => {
|
||
|
|
onCancel?.()
|
||
|
|
onOpenChange(false)
|
||
|
|
}}
|
||
|
|
className='h-full rounded-none border-0'
|
||
|
|
/>
|
||
|
|
</SheetContent>
|
||
|
|
</Sheet>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ModelPricingEditorPanel({
|
||
|
|
onSave,
|
||
|
|
editData,
|
||
|
|
selectedTargetCount = 0,
|
||
|
|
onCancel,
|
||
|
|
className,
|
||
|
|
}: ModelPricingEditorPanelProps) {
|
||
|
|
const { t } = useTranslation()
|
||
|
|
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
||
|
|
const [promptPrice, setPromptPrice] = useState('')
|
||
|
|
const [lanePrices, setLanePrices] = useState<Record<LaneKey, string>>({
|
||
|
|
...EMPTY_LANE_PRICES,
|
||
|
|
})
|
||
|
|
const [laneEnabled, setLaneEnabled] = useState<Record<LaneKey, boolean>>({
|
||
|
|
...EMPTY_LANE_ENABLED,
|
||
|
|
})
|
||
|
|
const [billingExpr, setBillingExpr] = useState('')
|
||
|
|
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
||
|
|
const [previewOpen, setPreviewOpen] = useState(true)
|
||
|
|
const isEditMode = !!editData
|
||
|
|
|
||
|
|
const form = useForm<ModelPricingFormValues>({
|
||
|
|
resolver: zodResolver(createModelPricingSchema(t)),
|
||
|
|
defaultValues: {
|
||
|
|
name: '',
|
||
|
|
price: '',
|
||
|
|
ratio: '',
|
||
|
|
cacheRatio: '',
|
||
|
|
createCacheRatio: '',
|
||
|
|
completionRatio: '',
|
||
|
|
imageRatio: '',
|
||
|
|
audioRatio: '',
|
||
|
|
audioCompletionRatio: '',
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const nextLaneState = createInitialLaneState(editData)
|
||
|
|
|
||
|
|
if (editData) {
|
||
|
|
form.reset({
|
||
|
|
name: editData.name,
|
||
|
|
price: editData.price || '',
|
||
|
|
ratio: editData.ratio || '',
|
||
|
|
cacheRatio: editData.cacheRatio || '',
|
||
|
|
createCacheRatio: editData.createCacheRatio || '',
|
||
|
|
completionRatio: editData.completionRatio || '',
|
||
|
|
imageRatio: editData.imageRatio || '',
|
||
|
|
audioRatio: editData.audioRatio || '',
|
||
|
|
audioCompletionRatio: editData.audioCompletionRatio || '',
|
||
|
|
})
|
||
|
|
setPricingMode(
|
||
|
|
editData.billingMode === 'tiered_expr'
|
||
|
|
? 'tiered_expr'
|
||
|
|
: editData.price
|
||
|
|
? 'per-request'
|
||
|
|
: 'per-token'
|
||
|
|
)
|
||
|
|
setBillingExpr(editData.billingExpr || '')
|
||
|
|
setRequestRuleExpr(editData.requestRuleExpr || '')
|
||
|
|
} else {
|
||
|
|
form.reset({
|
||
|
|
name: '',
|
||
|
|
price: '',
|
||
|
|
ratio: '',
|
||
|
|
cacheRatio: '',
|
||
|
|
createCacheRatio: '',
|
||
|
|
completionRatio: '',
|
||
|
|
imageRatio: '',
|
||
|
|
audioRatio: '',
|
||
|
|
audioCompletionRatio: '',
|
||
|
|
})
|
||
|
|
setPricingMode('per-token')
|
||
|
|
setBillingExpr('')
|
||
|
|
setRequestRuleExpr('')
|
||
|
|
}
|
||
|
|
|
||
|
|
setPromptPrice(nextLaneState.promptPrice)
|
||
|
|
setLanePrices(nextLaneState.prices)
|
||
|
|
setLaneEnabled(nextLaneState.enabled)
|
||
|
|
setPreviewOpen(true)
|
||
|
|
}, [editData, form])
|
||
|
|
|
||
|
|
const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
|
||
|
|
form.setValue(field, value, {
|
||
|
|
shouldDirty: true,
|
||
|
|
shouldValidate: true,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const deriveLaneRatio = (
|
||
|
|
lane: LaneKey,
|
||
|
|
price: string,
|
||
|
|
nextPromptPrice = promptPrice,
|
||
|
|
nextLanePrices = lanePrices
|
||
|
|
) => {
|
||
|
|
const priceNumber = toNumberOrNull(price)
|
||
|
|
if (priceNumber === null) return ''
|
||
|
|
|
||
|
|
if (lane === 'audioOutput') {
|
||
|
|
const audioInputPrice = toNumberOrNull(nextLanePrices.audioInput)
|
||
|
|
if (audioInputPrice === null || audioInputPrice === 0) return ''
|
||
|
|
return formatNumber(priceNumber / audioInputPrice)
|
||
|
|
}
|
||
|
|
|
||
|
|
const inputPrice = toNumberOrNull(nextPromptPrice)
|
||
|
|
if (inputPrice === null || inputPrice === 0) return ''
|
||
|
|
return formatNumber(priceNumber / inputPrice)
|
||
|
|
}
|
||
|
|
|
||
|
|
const syncLaneRatios = (
|
||
|
|
nextPromptPrice = promptPrice,
|
||
|
|
nextLanePrices = lanePrices,
|
||
|
|
nextLaneEnabled = laneEnabled
|
||
|
|
) => {
|
||
|
|
const inputPrice = toNumberOrNull(nextPromptPrice)
|
||
|
|
setFormValue(
|
||
|
|
'ratio',
|
||
|
|
inputPrice !== null ? formatNumber(inputPrice / 2) : ''
|
||
|
|
)
|
||
|
|
|
||
|
|
laneConfigs.forEach(({ key }) => {
|
||
|
|
const ratioField = ratioFieldByLane[key]
|
||
|
|
if (!nextLaneEnabled[key]) {
|
||
|
|
setFormValue(ratioField, '')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setFormValue(
|
||
|
|
ratioField,
|
||
|
|
deriveLaneRatio(
|
||
|
|
key,
|
||
|
|
nextLanePrices[key],
|
||
|
|
nextPromptPrice,
|
||
|
|
nextLanePrices
|
||
|
|
)
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const handlePromptPriceChange = (value: string) => {
|
||
|
|
if (!numericDraftRegex.test(value)) return
|
||
|
|
setPromptPrice(value)
|
||
|
|
syncLaneRatios(value, lanePrices, laneEnabled)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleLanePriceChange = (lane: LaneKey, value: string) => {
|
||
|
|
if (!numericDraftRegex.test(value)) return
|
||
|
|
const nextLanePrices = { ...lanePrices, [lane]: value }
|
||
|
|
setLanePrices(nextLanePrices)
|
||
|
|
|
||
|
|
if (laneEnabled[lane]) {
|
||
|
|
setFormValue(
|
||
|
|
ratioFieldByLane[lane],
|
||
|
|
deriveLaneRatio(lane, value, promptPrice, nextLanePrices)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (lane === 'audioInput' && laneEnabled.audioOutput) {
|
||
|
|
setFormValue(
|
||
|
|
'audioCompletionRatio',
|
||
|
|
deriveLaneRatio(
|
||
|
|
'audioOutput',
|
||
|
|
nextLanePrices.audioOutput,
|
||
|
|
promptPrice,
|
||
|
|
nextLanePrices
|
||
|
|
)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleLaneToggle = (lane: LaneKey, checked: boolean) => {
|
||
|
|
const nextEnabled = { ...laneEnabled, [lane]: checked }
|
||
|
|
let nextPrices = lanePrices
|
||
|
|
|
||
|
|
if (!checked) {
|
||
|
|
nextPrices = { ...nextPrices, [lane]: '' }
|
||
|
|
setFormValue(ratioFieldByLane[lane], '')
|
||
|
|
if (lane === 'audioInput') {
|
||
|
|
nextEnabled.audioOutput = false
|
||
|
|
nextPrices.audioOutput = ''
|
||
|
|
setFormValue('audioCompletionRatio', '')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setLaneEnabled(nextEnabled)
|
||
|
|
setLanePrices(nextPrices)
|
||
|
|
|
||
|
|
if (checked) {
|
||
|
|
setFormValue(
|
||
|
|
ratioFieldByLane[lane],
|
||
|
|
deriveLaneRatio(lane, nextPrices[lane], promptPrice, nextPrices)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleModeChange = (value: string) => {
|
||
|
|
const nextMode = value as PricingMode
|
||
|
|
setPricingMode(nextMode)
|
||
|
|
if (nextMode === 'tiered_expr' && !billingExpr) {
|
||
|
|
setBillingExpr('tier("base", p * 0 + c * 0)')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const watchedValues = form.watch()
|
||
|
|
const previewRows = useMemo(
|
||
|
|
() =>
|
||
|
|
buildPreviewRows(
|
||
|
|
watchedValues,
|
||
|
|
pricingMode,
|
||
|
|
billingExpr,
|
||
|
|
requestRuleExpr,
|
||
|
|
promptPrice,
|
||
|
|
lanePrices,
|
||
|
|
laneEnabled,
|
||
|
|
t
|
||
|
|
),
|
||
|
|
[
|
||
|
|
billingExpr,
|
||
|
|
laneEnabled,
|
||
|
|
lanePrices,
|
||
|
|
pricingMode,
|
||
|
|
promptPrice,
|
||
|
|
requestRuleExpr,
|
||
|
|
t,
|
||
|
|
watchedValues,
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
const warnings = useMemo(() => {
|
||
|
|
const nextWarnings: string[] = []
|
||
|
|
const hasConflict =
|
||
|
|
!!editData?.price &&
|
||
|
|
[
|
||
|
|
editData.ratio,
|
||
|
|
editData.completionRatio,
|
||
|
|
editData.cacheRatio,
|
||
|
|
editData.createCacheRatio,
|
||
|
|
editData.imageRatio,
|
||
|
|
editData.audioRatio,
|
||
|
|
editData.audioCompletionRatio,
|
||
|
|
].some(hasValue)
|
||
|
|
|
||
|
|
if (hasConflict) {
|
||
|
|
nextWarnings.push(
|
||
|
|
t(
|
||
|
|
'This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.'
|
||
|
|
)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
pricingMode === 'per-token' &&
|
||
|
|
toNumberOrNull(promptPrice) === null &&
|
||
|
|
laneConfigs.some(
|
||
|
|
({ key }) => laneEnabled[key] && hasValue(lanePrices[key])
|
||
|
|
)
|
||
|
|
) {
|
||
|
|
nextWarnings.push(
|
||
|
|
t('Input price is required before saving dependent prices.')
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
pricingMode === 'per-token' &&
|
||
|
|
laneEnabled.audioOutput &&
|
||
|
|
!hasValue(lanePrices.audioInput)
|
||
|
|
) {
|
||
|
|
nextWarnings.push(t('Audio output price requires an audio input price.'))
|
||
|
|
}
|
||
|
|
|
||
|
|
return nextWarnings
|
||
|
|
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
|
||
|
|
|
||
|
|
const handleSubmit = (values: ModelPricingFormValues) => {
|
||
|
|
if (
|
||
|
|
pricingMode === 'per-token' &&
|
||
|
|
toNumberOrNull(promptPrice) === null &&
|
||
|
|
laneConfigs.some(
|
||
|
|
({ key }) => laneEnabled[key] && hasValue(lanePrices[key])
|
||
|
|
)
|
||
|
|
) {
|
||
|
|
form.setError('ratio', {
|
||
|
|
message: t('Input price is required before saving dependent prices.'),
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
pricingMode === 'per-token' &&
|
||
|
|
laneEnabled.audioOutput &&
|
||
|
|
!hasValue(lanePrices.audioInput)
|
||
|
|
) {
|
||
|
|
form.setError('audioRatio', {
|
||
|
|
message: t('Audio output price requires an audio input price.'),
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const data: ModelRatioData = {
|
||
|
|
name: values.name.trim(),
|
||
|
|
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()
|
||
|
|
onCancel?.()
|
||
|
|
}
|
||
|
|
|
||
|
|
const activeName = watchedValues.name || editData?.name || t('New model')
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border',
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className='border-b p-4'>
|
||
|
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||
|
|
<div className='min-w-0'>
|
||
|
|
<h3 className='truncate text-base font-medium'>
|
||
|
|
{isEditMode ? t('Edit model pricing') : t('Add model pricing')}
|
||
|
|
</h3>
|
||
|
|
<p className='text-muted-foreground truncate text-sm'>
|
||
|
|
{activeName}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Badge variant={getModeBadgeVariant(pricingMode)}>
|
||
|
|
{t(getModeLabel(pricingMode))}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Form {...form}>
|
||
|
|
<form
|
||
|
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||
|
|
className='flex min-h-0 flex-1 flex-col'
|
||
|
|
autoComplete='off'
|
||
|
|
>
|
||
|
|
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
|
||
|
|
<FieldGroup>
|
||
|
|
{warnings.length > 0 && (
|
||
|
|
<Alert variant='destructive'>
|
||
|
|
<AlertTriangle data-icon='inline-start' />
|
||
|
|
<AlertDescription>
|
||
|
|
<div className='flex flex-col gap-1'>
|
||
|
|
{warnings.map((warning) => (
|
||
|
|
<span key={warning}>{warning}</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<Tabs value={pricingMode} onValueChange={handleModeChange}>
|
||
|
|
<TabsList className='grid w-full grid-cols-3'>
|
||
|
|
<TabsTrigger value='per-token'>{t('Per-token')}</TabsTrigger>
|
||
|
|
<TabsTrigger value='per-request'>
|
||
|
|
{t('Per-request')}
|
||
|
|
</TabsTrigger>
|
||
|
|
<TabsTrigger value='tiered_expr'>
|
||
|
|
{t('Expression')}
|
||
|
|
</TabsTrigger>
|
||
|
|
</TabsList>
|
||
|
|
|
||
|
|
<TabsContent value='per-token' className='flex flex-col gap-5'>
|
||
|
|
<FieldGroup>
|
||
|
|
<Field>
|
||
|
|
<FieldLabel>{t('Input price')}</FieldLabel>
|
||
|
|
<PriceInput
|
||
|
|
value={promptPrice}
|
||
|
|
placeholder='3'
|
||
|
|
onChange={handlePromptPriceChange}
|
||
|
|
/>
|
||
|
|
<FieldDescription>
|
||
|
|
{t('USD price per 1M input tokens.')}
|
||
|
|
</FieldDescription>
|
||
|
|
</Field>
|
||
|
|
|
||
|
|
<div className='grid gap-3 sm:grid-cols-2'>
|
||
|
|
{laneConfigs.map((lane) => {
|
||
|
|
const disabled =
|
||
|
|
lane.key === 'audioOutput' &&
|
||
|
|
(!laneEnabled.audioInput ||
|
||
|
|
!hasValue(lanePrices.audioInput))
|
||
|
|
return (
|
||
|
|
<PriceLane
|
||
|
|
key={lane.key}
|
||
|
|
title={t(lane.titleKey)}
|
||
|
|
description={t(lane.descriptionKey)}
|
||
|
|
placeholder={lane.placeholder}
|
||
|
|
value={lanePrices[lane.key]}
|
||
|
|
enabled={laneEnabled[lane.key]}
|
||
|
|
disabled={disabled}
|
||
|
|
onEnabledChange={(checked) =>
|
||
|
|
handleLaneToggle(lane.key, checked)
|
||
|
|
}
|
||
|
|
onChange={(value) =>
|
||
|
|
handleLanePriceChange(lane.key, value)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</FieldGroup>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
<TabsContent
|
||
|
|
value='per-request'
|
||
|
|
className='flex flex-col gap-5'
|
||
|
|
>
|
||
|
|
<FormField
|
||
|
|
control={form.control}
|
||
|
|
name='price'
|
||
|
|
render={({ field }) => (
|
||
|
|
<FormItem>
|
||
|
|
<FormLabel>{t('Fixed price')}</FormLabel>
|
||
|
|
<FormControl>
|
||
|
|
<InputGroup>
|
||
|
|
<InputGroupAddon>$</InputGroupAddon>
|
||
|
|
<InputGroupInput
|
||
|
|
inputMode='decimal'
|
||
|
|
placeholder='0.01'
|
||
|
|
{...field}
|
||
|
|
onChange={(event) => {
|
||
|
|
const value = event.target.value
|
||
|
|
if (numericDraftRegex.test(value)) {
|
||
|
|
field.onChange(value)
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<InputGroupAddon align='inline-end'>
|
||
|
|
{t('per request')}
|
||
|
|
</InputGroupAddon>
|
||
|
|
</InputGroup>
|
||
|
|
</FormControl>
|
||
|
|
<FormDescription>
|
||
|
|
{t(
|
||
|
|
'Cost in USD per request, regardless of tokens used.'
|
||
|
|
)}
|
||
|
|
</FormDescription>
|
||
|
|
<FormMessage />
|
||
|
|
</FormItem>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
<TabsContent
|
||
|
|
value='tiered_expr'
|
||
|
|
className='flex flex-col gap-5'
|
||
|
|
>
|
||
|
|
<TieredPricingEditor
|
||
|
|
modelName={watchedValues.name}
|
||
|
|
billingExpr={billingExpr}
|
||
|
|
requestRuleExpr={requestRuleExpr}
|
||
|
|
onBillingExprChange={setBillingExpr}
|
||
|
|
onRequestRuleExprChange={setRequestRuleExpr}
|
||
|
|
/>
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
|
||
|
|
<Collapsible open={previewOpen} onOpenChange={setPreviewOpen}>
|
||
|
|
<CollapsibleTrigger
|
||
|
|
render={
|
||
|
|
<Button
|
||
|
|
type='button'
|
||
|
|
variant='outline'
|
||
|
|
className='flex w-full justify-between'
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<span>{t('Save preview')}</span>
|
||
|
|
<ChevronDown
|
||
|
|
className={cn(
|
||
|
|
'transition-transform',
|
||
|
|
previewOpen && 'rotate-180'
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</CollapsibleTrigger>
|
||
|
|
<CollapsibleContent className='pt-3'>
|
||
|
|
<div className='rounded-lg border'>
|
||
|
|
{previewRows.map((row) => (
|
||
|
|
<div
|
||
|
|
key={row.key}
|
||
|
|
className='grid grid-cols-[140px_1fr] gap-3 border-b px-3 py-2 text-sm last:border-b-0'
|
||
|
|
>
|
||
|
|
<span className='text-muted-foreground text-xs'>
|
||
|
|
{row.label}
|
||
|
|
</span>
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
'min-w-0',
|
||
|
|
row.multiline
|
||
|
|
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
||
|
|
: 'truncate'
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{row.value}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</CollapsibleContent>
|
||
|
|
</Collapsible>
|
||
|
|
</FieldGroup>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<SheetFooter className='bg-background/95 border-t sm:flex-row sm:items-center sm:justify-between'>
|
||
|
|
<div className='text-muted-foreground text-xs'>
|
||
|
|
{selectedTargetCount > 0
|
||
|
|
? t('{{count}} selected targets available for bulk copy.', {
|
||
|
|
count: selectedTargetCount,
|
||
|
|
})
|
||
|
|
: t('Changes are written to the settings draft on save.')}
|
||
|
|
</div>
|
||
|
|
<div className='flex justify-end gap-2'>
|
||
|
|
<Button type='button' variant='outline' onClick={onCancel}>
|
||
|
|
{t('Cancel')}
|
||
|
|
</Button>
|
||
|
|
<Button type='submit'>
|
||
|
|
{isEditMode ? t('Update') : t('Add')}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</SheetFooter>
|
||
|
|
</form>
|
||
|
|
</Form>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function PriceInput(props: {
|
||
|
|
value: string
|
||
|
|
placeholder?: string
|
||
|
|
disabled?: boolean
|
||
|
|
onChange: (value: string) => void
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<InputGroup>
|
||
|
|
<InputGroupAddon>$</InputGroupAddon>
|
||
|
|
<InputGroupInput
|
||
|
|
inputMode='decimal'
|
||
|
|
value={props.value}
|
||
|
|
placeholder={props.placeholder}
|
||
|
|
disabled={props.disabled}
|
||
|
|
onChange={(event) => props.onChange(event.target.value)}
|
||
|
|
/>
|
||
|
|
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
|
||
|
|
</InputGroup>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function PriceLane(props: {
|
||
|
|
title: string
|
||
|
|
description: string
|
||
|
|
placeholder: string
|
||
|
|
value: string
|
||
|
|
enabled: boolean
|
||
|
|
disabled?: boolean
|
||
|
|
onEnabledChange: (checked: boolean) => void
|
||
|
|
onChange: (value: string) => void
|
||
|
|
}) {
|
||
|
|
const { t } = useTranslation()
|
||
|
|
const effectiveDisabled = props.disabled || !props.enabled
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Field
|
||
|
|
className={cn(
|
||
|
|
'rounded-lg border p-3',
|
||
|
|
effectiveDisabled && 'bg-muted/35'
|
||
|
|
)}
|
||
|
|
data-disabled={effectiveDisabled || undefined}
|
||
|
|
>
|
||
|
|
<div className='flex items-start justify-between gap-3'>
|
||
|
|
<FieldContent>
|
||
|
|
<FieldTitle>{props.title}</FieldTitle>
|
||
|
|
<FieldDescription>{props.description}</FieldDescription>
|
||
|
|
</FieldContent>
|
||
|
|
<Switch
|
||
|
|
checked={props.enabled}
|
||
|
|
disabled={props.disabled}
|
||
|
|
onCheckedChange={props.onEnabledChange}
|
||
|
|
aria-label={props.title}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<PriceInput
|
||
|
|
value={props.value}
|
||
|
|
placeholder={props.placeholder}
|
||
|
|
disabled={effectiveDisabled}
|
||
|
|
onChange={props.onChange}
|
||
|
|
/>
|
||
|
|
<FieldDescription>
|
||
|
|
{props.enabled
|
||
|
|
? t('USD price per 1M tokens.')
|
||
|
|
: t('Disabled lanes are omitted on save.')}
|
||
|
|
</FieldDescription>
|
||
|
|
</Field>
|
||
|
|
)
|
||
|
|
}
|