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 > 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 = { completion: '', cache: '', createCache: '', image: '', audioInput: '', audioOutput: '', } const EMPTY_LANE_ENABLED: Record = { completion: false, cache: false, createCache: false, image: false, audioInput: false, audioOutput: false, } const ratioFieldByLane: Record = { 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 = { 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, laneEnabled: Record, 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 ( {title} {description} { onCancel?.() onOpenChange(false) }} className='h-full rounded-none border-0' /> ) } export function ModelPricingEditorPanel({ onSave, editData, selectedTargetCount = 0, onCancel, className, }: ModelPricingEditorPanelProps) { const { t } = useTranslation() const [pricingMode, setPricingMode] = useState('per-token') const [promptPrice, setPromptPrice] = useState('') const [lanePrices, setLanePrices] = useState>({ ...EMPTY_LANE_PRICES, }) const [laneEnabled, setLaneEnabled] = useState>({ ...EMPTY_LANE_ENABLED, }) const [billingExpr, setBillingExpr] = useState('') const [requestRuleExpr, setRequestRuleExpr] = useState('') const [previewOpen, setPreviewOpen] = useState(true) const isEditMode = !!editData const form = useForm({ 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 (

{isEditMode ? t('Edit model pricing') : t('Add model pricing')}

{activeName}

{t(getModeLabel(pricingMode))}
{warnings.length > 0 && (
{warnings.map((warning) => ( {warning} ))}
)} ( {t('Model name')} {t('The exact model identifier as used in API requests.')} )} /> {t('Per-token')} {t('Per-request')} {t('Expression')} {t('Input price')} {t('USD price per 1M input tokens.')}
{laneConfigs.map((lane) => { const disabled = lane.key === 'audioOutput' && (!laneEnabled.audioInput || !hasValue(lanePrices.audioInput)) return ( handleLaneToggle(lane.key, checked) } onChange={(value) => handleLanePriceChange(lane.key, value) } /> ) })}
( {t('Fixed price')} $ { const value = event.target.value if (numericDraftRegex.test(value)) { field.onChange(value) } }} /> {t('per request')} {t( 'Cost in USD per request, regardless of tokens used.' )} )} />
} > {t('Save preview')}
{previewRows.map((row) => (
{row.label} {row.value}
))}
{selectedTargetCount > 0 ? t('{{count}} selected targets available for bulk copy.', { count: selectedTargetCount, }) : t('Changes are written to the settings draft on save.')}
) } function PriceInput(props: { value: string placeholder?: string disabled?: boolean onChange: (value: string) => void }) { return ( $ props.onChange(event.target.value)} /> $/1M ) } 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 (
{props.title} {props.description}
{props.enabled ? t('USD price per 1M tokens.') : t('Disabled lanes are omitted on save.')}
) }