Refine the default system settings structure and model pricing editor so pricing configuration is easier to scan and edit.
1033 lines
29 KiB
TypeScript
Vendored
1033 lines
29 KiB
TypeScript
Vendored
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>
|
|
)
|
|
}
|